@pyreon/compiler 0.13.1 → 0.15.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.
@@ -0,0 +1,728 @@
1
+ /**
2
+ * Pyreon Pattern Interceptor — detects Pyreon-specific anti-patterns in
3
+ * code that has ALREADY committed to the framework (imports are Pyreon,
4
+ * not React). Complements `react-intercept.ts` — the React detector
5
+ * catches "coming from React" mistakes; this one catches "using Pyreon
6
+ * wrong" mistakes.
7
+ *
8
+ * Catalog of detected patterns (grounded in `.claude/rules/anti-patterns.md`):
9
+ *
10
+ * - `for-missing-by` — `<For each={...}>` without a `by` prop
11
+ * - `for-with-key` — `<For key={...}>` (JSX reserves `key`; the keying
12
+ * prop is `by` in Pyreon)
13
+ * - `props-destructured` — `({ foo }: Props) => <JSX />` destructures at
14
+ * the component signature; reading is captured once
15
+ * and loses reactivity. Access `props.foo` instead
16
+ * or use `splitProps(props, [...])`.
17
+ * - `process-dev-gate` — `typeof process !== 'undefined' &&
18
+ * process.env.NODE_ENV !== 'production'` is dead
19
+ * code in real Vite browser bundles. Use
20
+ * `import.meta.env?.DEV` instead.
21
+ * - `empty-theme` — `.theme({})` chain is a no-op; remove it.
22
+ * - `raw-add-event-listener` — raw `addEventListener(...)` in a component
23
+ * or hook body. Use `useEventListener(...)` from
24
+ * `@pyreon/hooks` for auto-cleanup.
25
+ * - `raw-remove-event-listener` — same, for removeEventListener.
26
+ * - `date-math-random-id` — `Date.now() + Math.random()` / template-concat
27
+ * variants. Under rapid operations (paste, clone)
28
+ * collision probability is non-trivial. Use a
29
+ * monotonic counter.
30
+ * - `on-click-undefined` — `onClick={undefined}` explicitly; the runtime
31
+ * used to crash on this pattern. Omit the prop.
32
+ * - `signal-write-as-call` — `sig(value)` is a no-op read that ignores
33
+ * its argument; the runtime warns in dev. Static
34
+ * detector spots it pre-runtime when `sig` was
35
+ * declared as `const sig = signal(...)` /
36
+ * `computed(...)` and called with ≥1 argument.
37
+ * - `static-return-null-conditional` — `if (cond) return null` at the
38
+ * top of a component body runs ONCE; signal changes
39
+ * in `cond` never re-evaluate the early-return.
40
+ * Wrap in a returned reactive accessor.
41
+ * - `as-unknown-as-vnodechild` — defensive `as unknown as VNodeChild`
42
+ * cast on JSX returns is unnecessary (`JSX.Element`
43
+ * is already assignable to `VNodeChild`).
44
+ *
45
+ * Two-mode surface mirrors `react-intercept.ts`:
46
+ * - `detectPyreonPatterns(code)` — diagnostics only
47
+ * - `hasPyreonPatterns(code)` — fast regex pre-filter
48
+ *
49
+ * ## fixable: false (invariant)
50
+ *
51
+ * Every Pyreon diagnostic reports `fixable: false` — no exceptions.
52
+ * The `migrate_react` MCP tool only knows React mappings, so claiming
53
+ * a Pyreon code is auto-fixable would mislead a consumer who wires
54
+ * their UX off the flag and finds nothing applies the fix. Flip to
55
+ * `true` ONLY when a companion `migrate_pyreon` tool ships in a
56
+ * subsequent PR. The invariant is locked in
57
+ * `tests/pyreon-intercept.test.ts` under "fixable contract".
58
+ *
59
+ * Designed for three consumers:
60
+ * 1. Compiler pre-pass warnings during build
61
+ * 2. CLI `pyreon doctor`
62
+ * 3. MCP server `validate` tool
63
+ */
64
+
65
+ import ts from 'typescript'
66
+
67
+ // ═══════════════════════════════════════════════════════════════════════════════
68
+ // Types
69
+ // ═══════════════════════════════════════════════════════════════════════════════
70
+
71
+ export type PyreonDiagnosticCode =
72
+ | 'for-missing-by'
73
+ | 'for-with-key'
74
+ | 'props-destructured'
75
+ | 'process-dev-gate'
76
+ | 'empty-theme'
77
+ | 'raw-add-event-listener'
78
+ | 'raw-remove-event-listener'
79
+ | 'date-math-random-id'
80
+ | 'on-click-undefined'
81
+ | 'signal-write-as-call'
82
+ | 'static-return-null-conditional'
83
+ | 'as-unknown-as-vnodechild'
84
+
85
+ export interface PyreonDiagnostic {
86
+ /** Machine-readable code for filtering + programmatic handling */
87
+ code: PyreonDiagnosticCode
88
+ /** Human-readable message explaining the issue */
89
+ message: string
90
+ /** 1-based line number */
91
+ line: number
92
+ /** 0-based column */
93
+ column: number
94
+ /** The code as written */
95
+ current: string
96
+ /** The suggested Pyreon fix */
97
+ suggested: string
98
+ /** Whether a mechanical auto-fix is safe */
99
+ fixable: boolean
100
+ }
101
+
102
+ // ═══════════════════════════════════════════════════════════════════════════════
103
+ // Detection context
104
+ // ═══════════════════════════════════════════════════════════════════════════════
105
+
106
+ interface DetectContext {
107
+ sf: ts.SourceFile
108
+ code: string
109
+ diagnostics: PyreonDiagnostic[]
110
+ /**
111
+ * Identifiers bound to `signal(...)` or `computed(...)` calls anywhere in
112
+ * the file. Populated by `collectSignalBindings()` before the main
113
+ * detection walk. Used by `detectSignalWriteAsCall` to flag `sig(value)`
114
+ * patterns that should be `sig.set(value)`.
115
+ */
116
+ signalBindings: Set<string>
117
+ }
118
+
119
+ function getNodeText(ctx: DetectContext, node: ts.Node): string {
120
+ return ctx.code.slice(node.getStart(ctx.sf), node.getEnd())
121
+ }
122
+
123
+ function pushDiag(
124
+ ctx: DetectContext,
125
+ node: ts.Node,
126
+ code: PyreonDiagnosticCode,
127
+ message: string,
128
+ current: string,
129
+ suggested: string,
130
+ fixable: boolean,
131
+ ): void {
132
+ const { line, character } = ctx.sf.getLineAndCharacterOfPosition(node.getStart(ctx.sf))
133
+ ctx.diagnostics.push({
134
+ code,
135
+ message,
136
+ line: line + 1,
137
+ column: character,
138
+ current: current.trim(),
139
+ suggested: suggested.trim(),
140
+ fixable,
141
+ })
142
+ }
143
+
144
+ // ═══════════════════════════════════════════════════════════════════════════════
145
+ // JSX helpers
146
+ // ═══════════════════════════════════════════════════════════════════════════════
147
+
148
+ function getJsxTagName(node: ts.JsxOpeningLikeElement): string {
149
+ const t = node.tagName
150
+ if (ts.isIdentifier(t)) return t.text
151
+ return ''
152
+ }
153
+
154
+ function findJsxAttribute(
155
+ node: ts.JsxOpeningLikeElement,
156
+ name: string,
157
+ ): ts.JsxAttribute | undefined {
158
+ for (const attr of node.attributes.properties) {
159
+ if (ts.isJsxAttribute(attr) && ts.isIdentifier(attr.name) && attr.name.text === name) {
160
+ return attr
161
+ }
162
+ }
163
+ return undefined
164
+ }
165
+
166
+ // ═══════════════════════════════════════════════════════════════════════════════
167
+ // Pattern: <For> without `by` / with `key`
168
+ // ═══════════════════════════════════════════════════════════════════════════════
169
+
170
+ function detectForKeying(ctx: DetectContext, node: ts.JsxOpeningLikeElement): void {
171
+ if (getJsxTagName(node) !== 'For') return
172
+
173
+ const keyAttr = findJsxAttribute(node, 'key')
174
+ if (keyAttr) {
175
+ pushDiag(
176
+ ctx,
177
+ keyAttr,
178
+ 'for-with-key',
179
+ '`key` on <For> is reserved by JSX for VNode reconciliation and is extracted before the prop reaches the runtime. In Pyreon, use `by` for list identity.',
180
+ getNodeText(ctx, keyAttr),
181
+ getNodeText(ctx, keyAttr).replace(/^key\b/, 'by'),
182
+ // fixable remains `false` until a `migrate_pyreon` tool exists —
183
+ // today the MCP only ships `migrate_react`, so claiming auto-fix
184
+ // here would mislead consumers building on the flag.
185
+ false,
186
+ )
187
+ }
188
+
189
+ const eachAttr = findJsxAttribute(node, 'each')
190
+ const byAttr = findJsxAttribute(node, 'by')
191
+ if (eachAttr && !byAttr && !keyAttr) {
192
+ pushDiag(
193
+ ctx,
194
+ node,
195
+ 'for-missing-by',
196
+ '<For each={...}> requires a `by` prop so the keyed reconciler can preserve item identity across reorders. Without `by`, every update remounts the full list.',
197
+ getNodeText(ctx, node),
198
+ '<For each={items} by={(item) => item.id}>',
199
+ false,
200
+ )
201
+ }
202
+ }
203
+
204
+ // ═══════════════════════════════════════════════════════════════════════════════
205
+ // Pattern: destructured props in component signature
206
+ // ═══════════════════════════════════════════════════════════════════════════════
207
+
208
+ function containsJsx(node: ts.Node): boolean {
209
+ let found = false
210
+ function walk(n: ts.Node): void {
211
+ if (found) return
212
+ if (
213
+ ts.isJsxElement(n) ||
214
+ ts.isJsxSelfClosingElement(n) ||
215
+ ts.isJsxFragment(n) ||
216
+ ts.isJsxOpeningElement(n)
217
+ ) {
218
+ found = true
219
+ return
220
+ }
221
+ ts.forEachChild(n, walk)
222
+ }
223
+ ts.forEachChild(node, walk)
224
+ // Also allow expression-body arrow fns
225
+ if (!found) {
226
+ if (
227
+ ts.isArrowFunction(node) &&
228
+ !ts.isBlock(node.body) &&
229
+ (ts.isJsxElement(node.body) ||
230
+ ts.isJsxSelfClosingElement(node.body) ||
231
+ ts.isJsxFragment(node.body))
232
+ ) {
233
+ found = true
234
+ }
235
+ }
236
+ return found
237
+ }
238
+
239
+ function detectPropsDestructured(
240
+ ctx: DetectContext,
241
+ node: ts.ArrowFunction | ts.FunctionDeclaration | ts.FunctionExpression,
242
+ ): void {
243
+ if (!node.parameters.length) return
244
+ const first = node.parameters[0]
245
+ if (!first || !ts.isObjectBindingPattern(first.name)) return
246
+ if (first.name.elements.length === 0) return
247
+
248
+ // Heuristic: only flag functions that actually render JSX (component
249
+ // functions), not arbitrary callbacks that happen to destructure an
250
+ // options bag.
251
+ if (!containsJsx(node)) return
252
+
253
+ pushDiag(
254
+ ctx,
255
+ first,
256
+ 'props-destructured',
257
+ 'Destructuring props at the component signature captures the values ONCE during setup — subsequent signal writes in the parent do not update the destructured locals. Access `props.x` directly, or use `splitProps(props, [...])` to carve out a group while preserving reactivity.',
258
+ getNodeText(ctx, first),
259
+ '(props: Props) => /* read props.x directly */',
260
+ false,
261
+ )
262
+ }
263
+
264
+ // ═══════════════════════════════════════════════════════════════════════════════
265
+ // Pattern: typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'
266
+ // ═══════════════════════════════════════════════════════════════════════════════
267
+
268
+ function isTypeofProcess(node: ts.Expression): boolean {
269
+ if (!ts.isBinaryExpression(node)) return false
270
+ if (node.operatorToken.kind !== ts.SyntaxKind.ExclamationEqualsEqualsToken) return false
271
+ if (!ts.isTypeOfExpression(node.left)) return false
272
+ if (!ts.isIdentifier(node.left.expression) || node.left.expression.text !== 'process') return false
273
+ return ts.isStringLiteral(node.right) && node.right.text === 'undefined'
274
+ }
275
+
276
+ function isProcessNodeEnvProdGuard(node: ts.Expression): boolean {
277
+ if (!ts.isBinaryExpression(node)) return false
278
+ if (node.operatorToken.kind !== ts.SyntaxKind.ExclamationEqualsEqualsToken) return false
279
+ // process.env.NODE_ENV
280
+ const left = node.left
281
+ if (!ts.isPropertyAccessExpression(left)) return false
282
+ if (!ts.isIdentifier(left.name) || left.name.text !== 'NODE_ENV') return false
283
+ if (!ts.isPropertyAccessExpression(left.expression)) return false
284
+ if (
285
+ !ts.isIdentifier(left.expression.name) ||
286
+ left.expression.name.text !== 'env'
287
+ ) {
288
+ return false
289
+ }
290
+ if (!ts.isIdentifier(left.expression.expression)) return false
291
+ if (left.expression.expression.text !== 'process') return false
292
+ return ts.isStringLiteral(node.right) && node.right.text === 'production'
293
+ }
294
+
295
+ function detectProcessDevGate(ctx: DetectContext, node: ts.BinaryExpression): void {
296
+ if (node.operatorToken.kind !== ts.SyntaxKind.AmpersandAmpersandToken) return
297
+ // left: typeof process !== 'undefined', right: process.env.NODE_ENV !== 'production'
298
+ // (or either side in either order)
299
+ const match =
300
+ (isTypeofProcess(node.left) && isProcessNodeEnvProdGuard(node.right)) ||
301
+ (isTypeofProcess(node.right) && isProcessNodeEnvProdGuard(node.left))
302
+ if (!match) return
303
+
304
+ pushDiag(
305
+ ctx,
306
+ node,
307
+ 'process-dev-gate',
308
+ 'The `typeof process !== "undefined" && process.env.NODE_ENV !== "production"` gate is DEAD CODE in real Vite browser bundles — Vite does not polyfill `process`. Unit tests pass (vitest has `process`) but the warning never fires in production. Use `import.meta.env?.DEV` instead, which Vite literal-replaces at build time.',
309
+ getNodeText(ctx, node),
310
+ 'import.meta.env?.DEV === true',
311
+ // No `migrate_pyreon` tool yet — claiming fixable would mislead.
312
+ false,
313
+ )
314
+ }
315
+
316
+ // ═══════════════════════════════════════════════════════════════════════════════
317
+ // Pattern: .theme({}) empty chain
318
+ // ═══════════════════════════════════════════════════════════════════════════════
319
+
320
+ function detectEmptyTheme(ctx: DetectContext, node: ts.CallExpression): void {
321
+ const callee = node.expression
322
+ if (!ts.isPropertyAccessExpression(callee)) return
323
+ if (!ts.isIdentifier(callee.name) || callee.name.text !== 'theme') return
324
+ if (node.arguments.length !== 1) return
325
+ const arg = node.arguments[0]
326
+ if (!arg || !ts.isObjectLiteralExpression(arg)) return
327
+ if (arg.properties.length !== 0) return
328
+
329
+ pushDiag(
330
+ ctx,
331
+ node,
332
+ 'empty-theme',
333
+ '`.theme({})` is a no-op chain. If the component needs no base theme, skip `.theme()` entirely rather than calling it with an empty object.',
334
+ getNodeText(ctx, node),
335
+ getNodeText(ctx, callee.expression),
336
+ // No `migrate_pyreon` tool yet — claiming fixable would mislead.
337
+ false,
338
+ )
339
+ }
340
+
341
+ // ═══════════════════════════════════════════════════════════════════════════════
342
+ // Pattern: raw addEventListener / removeEventListener
343
+ // ═══════════════════════════════════════════════════════════════════════════════
344
+
345
+ function detectRawEventListener(ctx: DetectContext, node: ts.CallExpression): void {
346
+ const callee = node.expression
347
+ if (!ts.isPropertyAccessExpression(callee)) return
348
+ if (!ts.isIdentifier(callee.name)) return
349
+ const method = callee.name.text
350
+ if (method !== 'addEventListener' && method !== 'removeEventListener') return
351
+
352
+ // Only flag when the target is `window` / `document` / an identifier
353
+ // that looks like a DOM element. Property-access chains (e.g.
354
+ // `editor.dom.addEventListener`) are generally CodeMirror / framework
355
+ // hosts — leave those alone.
356
+ const target = callee.expression
357
+ const targetName = ts.isIdentifier(target)
358
+ ? target.text
359
+ : ts.isPropertyAccessExpression(target) && ts.isIdentifier(target.name)
360
+ ? target.name.text
361
+ : ''
362
+
363
+ const flagTargets = new Set(['window', 'document', 'body', 'el', 'element', 'node', 'target'])
364
+ if (!flagTargets.has(targetName)) return
365
+
366
+ if (method === 'addEventListener') {
367
+ pushDiag(
368
+ ctx,
369
+ node,
370
+ 'raw-add-event-listener',
371
+ 'Raw `addEventListener` in a component / hook body bypasses Pyreon\'s lifecycle cleanup — listeners leak on unmount. Use `useEventListener` from `@pyreon/hooks` for auto-cleanup.',
372
+ getNodeText(ctx, node),
373
+ 'useEventListener(target, event, handler)',
374
+ false,
375
+ )
376
+ } else {
377
+ pushDiag(
378
+ ctx,
379
+ node,
380
+ 'raw-remove-event-listener',
381
+ 'Raw `removeEventListener` is the symptom of manual listener management. Replace the paired `addEventListener` with `useEventListener` from `@pyreon/hooks` — it registers the cleanup automatically.',
382
+ getNodeText(ctx, node),
383
+ 'useEventListener(target, event, handler) // cleanup is automatic',
384
+ false,
385
+ )
386
+ }
387
+ }
388
+
389
+ // ═══════════════════════════════════════════════════════════════════════════════
390
+ // Pattern: Date.now() + Math.random() for IDs
391
+ // ═══════════════════════════════════════════════════════════════════════════════
392
+
393
+ function isCallTo(node: ts.Node, object: string, method: string): boolean {
394
+ return (
395
+ ts.isCallExpression(node) &&
396
+ ts.isPropertyAccessExpression(node.expression) &&
397
+ ts.isIdentifier(node.expression.expression) &&
398
+ node.expression.expression.text === object &&
399
+ ts.isIdentifier(node.expression.name) &&
400
+ node.expression.name.text === method
401
+ )
402
+ }
403
+
404
+ function subtreeHas(node: ts.Node, predicate: (n: ts.Node) => boolean): boolean {
405
+ let found = false
406
+ function walk(n: ts.Node): void {
407
+ if (found) return
408
+ if (predicate(n)) {
409
+ found = true
410
+ return
411
+ }
412
+ ts.forEachChild(n, walk)
413
+ }
414
+ walk(node)
415
+ return found
416
+ }
417
+
418
+ function detectDateMathRandomId(ctx: DetectContext, node: ts.Expression): void {
419
+ const hasDate = subtreeHas(node, (n) => isCallTo(n, 'Date', 'now'))
420
+ if (!hasDate) return
421
+ const hasRandom = subtreeHas(node, (n) => isCallTo(n, 'Math', 'random'))
422
+ if (!hasRandom) return
423
+
424
+ pushDiag(
425
+ ctx,
426
+ node,
427
+ 'date-math-random-id',
428
+ 'Combining `Date.now()` + `Math.random()` for unique IDs is collision-prone under rapid operations (paste, clone) — `Date.now()` returns the same value within a millisecond and `Math.random().toString(36).slice(2, 6)` has only ~1.67M combinations. Use a monotonic counter instead.',
429
+ getNodeText(ctx, node),
430
+ 'let _counter = 0; const nextId = () => String(++_counter)',
431
+ false,
432
+ )
433
+ }
434
+
435
+ // ═══════════════════════════════════════════════════════════════════════════════
436
+ // Pattern: onClick={undefined}
437
+ // ═══════════════════════════════════════════════════════════════════════════════
438
+
439
+ function detectOnClickUndefined(ctx: DetectContext, node: ts.JsxAttribute): void {
440
+ if (!ts.isIdentifier(node.name)) return
441
+ const attrName = node.name.text
442
+ if (!attrName.startsWith('on') || attrName.length < 3) return
443
+ if (!node.initializer || !ts.isJsxExpression(node.initializer)) return
444
+ const expr = node.initializer.expression
445
+ if (!expr) return
446
+ const isExplicitUndefined =
447
+ (ts.isIdentifier(expr) && expr.text === 'undefined') ||
448
+ expr.kind === ts.SyntaxKind.VoidExpression
449
+
450
+ if (!isExplicitUndefined) return
451
+
452
+ pushDiag(
453
+ ctx,
454
+ node,
455
+ 'on-click-undefined',
456
+ `\`${attrName}={undefined}\` explicitly passes undefined as a listener. Pyreon's runtime guards against this, but the cleanest pattern is to omit the attribute entirely or use a conditional: \`${attrName}={condition ? handler : undefined}\`.`,
457
+ getNodeText(ctx, node),
458
+ `/* omit ${attrName} when the handler is not defined */`,
459
+ // No `migrate_pyreon` tool yet — claiming fixable would mislead.
460
+ false,
461
+ )
462
+ }
463
+
464
+ // ═══════════════════════════════════════════════════════════════════════════════
465
+ // Pattern: signal-write-as-call (sig(value) instead of sig.set(value))
466
+ // ═══════════════════════════════════════════════════════════════════════════════
467
+
468
+ /**
469
+ * Walks the file and collects every identifier bound to a `signal(...)` or
470
+ * `computed(...)` call. Only `const` declarations are tracked — `let`/`var`
471
+ * may be reassigned to non-signal values, so a use-site call wouldn't be a
472
+ * reliable signal-write.
473
+ *
474
+ * The collection is intentionally scope-blind: a name shadowed in a nested
475
+ * scope (`const x = signal(0); function f() { const x = 5; x(7) }`) would
476
+ * produce a false positive on `x(7)`. That tradeoff is acceptable because
477
+ * (1) shadowing a signal name with a non-signal is itself unusual and
478
+ * (2) the detector message points at exactly the wrong-shape call so a
479
+ * human reviewer can dismiss the rare false positive in seconds.
480
+ */
481
+ function collectSignalBindings(sf: ts.SourceFile): Set<string> {
482
+ const names = new Set<string>()
483
+ function isSignalFactoryCall(init: ts.Expression | undefined): boolean {
484
+ if (!init || !ts.isCallExpression(init)) return false
485
+ const callee = init.expression
486
+ if (!ts.isIdentifier(callee)) return false
487
+ return callee.text === 'signal' || callee.text === 'computed'
488
+ }
489
+ function walk(node: ts.Node): void {
490
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
491
+ // Only `const` — find the parent VariableDeclarationList to check.
492
+ const list = node.parent
493
+ if (
494
+ ts.isVariableDeclarationList(list) &&
495
+ (list.flags & ts.NodeFlags.Const) !== 0 &&
496
+ isSignalFactoryCall(node.initializer)
497
+ ) {
498
+ names.add(node.name.text)
499
+ }
500
+ }
501
+ ts.forEachChild(node, walk)
502
+ }
503
+ walk(sf)
504
+ return names
505
+ }
506
+
507
+ function detectSignalWriteAsCall(ctx: DetectContext, node: ts.CallExpression): void {
508
+ if (ctx.signalBindings.size === 0) return
509
+ const callee = node.expression
510
+ if (!ts.isIdentifier(callee)) return
511
+ if (!ctx.signalBindings.has(callee.text)) return
512
+ // `sig()` (zero args) is a READ — that's the intended Pyreon API.
513
+ if (node.arguments.length === 0) return
514
+ // `sig.set(x)` / `sig.update(fn)` / `sig.peek()` — the proper write/read
515
+ // surface — go through PropertyAccess, not direct CallExpression on the
516
+ // identifier. So if we got here, the call is `sig(value)` or
517
+ // `sig(value, ..)` which is the buggy shape.
518
+ pushDiag(
519
+ ctx,
520
+ node,
521
+ 'signal-write-as-call',
522
+ `\`${callee.text}(value)\` does NOT write the signal — \`signal()\` is the read-only callable surface and ignores its arguments. Use \`${callee.text}.set(value)\` to assign or \`${callee.text}.update((prev) => …)\` to derive from the previous value. Pyreon's runtime warns about this pattern in dev, but the warning fires AFTER the silent no-op.`,
523
+ getNodeText(ctx, node),
524
+ `${callee.text}.set(${node.arguments.map((a) => getNodeText(ctx, a)).join(', ')})`,
525
+ false,
526
+ )
527
+ }
528
+
529
+ // ═══════════════════════════════════════════════════════════════════════════════
530
+ // Pattern: static-return-null-conditional in component bodies
531
+ // ═══════════════════════════════════════════════════════════════════════════════
532
+
533
+ /**
534
+ * `if (cond) return null` at the top of a component body runs ONCE — Pyreon
535
+ * components mount and never re-execute their function bodies. A signal
536
+ * change inside `cond` therefore never re-evaluates the condition; the
537
+ * component is permanently stuck on whichever branch the first run picked.
538
+ *
539
+ * The fix is to wrap the conditional in a returned reactive accessor:
540
+ * return (() => { if (!cond()) return null; return <div /> })
541
+ *
542
+ * Detection:
543
+ * - The function contains JSX (i.e. it's a component)
544
+ * - The function body has an `IfStatement` whose `thenStatement` is
545
+ * `return null` (either bare `return null` or `{ return null }`)
546
+ * - The `if` is at the function body's top level, NOT inside a returned
547
+ * arrow / IIFE (those are reactive scopes — flagging them would be a
548
+ * false positive)
549
+ */
550
+ function returnsNullStatement(stmt: ts.Statement): boolean {
551
+ if (ts.isReturnStatement(stmt)) {
552
+ const expr = stmt.expression
553
+ return !!expr && expr.kind === ts.SyntaxKind.NullKeyword
554
+ }
555
+ if (ts.isBlock(stmt)) {
556
+ return stmt.statements.length === 1 && returnsNullStatement(stmt.statements[0]!)
557
+ }
558
+ return false
559
+ }
560
+
561
+ /**
562
+ * Returns true if the function looks like a top-level component:
563
+ * - `function PascalName(...) { ... }` (FunctionDeclaration with PascalCase id), OR
564
+ * - `const PascalName = (...) => { ... }` (arrow inside a VariableDeclaration whose name is PascalCase).
565
+ *
566
+ * Anonymous nested arrows — most importantly the reactive accessor
567
+ * `return (() => { if (!cond()) return null; return <div /> })` — are
568
+ * NOT considered components here, even when they contain JSX. Without
569
+ * this filter the detector would fire on the very pattern the
570
+ * diagnostic recommends as the fix.
571
+ */
572
+ function isComponentShapedFunction(
573
+ node: ts.ArrowFunction | ts.FunctionDeclaration | ts.FunctionExpression,
574
+ ): boolean {
575
+ if (ts.isFunctionDeclaration(node)) {
576
+ return !!node.name && /^[A-Z]/.test(node.name.text)
577
+ }
578
+ // Arrow / FunctionExpression: check VariableDeclaration parent.
579
+ const parent = node.parent
580
+ if (parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) {
581
+ return /^[A-Z]/.test(parent.name.text)
582
+ }
583
+ return false
584
+ }
585
+
586
+ function detectStaticReturnNullConditional(
587
+ ctx: DetectContext,
588
+ node: ts.ArrowFunction | ts.FunctionDeclaration | ts.FunctionExpression,
589
+ ): void {
590
+ // Only component-shaped functions (must render JSX AND be named with
591
+ // PascalCase) — see isComponentShapedFunction for why the name check
592
+ // matters: it filters out the reactive-accessor-as-fix pattern.
593
+ if (!isComponentShapedFunction(node)) return
594
+ if (!containsJsx(node)) return
595
+ const body = node.body
596
+ if (!body || !ts.isBlock(body)) return
597
+
598
+ for (const stmt of body.statements) {
599
+ if (!ts.isIfStatement(stmt)) continue
600
+ if (!returnsNullStatement(stmt.thenStatement)) continue
601
+ // Found `if (cond) return null` at top-level component body scope.
602
+ pushDiag(
603
+ ctx,
604
+ stmt,
605
+ 'static-return-null-conditional',
606
+ 'Pyreon components run ONCE — `if (cond) return null` at the top of a component body is evaluated exactly once at mount. Reading a signal inside `cond` will NOT re-trigger the early return when the signal changes; the component is stuck on whichever branch the first run picked. Wrap the conditional in a returned reactive accessor: `return (() => { if (!cond()) return null; return <div /> })` — the accessor re-runs whenever its tracked signals change.',
607
+ getNodeText(ctx, stmt),
608
+ 'return (() => { if (!cond()) return null; return <JSX /> })',
609
+ false,
610
+ )
611
+ // Only flag the FIRST occurrence per component to avoid noise on
612
+ // chained early-returns (often a single mistake, not three).
613
+ return
614
+ }
615
+ }
616
+
617
+ // ═══════════════════════════════════════════════════════════════════════════════
618
+ // Pattern: `expr as unknown as VNodeChild`
619
+ // ═══════════════════════════════════════════════════════════════════════════════
620
+
621
+ /**
622
+ * `JSX.Element` (which is what JSX evaluates to) is already assignable to
623
+ * `VNodeChild`. The `as unknown as VNodeChild` double-cast is unnecessary
624
+ * — it's been showing up in `@pyreon/ui-primitives` as a defensive habit
625
+ * carried over from earlier framework versions. The cast is never load-
626
+ * bearing today; removing it never changes runtime behavior. Pure cosmetic
627
+ * but a useful proxy for non-idiomatic Pyreon code in primitives.
628
+ */
629
+ function detectAsUnknownAsVNodeChild(ctx: DetectContext, node: ts.AsExpression): void {
630
+ // Outer cast: `... as VNodeChild`
631
+ const outerType = node.type
632
+ if (!ts.isTypeReferenceNode(outerType)) return
633
+ if (!ts.isIdentifier(outerType.typeName) || outerType.typeName.text !== 'VNodeChild') return
634
+ // Inner: `<expr> as unknown`
635
+ const inner = node.expression
636
+ if (!ts.isAsExpression(inner)) return
637
+ if (inner.type.kind !== ts.SyntaxKind.UnknownKeyword) return
638
+
639
+ pushDiag(
640
+ ctx,
641
+ node,
642
+ 'as-unknown-as-vnodechild',
643
+ '`as unknown as VNodeChild` is unnecessary — `JSX.Element` (the type produced by JSX) is already assignable to `VNodeChild`. Remove the double cast; it is pure noise that hides genuine type issues if they ever appear at this site.',
644
+ getNodeText(ctx, node),
645
+ getNodeText(ctx, inner.expression),
646
+ false,
647
+ )
648
+ }
649
+
650
+ // ═══════════════════════════════════════════════════════════════════════════════
651
+ // Visitor
652
+ // ═══════════════════════════════════════════════════════════════════════════════
653
+
654
+ function visitNode(ctx: DetectContext, node: ts.Node): void {
655
+ if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
656
+ detectForKeying(ctx, node)
657
+ }
658
+ if (
659
+ ts.isArrowFunction(node) ||
660
+ ts.isFunctionDeclaration(node) ||
661
+ ts.isFunctionExpression(node)
662
+ ) {
663
+ detectPropsDestructured(ctx, node)
664
+ detectStaticReturnNullConditional(ctx, node)
665
+ }
666
+ if (ts.isBinaryExpression(node)) {
667
+ detectProcessDevGate(ctx, node)
668
+ detectDateMathRandomId(ctx, node)
669
+ }
670
+ if (ts.isTemplateExpression(node)) {
671
+ detectDateMathRandomId(ctx, node)
672
+ }
673
+ if (ts.isCallExpression(node)) {
674
+ detectEmptyTheme(ctx, node)
675
+ detectRawEventListener(ctx, node)
676
+ detectSignalWriteAsCall(ctx, node)
677
+ }
678
+ if (ts.isJsxAttribute(node)) {
679
+ detectOnClickUndefined(ctx, node)
680
+ }
681
+ if (ts.isAsExpression(node)) {
682
+ detectAsUnknownAsVNodeChild(ctx, node)
683
+ }
684
+ }
685
+
686
+ function visit(ctx: DetectContext, node: ts.Node): void {
687
+ ts.forEachChild(node, (child) => {
688
+ visitNode(ctx, child)
689
+ visit(ctx, child)
690
+ })
691
+ }
692
+
693
+ // ═══════════════════════════════════════════════════════════════════════════════
694
+ // Public API
695
+ // ═══════════════════════════════════════════════════════════════════════════════
696
+
697
+ export function detectPyreonPatterns(code: string, filename = 'input.tsx'): PyreonDiagnostic[] {
698
+ const sf = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX)
699
+ const ctx: DetectContext = {
700
+ sf,
701
+ code,
702
+ diagnostics: [],
703
+ signalBindings: collectSignalBindings(sf),
704
+ }
705
+ visit(ctx, sf)
706
+ // Sort by (line, column) for stable ordering when multiple patterns fire.
707
+ ctx.diagnostics.sort((a, b) => a.line - b.line || a.column - b.column)
708
+ return ctx.diagnostics
709
+ }
710
+
711
+ /** Fast regex pre-filter — returns true if the code is worth a full AST walk. */
712
+ export function hasPyreonPatterns(code: string): boolean {
713
+ return (
714
+ /\bFor\b[^=]*\beach\s*=/.test(code) ||
715
+ /\btypeof\s+process\b/.test(code) ||
716
+ /\.theme\s*\(\s*\{\s*\}\s*\)/.test(code) ||
717
+ /\b(?:add|remove)EventListener\s*\(/.test(code) ||
718
+ (/\bDate\.now\s*\(/.test(code) && /\bMath\.random\s*\(/.test(code)) ||
719
+ /on[A-Z]\w*\s*=\s*\{\s*undefined\s*\}/.test(code) ||
720
+ /=\s*\(\s*\{[^}]+\}\s*[:)]/.test(code) ||
721
+ // signal-write-as-call: `const X = signal(` declaration anywhere
722
+ /\b(?:signal|computed)\s*[<(]/.test(code) ||
723
+ // static-return-null-conditional: `if (...) return null` anywhere
724
+ /\bif\s*\([^)]+\)\s*\{?\s*return\s+null\b/.test(code) ||
725
+ // as-unknown-as-vnodechild
726
+ /\bas\s+unknown\s+as\s+VNodeChild\b/.test(code)
727
+ )
728
+ }