@pyreon/compiler 0.13.1 → 0.14.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,504 @@
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
+ *
33
+ * Two-mode surface mirrors `react-intercept.ts`:
34
+ * - `detectPyreonPatterns(code)` — diagnostics only
35
+ * - `hasPyreonPatterns(code)` — fast regex pre-filter
36
+ *
37
+ * ## fixable: false (invariant)
38
+ *
39
+ * Every Pyreon diagnostic reports `fixable: false` — no exceptions.
40
+ * The `migrate_react` MCP tool only knows React mappings, so claiming
41
+ * a Pyreon code is auto-fixable would mislead a consumer who wires
42
+ * their UX off the flag and finds nothing applies the fix. Flip to
43
+ * `true` ONLY when a companion `migrate_pyreon` tool ships in a
44
+ * subsequent PR. The invariant is locked in
45
+ * `tests/pyreon-intercept.test.ts` under "fixable contract".
46
+ *
47
+ * Designed for three consumers:
48
+ * 1. Compiler pre-pass warnings during build
49
+ * 2. CLI `pyreon doctor`
50
+ * 3. MCP server `validate` tool
51
+ */
52
+
53
+ import ts from 'typescript'
54
+
55
+ // ═══════════════════════════════════════════════════════════════════════════════
56
+ // Types
57
+ // ═══════════════════════════════════════════════════════════════════════════════
58
+
59
+ export type PyreonDiagnosticCode =
60
+ | 'for-missing-by'
61
+ | 'for-with-key'
62
+ | 'props-destructured'
63
+ | 'process-dev-gate'
64
+ | 'empty-theme'
65
+ | 'raw-add-event-listener'
66
+ | 'raw-remove-event-listener'
67
+ | 'date-math-random-id'
68
+ | 'on-click-undefined'
69
+
70
+ export interface PyreonDiagnostic {
71
+ /** Machine-readable code for filtering + programmatic handling */
72
+ code: PyreonDiagnosticCode
73
+ /** Human-readable message explaining the issue */
74
+ message: string
75
+ /** 1-based line number */
76
+ line: number
77
+ /** 0-based column */
78
+ column: number
79
+ /** The code as written */
80
+ current: string
81
+ /** The suggested Pyreon fix */
82
+ suggested: string
83
+ /** Whether a mechanical auto-fix is safe */
84
+ fixable: boolean
85
+ }
86
+
87
+ // ═══════════════════════════════════════════════════════════════════════════════
88
+ // Detection context
89
+ // ═══════════════════════════════════════════════════════════════════════════════
90
+
91
+ interface DetectContext {
92
+ sf: ts.SourceFile
93
+ code: string
94
+ diagnostics: PyreonDiagnostic[]
95
+ }
96
+
97
+ function getNodeText(ctx: DetectContext, node: ts.Node): string {
98
+ return ctx.code.slice(node.getStart(ctx.sf), node.getEnd())
99
+ }
100
+
101
+ function pushDiag(
102
+ ctx: DetectContext,
103
+ node: ts.Node,
104
+ code: PyreonDiagnosticCode,
105
+ message: string,
106
+ current: string,
107
+ suggested: string,
108
+ fixable: boolean,
109
+ ): void {
110
+ const { line, character } = ctx.sf.getLineAndCharacterOfPosition(node.getStart(ctx.sf))
111
+ ctx.diagnostics.push({
112
+ code,
113
+ message,
114
+ line: line + 1,
115
+ column: character,
116
+ current: current.trim(),
117
+ suggested: suggested.trim(),
118
+ fixable,
119
+ })
120
+ }
121
+
122
+ // ═══════════════════════════════════════════════════════════════════════════════
123
+ // JSX helpers
124
+ // ═══════════════════════════════════════════════════════════════════════════════
125
+
126
+ function getJsxTagName(node: ts.JsxOpeningLikeElement): string {
127
+ const t = node.tagName
128
+ if (ts.isIdentifier(t)) return t.text
129
+ return ''
130
+ }
131
+
132
+ function findJsxAttribute(
133
+ node: ts.JsxOpeningLikeElement,
134
+ name: string,
135
+ ): ts.JsxAttribute | undefined {
136
+ for (const attr of node.attributes.properties) {
137
+ if (ts.isJsxAttribute(attr) && ts.isIdentifier(attr.name) && attr.name.text === name) {
138
+ return attr
139
+ }
140
+ }
141
+ return undefined
142
+ }
143
+
144
+ // ═══════════════════════════════════════════════════════════════════════════════
145
+ // Pattern: <For> without `by` / with `key`
146
+ // ═══════════════════════════════════════════════════════════════════════════════
147
+
148
+ function detectForKeying(ctx: DetectContext, node: ts.JsxOpeningLikeElement): void {
149
+ if (getJsxTagName(node) !== 'For') return
150
+
151
+ const keyAttr = findJsxAttribute(node, 'key')
152
+ if (keyAttr) {
153
+ pushDiag(
154
+ ctx,
155
+ keyAttr,
156
+ 'for-with-key',
157
+ '`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.',
158
+ getNodeText(ctx, keyAttr),
159
+ getNodeText(ctx, keyAttr).replace(/^key\b/, 'by'),
160
+ // fixable remains `false` until a `migrate_pyreon` tool exists —
161
+ // today the MCP only ships `migrate_react`, so claiming auto-fix
162
+ // here would mislead consumers building on the flag.
163
+ false,
164
+ )
165
+ }
166
+
167
+ const eachAttr = findJsxAttribute(node, 'each')
168
+ const byAttr = findJsxAttribute(node, 'by')
169
+ if (eachAttr && !byAttr && !keyAttr) {
170
+ pushDiag(
171
+ ctx,
172
+ node,
173
+ 'for-missing-by',
174
+ '<For each={...}> requires a `by` prop so the keyed reconciler can preserve item identity across reorders. Without `by`, every update remounts the full list.',
175
+ getNodeText(ctx, node),
176
+ '<For each={items} by={(item) => item.id}>',
177
+ false,
178
+ )
179
+ }
180
+ }
181
+
182
+ // ═══════════════════════════════════════════════════════════════════════════════
183
+ // Pattern: destructured props in component signature
184
+ // ═══════════════════════════════════════════════════════════════════════════════
185
+
186
+ function containsJsx(node: ts.Node): boolean {
187
+ let found = false
188
+ function walk(n: ts.Node): void {
189
+ if (found) return
190
+ if (
191
+ ts.isJsxElement(n) ||
192
+ ts.isJsxSelfClosingElement(n) ||
193
+ ts.isJsxFragment(n) ||
194
+ ts.isJsxOpeningElement(n)
195
+ ) {
196
+ found = true
197
+ return
198
+ }
199
+ ts.forEachChild(n, walk)
200
+ }
201
+ ts.forEachChild(node, walk)
202
+ // Also allow expression-body arrow fns
203
+ if (!found) {
204
+ if (
205
+ ts.isArrowFunction(node) &&
206
+ !ts.isBlock(node.body) &&
207
+ (ts.isJsxElement(node.body) ||
208
+ ts.isJsxSelfClosingElement(node.body) ||
209
+ ts.isJsxFragment(node.body))
210
+ ) {
211
+ found = true
212
+ }
213
+ }
214
+ return found
215
+ }
216
+
217
+ function detectPropsDestructured(
218
+ ctx: DetectContext,
219
+ node: ts.ArrowFunction | ts.FunctionDeclaration | ts.FunctionExpression,
220
+ ): void {
221
+ if (!node.parameters.length) return
222
+ const first = node.parameters[0]
223
+ if (!first || !ts.isObjectBindingPattern(first.name)) return
224
+ if (first.name.elements.length === 0) return
225
+
226
+ // Heuristic: only flag functions that actually render JSX (component
227
+ // functions), not arbitrary callbacks that happen to destructure an
228
+ // options bag.
229
+ if (!containsJsx(node)) return
230
+
231
+ pushDiag(
232
+ ctx,
233
+ first,
234
+ 'props-destructured',
235
+ '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.',
236
+ getNodeText(ctx, first),
237
+ '(props: Props) => /* read props.x directly */',
238
+ false,
239
+ )
240
+ }
241
+
242
+ // ═══════════════════════════════════════════════════════════════════════════════
243
+ // Pattern: typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'
244
+ // ═══════════════════════════════════════════════════════════════════════════════
245
+
246
+ function isTypeofProcess(node: ts.Expression): boolean {
247
+ if (!ts.isBinaryExpression(node)) return false
248
+ if (node.operatorToken.kind !== ts.SyntaxKind.ExclamationEqualsEqualsToken) return false
249
+ if (!ts.isTypeOfExpression(node.left)) return false
250
+ if (!ts.isIdentifier(node.left.expression) || node.left.expression.text !== 'process') return false
251
+ return ts.isStringLiteral(node.right) && node.right.text === 'undefined'
252
+ }
253
+
254
+ function isProcessNodeEnvProdGuard(node: ts.Expression): boolean {
255
+ if (!ts.isBinaryExpression(node)) return false
256
+ if (node.operatorToken.kind !== ts.SyntaxKind.ExclamationEqualsEqualsToken) return false
257
+ // process.env.NODE_ENV
258
+ const left = node.left
259
+ if (!ts.isPropertyAccessExpression(left)) return false
260
+ if (!ts.isIdentifier(left.name) || left.name.text !== 'NODE_ENV') return false
261
+ if (!ts.isPropertyAccessExpression(left.expression)) return false
262
+ if (
263
+ !ts.isIdentifier(left.expression.name) ||
264
+ left.expression.name.text !== 'env'
265
+ ) {
266
+ return false
267
+ }
268
+ if (!ts.isIdentifier(left.expression.expression)) return false
269
+ if (left.expression.expression.text !== 'process') return false
270
+ return ts.isStringLiteral(node.right) && node.right.text === 'production'
271
+ }
272
+
273
+ function detectProcessDevGate(ctx: DetectContext, node: ts.BinaryExpression): void {
274
+ if (node.operatorToken.kind !== ts.SyntaxKind.AmpersandAmpersandToken) return
275
+ // left: typeof process !== 'undefined', right: process.env.NODE_ENV !== 'production'
276
+ // (or either side in either order)
277
+ const match =
278
+ (isTypeofProcess(node.left) && isProcessNodeEnvProdGuard(node.right)) ||
279
+ (isTypeofProcess(node.right) && isProcessNodeEnvProdGuard(node.left))
280
+ if (!match) return
281
+
282
+ pushDiag(
283
+ ctx,
284
+ node,
285
+ 'process-dev-gate',
286
+ '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.',
287
+ getNodeText(ctx, node),
288
+ 'import.meta.env?.DEV === true',
289
+ // No `migrate_pyreon` tool yet — claiming fixable would mislead.
290
+ false,
291
+ )
292
+ }
293
+
294
+ // ═══════════════════════════════════════════════════════════════════════════════
295
+ // Pattern: .theme({}) empty chain
296
+ // ═══════════════════════════════════════════════════════════════════════════════
297
+
298
+ function detectEmptyTheme(ctx: DetectContext, node: ts.CallExpression): void {
299
+ const callee = node.expression
300
+ if (!ts.isPropertyAccessExpression(callee)) return
301
+ if (!ts.isIdentifier(callee.name) || callee.name.text !== 'theme') return
302
+ if (node.arguments.length !== 1) return
303
+ const arg = node.arguments[0]
304
+ if (!arg || !ts.isObjectLiteralExpression(arg)) return
305
+ if (arg.properties.length !== 0) return
306
+
307
+ pushDiag(
308
+ ctx,
309
+ node,
310
+ 'empty-theme',
311
+ '`.theme({})` is a no-op chain. If the component needs no base theme, skip `.theme()` entirely rather than calling it with an empty object.',
312
+ getNodeText(ctx, node),
313
+ getNodeText(ctx, callee.expression),
314
+ // No `migrate_pyreon` tool yet — claiming fixable would mislead.
315
+ false,
316
+ )
317
+ }
318
+
319
+ // ═══════════════════════════════════════════════════════════════════════════════
320
+ // Pattern: raw addEventListener / removeEventListener
321
+ // ═══════════════════════════════════════════════════════════════════════════════
322
+
323
+ function detectRawEventListener(ctx: DetectContext, node: ts.CallExpression): void {
324
+ const callee = node.expression
325
+ if (!ts.isPropertyAccessExpression(callee)) return
326
+ if (!ts.isIdentifier(callee.name)) return
327
+ const method = callee.name.text
328
+ if (method !== 'addEventListener' && method !== 'removeEventListener') return
329
+
330
+ // Only flag when the target is `window` / `document` / an identifier
331
+ // that looks like a DOM element. Property-access chains (e.g.
332
+ // `editor.dom.addEventListener`) are generally CodeMirror / framework
333
+ // hosts — leave those alone.
334
+ const target = callee.expression
335
+ const targetName = ts.isIdentifier(target)
336
+ ? target.text
337
+ : ts.isPropertyAccessExpression(target) && ts.isIdentifier(target.name)
338
+ ? target.name.text
339
+ : ''
340
+
341
+ const flagTargets = new Set(['window', 'document', 'body', 'el', 'element', 'node', 'target'])
342
+ if (!flagTargets.has(targetName)) return
343
+
344
+ if (method === 'addEventListener') {
345
+ pushDiag(
346
+ ctx,
347
+ node,
348
+ 'raw-add-event-listener',
349
+ 'Raw `addEventListener` in a component / hook body bypasses Pyreon\'s lifecycle cleanup — listeners leak on unmount. Use `useEventListener` from `@pyreon/hooks` for auto-cleanup.',
350
+ getNodeText(ctx, node),
351
+ 'useEventListener(target, event, handler)',
352
+ false,
353
+ )
354
+ } else {
355
+ pushDiag(
356
+ ctx,
357
+ node,
358
+ 'raw-remove-event-listener',
359
+ 'Raw `removeEventListener` is the symptom of manual listener management. Replace the paired `addEventListener` with `useEventListener` from `@pyreon/hooks` — it registers the cleanup automatically.',
360
+ getNodeText(ctx, node),
361
+ 'useEventListener(target, event, handler) // cleanup is automatic',
362
+ false,
363
+ )
364
+ }
365
+ }
366
+
367
+ // ═══════════════════════════════════════════════════════════════════════════════
368
+ // Pattern: Date.now() + Math.random() for IDs
369
+ // ═══════════════════════════════════════════════════════════════════════════════
370
+
371
+ function isCallTo(node: ts.Node, object: string, method: string): boolean {
372
+ return (
373
+ ts.isCallExpression(node) &&
374
+ ts.isPropertyAccessExpression(node.expression) &&
375
+ ts.isIdentifier(node.expression.expression) &&
376
+ node.expression.expression.text === object &&
377
+ ts.isIdentifier(node.expression.name) &&
378
+ node.expression.name.text === method
379
+ )
380
+ }
381
+
382
+ function subtreeHas(node: ts.Node, predicate: (n: ts.Node) => boolean): boolean {
383
+ let found = false
384
+ function walk(n: ts.Node): void {
385
+ if (found) return
386
+ if (predicate(n)) {
387
+ found = true
388
+ return
389
+ }
390
+ ts.forEachChild(n, walk)
391
+ }
392
+ walk(node)
393
+ return found
394
+ }
395
+
396
+ function detectDateMathRandomId(ctx: DetectContext, node: ts.Expression): void {
397
+ const hasDate = subtreeHas(node, (n) => isCallTo(n, 'Date', 'now'))
398
+ if (!hasDate) return
399
+ const hasRandom = subtreeHas(node, (n) => isCallTo(n, 'Math', 'random'))
400
+ if (!hasRandom) return
401
+
402
+ pushDiag(
403
+ ctx,
404
+ node,
405
+ 'date-math-random-id',
406
+ '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.',
407
+ getNodeText(ctx, node),
408
+ 'let _counter = 0; const nextId = () => String(++_counter)',
409
+ false,
410
+ )
411
+ }
412
+
413
+ // ═══════════════════════════════════════════════════════════════════════════════
414
+ // Pattern: onClick={undefined}
415
+ // ═══════════════════════════════════════════════════════════════════════════════
416
+
417
+ function detectOnClickUndefined(ctx: DetectContext, node: ts.JsxAttribute): void {
418
+ if (!ts.isIdentifier(node.name)) return
419
+ const attrName = node.name.text
420
+ if (!attrName.startsWith('on') || attrName.length < 3) return
421
+ if (!node.initializer || !ts.isJsxExpression(node.initializer)) return
422
+ const expr = node.initializer.expression
423
+ if (!expr) return
424
+ const isExplicitUndefined =
425
+ (ts.isIdentifier(expr) && expr.text === 'undefined') ||
426
+ expr.kind === ts.SyntaxKind.VoidExpression
427
+
428
+ if (!isExplicitUndefined) return
429
+
430
+ pushDiag(
431
+ ctx,
432
+ node,
433
+ 'on-click-undefined',
434
+ `\`${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}\`.`,
435
+ getNodeText(ctx, node),
436
+ `/* omit ${attrName} when the handler is not defined */`,
437
+ // No `migrate_pyreon` tool yet — claiming fixable would mislead.
438
+ false,
439
+ )
440
+ }
441
+
442
+ // ═══════════════════════════════════════════════════════════════════════════════
443
+ // Visitor
444
+ // ═══════════════════════════════════════════════════════════════════════════════
445
+
446
+ function visitNode(ctx: DetectContext, node: ts.Node): void {
447
+ if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
448
+ detectForKeying(ctx, node)
449
+ }
450
+ if (
451
+ ts.isArrowFunction(node) ||
452
+ ts.isFunctionDeclaration(node) ||
453
+ ts.isFunctionExpression(node)
454
+ ) {
455
+ detectPropsDestructured(ctx, node)
456
+ }
457
+ if (ts.isBinaryExpression(node)) {
458
+ detectProcessDevGate(ctx, node)
459
+ detectDateMathRandomId(ctx, node)
460
+ }
461
+ if (ts.isTemplateExpression(node)) {
462
+ detectDateMathRandomId(ctx, node)
463
+ }
464
+ if (ts.isCallExpression(node)) {
465
+ detectEmptyTheme(ctx, node)
466
+ detectRawEventListener(ctx, node)
467
+ }
468
+ if (ts.isJsxAttribute(node)) {
469
+ detectOnClickUndefined(ctx, node)
470
+ }
471
+ }
472
+
473
+ function visit(ctx: DetectContext, node: ts.Node): void {
474
+ ts.forEachChild(node, (child) => {
475
+ visitNode(ctx, child)
476
+ visit(ctx, child)
477
+ })
478
+ }
479
+
480
+ // ═══════════════════════════════════════════════════════════════════════════════
481
+ // Public API
482
+ // ═══════════════════════════════════════════════════════════════════════════════
483
+
484
+ export function detectPyreonPatterns(code: string, filename = 'input.tsx'): PyreonDiagnostic[] {
485
+ const sf = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX)
486
+ const ctx: DetectContext = { sf, code, diagnostics: [] }
487
+ visit(ctx, sf)
488
+ // Sort by (line, column) for stable ordering when multiple patterns fire.
489
+ ctx.diagnostics.sort((a, b) => a.line - b.line || a.column - b.column)
490
+ return ctx.diagnostics
491
+ }
492
+
493
+ /** Fast regex pre-filter — returns true if the code is worth a full AST walk. */
494
+ export function hasPyreonPatterns(code: string): boolean {
495
+ return (
496
+ /\bFor\b[^=]*\beach\s*=/.test(code) ||
497
+ /\btypeof\s+process\b/.test(code) ||
498
+ /\.theme\s*\(\s*\{\s*\}\s*\)/.test(code) ||
499
+ /\b(?:add|remove)EventListener\s*\(/.test(code) ||
500
+ (/\bDate\.now\s*\(/.test(code) && /\bMath\.random\s*\(/.test(code)) ||
501
+ /on[A-Z]\w*\s*=\s*\{\s*undefined\s*\}/.test(code) ||
502
+ /=\s*\(\s*\{[^}]+\}\s*[:)]/.test(code)
503
+ )
504
+ }