@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,1029 +0,0 @@
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
- * - `props-destructured-body` — `const { foo } = props` written
18
- * SYNCHRONOUSLY in a component body — the body-scope
19
- * companion to `props-destructured`. Same capture-
20
- * once death; nested-function destructures (handler
21
- * / effect / returned accessor) are NOT flagged
22
- * (they re-read `props` per invocation).
23
- * - `process-dev-gate` — `typeof process !== 'undefined' &&
24
- * process.env.NODE_ENV !== 'production'` is dead
25
- * code in real Vite browser bundles. Use
26
- * `import.meta.env?.DEV` instead.
27
- * - `empty-theme` — `.theme({})` chain is a no-op; remove it.
28
- * - `raw-add-event-listener` — raw `addEventListener(...)` in a component
29
- * or hook body. Use `useEventListener(...)` from
30
- * `@pyreon/hooks` for auto-cleanup.
31
- * - `raw-remove-event-listener` — same, for removeEventListener.
32
- * - `date-math-random-id` — `Date.now() + Math.random()` / template-concat
33
- * variants. Under rapid operations (paste, clone)
34
- * collision probability is non-trivial. Use a
35
- * monotonic counter.
36
- * - `on-click-undefined` — `onClick={undefined}` explicitly; the runtime
37
- * used to crash on this pattern. Omit the prop.
38
- * - `signal-write-as-call` — `sig(value)` is a no-op read that ignores
39
- * its argument; the runtime warns in dev. Static
40
- * detector spots it pre-runtime when `sig` was
41
- * declared as `const sig = signal(...)` /
42
- * `computed(...)` and called with ≥1 argument.
43
- * - `static-return-null-conditional` — `if (cond) return null` at the
44
- * top of a component body runs ONCE; signal changes
45
- * in `cond` never re-evaluate the early-return.
46
- * Wrap in a returned reactive accessor.
47
- * - `as-unknown-as-vnodechild` — defensive `as unknown as VNodeChild`
48
- * cast on JSX returns is unnecessary (`JSX.Element`
49
- * is already assignable to `VNodeChild`).
50
- * - `island-never-with-registry-entry` — an `island()` declared with
51
- * `hydrate: 'never'` is also registered in the same
52
- * file's `hydrateIslands({ ... })` call. The whole
53
- * point of `'never'` is shipping zero client JS;
54
- * registering pulls the component module into the
55
- * client bundle graph (the runtime short-circuits
56
- * and never calls the loader, but the bundler still
57
- * includes the import). Drop the registry entry.
58
- *
59
- * Two-mode surface mirrors `react-intercept.ts`:
60
- * - `detectPyreonPatterns(code)` — diagnostics only
61
- * - `hasPyreonPatterns(code)` — fast regex pre-filter
62
- *
63
- * ## fixable: false (invariant)
64
- *
65
- * Every Pyreon diagnostic reports `fixable: false` — no exceptions.
66
- * The `migrate_react` MCP tool only knows React mappings, so claiming
67
- * a Pyreon code is auto-fixable would mislead a consumer who wires
68
- * their UX off the flag and finds nothing applies the fix. Flip to
69
- * `true` ONLY when a companion `migrate_pyreon` tool ships in a
70
- * subsequent PR. The invariant is locked in
71
- * `tests/pyreon-intercept.test.ts` under "fixable contract".
72
- *
73
- * Designed for three consumers:
74
- * 1. Compiler pre-pass warnings during build
75
- * 2. CLI `pyreon doctor`
76
- * 3. MCP server `validate` tool
77
- */
78
-
79
- import ts from 'typescript'
80
-
81
- // ═══════════════════════════════════════════════════════════════════════════════
82
- // Types
83
- // ═══════════════════════════════════════════════════════════════════════════════
84
-
85
- export type PyreonDiagnosticCode =
86
- | 'for-missing-by'
87
- | 'for-with-key'
88
- | 'props-destructured'
89
- | 'props-destructured-body'
90
- | 'process-dev-gate'
91
- | 'empty-theme'
92
- | 'raw-add-event-listener'
93
- | 'raw-remove-event-listener'
94
- | 'date-math-random-id'
95
- | 'on-click-undefined'
96
- | 'signal-write-as-call'
97
- | 'static-return-null-conditional'
98
- | 'as-unknown-as-vnodechild'
99
- | 'island-never-with-registry-entry'
100
- | 'query-options-as-function'
101
-
102
- export interface PyreonDiagnostic {
103
- /** Machine-readable code for filtering + programmatic handling */
104
- code: PyreonDiagnosticCode
105
- /** Human-readable message explaining the issue */
106
- message: string
107
- /** 1-based line number */
108
- line: number
109
- /** 0-based column */
110
- column: number
111
- /** The code as written */
112
- current: string
113
- /** The suggested Pyreon fix */
114
- suggested: string
115
- /** Whether a mechanical auto-fix is safe */
116
- fixable: boolean
117
- }
118
-
119
- // ═══════════════════════════════════════════════════════════════════════════════
120
- // Detection context
121
- // ═══════════════════════════════════════════════════════════════════════════════
122
-
123
- interface DetectContext {
124
- sf: ts.SourceFile
125
- code: string
126
- diagnostics: PyreonDiagnostic[]
127
- /**
128
- * Identifiers bound to `signal(...)` or `computed(...)` calls anywhere in
129
- * the file. Populated by `collectSignalBindings()` before the main
130
- * detection walk. Used by `detectSignalWriteAsCall` to flag `sig(value)`
131
- * patterns that should be `sig.set(value)`.
132
- */
133
- signalBindings: Set<string>
134
- /**
135
- * Names of `island()` declarations carrying `hydrate: 'never'`. Populated
136
- * by `collectNeverIslandNames()` before the main detection walk. Used by
137
- * `detectIslandNeverWithRegistry` to flag entries in
138
- * `hydrateIslands({ ... })` whose key matches a never-strategy island.
139
- *
140
- * Cross-call detection: the never-vs-registry mismatch is only catchable
141
- * when both sides live in the same source. In real apps the `island()`
142
- * declarations sit in `src/islands.ts` and the `hydrateIslands()` call
143
- * sits in `src/entry-client.ts`. The static detector covers the common
144
- * "all in one file" case (which catches the bug while users are first
145
- * learning the API); the cross-file case is the territory of `pyreon
146
- * doctor --check-islands` (separate PR / future scope).
147
- */
148
- neverIslandNames: Set<string>
149
- }
150
-
151
- function getNodeText(ctx: DetectContext, node: ts.Node): string {
152
- return ctx.code.slice(node.getStart(ctx.sf), node.getEnd())
153
- }
154
-
155
- function pushDiag(
156
- ctx: DetectContext,
157
- node: ts.Node,
158
- code: PyreonDiagnosticCode,
159
- message: string,
160
- current: string,
161
- suggested: string,
162
- fixable: boolean,
163
- ): void {
164
- const { line, character } = ctx.sf.getLineAndCharacterOfPosition(node.getStart(ctx.sf))
165
- ctx.diagnostics.push({
166
- code,
167
- message,
168
- line: line + 1,
169
- column: character,
170
- current: current.trim(),
171
- suggested: suggested.trim(),
172
- fixable,
173
- })
174
- }
175
-
176
- // ═══════════════════════════════════════════════════════════════════════════════
177
- // JSX helpers
178
- // ═══════════════════════════════════════════════════════════════════════════════
179
-
180
- function getJsxTagName(node: ts.JsxOpeningLikeElement): string {
181
- const t = node.tagName
182
- if (ts.isIdentifier(t)) return t.text
183
- return ''
184
- }
185
-
186
- function findJsxAttribute(
187
- node: ts.JsxOpeningLikeElement,
188
- name: string,
189
- ): ts.JsxAttribute | undefined {
190
- for (const attr of node.attributes.properties) {
191
- if (ts.isJsxAttribute(attr) && ts.isIdentifier(attr.name) && attr.name.text === name) {
192
- return attr
193
- }
194
- }
195
- return undefined
196
- }
197
-
198
- // ═══════════════════════════════════════════════════════════════════════════════
199
- // Pattern: <For> without `by` / with `key`
200
- // ═══════════════════════════════════════════════════════════════════════════════
201
-
202
- function detectForKeying(ctx: DetectContext, node: ts.JsxOpeningLikeElement): void {
203
- if (getJsxTagName(node) !== 'For') return
204
-
205
- const keyAttr = findJsxAttribute(node, 'key')
206
- if (keyAttr) {
207
- pushDiag(
208
- ctx,
209
- keyAttr,
210
- 'for-with-key',
211
- '`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.',
212
- getNodeText(ctx, keyAttr),
213
- getNodeText(ctx, keyAttr).replace(/^key\b/, 'by'),
214
- // fixable remains `false` until a `migrate_pyreon` tool exists —
215
- // today the MCP only ships `migrate_react`, so claiming auto-fix
216
- // here would mislead consumers building on the flag.
217
- false,
218
- )
219
- }
220
-
221
- const eachAttr = findJsxAttribute(node, 'each')
222
- const byAttr = findJsxAttribute(node, 'by')
223
- if (eachAttr && !byAttr && !keyAttr) {
224
- pushDiag(
225
- ctx,
226
- node,
227
- 'for-missing-by',
228
- '<For each={...}> requires a `by` prop so the keyed reconciler can preserve item identity across reorders. Without `by`, every update remounts the full list.',
229
- getNodeText(ctx, node),
230
- '<For each={items} by={(item) => item.id}>',
231
- false,
232
- )
233
- }
234
- }
235
-
236
- // ═══════════════════════════════════════════════════════════════════════════════
237
- // Pattern: destructured props in component signature
238
- // ═══════════════════════════════════════════════════════════════════════════════
239
-
240
- function containsJsx(node: ts.Node): boolean {
241
- let found = false
242
- function walk(n: ts.Node): void {
243
- if (found) return
244
- if (
245
- ts.isJsxElement(n) ||
246
- ts.isJsxSelfClosingElement(n) ||
247
- ts.isJsxFragment(n) ||
248
- ts.isJsxOpeningElement(n)
249
- ) {
250
- found = true
251
- return
252
- }
253
- ts.forEachChild(n, walk)
254
- }
255
- ts.forEachChild(node, walk)
256
- // Also allow expression-body arrow fns
257
- if (!found) {
258
- if (
259
- ts.isArrowFunction(node) &&
260
- !ts.isBlock(node.body) &&
261
- (ts.isJsxElement(node.body) ||
262
- ts.isJsxSelfClosingElement(node.body) ||
263
- ts.isJsxFragment(node.body))
264
- ) {
265
- found = true
266
- }
267
- }
268
- return found
269
- }
270
-
271
- function detectPropsDestructured(
272
- ctx: DetectContext,
273
- node: ts.ArrowFunction | ts.FunctionDeclaration | ts.FunctionExpression,
274
- ): void {
275
- if (!node.parameters.length) return
276
- const first = node.parameters[0]
277
- if (!first || !ts.isObjectBindingPattern(first.name)) return
278
- if (first.name.elements.length === 0) return
279
-
280
- // Heuristic: only flag functions that actually render JSX (component
281
- // functions), not arbitrary callbacks that happen to destructure an
282
- // options bag.
283
- if (!containsJsx(node)) return
284
-
285
- pushDiag(
286
- ctx,
287
- first,
288
- 'props-destructured',
289
- '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.',
290
- getNodeText(ctx, first),
291
- '(props: Props) => /* read props.x directly */',
292
- false,
293
- )
294
- }
295
-
296
- // ═══════════════════════════════════════════════════════════════════════════════
297
- // Pattern: body-scope `const { x } = props` destructure
298
- // ═══════════════════════════════════════════════════════════════════════════════
299
-
300
- /**
301
- * Strip the wrappers that can sit between `=` and the props identifier
302
- * (`const { x } = (props as Props)!`) so we can compare the base
303
- * expression's identity to the component's first-parameter name.
304
- */
305
- function unwrapInitializer(expr: ts.Expression): ts.Expression {
306
- let cur = expr
307
- let prev: ts.Expression | undefined
308
- while (cur !== prev) {
309
- prev = cur
310
- if (ts.isParenthesizedExpression(cur)) cur = cur.expression
311
- else if (ts.isAsExpression(cur)) cur = cur.expression
312
- else if (ts.isSatisfiesExpression(cur)) cur = cur.expression
313
- else if (ts.isNonNullExpression(cur)) cur = cur.expression
314
- }
315
- return cur
316
- }
317
-
318
- /**
319
- * Body-scope companion to {@link detectPropsDestructured}. Flags
320
- * `const { x } = props` (also `let` / `var`, aliases, defaults, rest,
321
- * nested patterns) written SYNCHRONOUSLY in a component's body.
322
- *
323
- * Why this is the footgun: the compiler emits `<C prop={sig()} />` as a
324
- * getter-shaped reactive prop. `const { x } = props` fires that getter
325
- * exactly ONCE at setup — `x` is a dead snapshot, never re-reads when
326
- * the signal changes. `props.x` (live member access inside a tracking
327
- * scope) or `splitProps(props, ['x'])` preserve the subscription.
328
- *
329
- * Precision (zero false positives is the priority — a missed body-scope
330
- * destructure is acceptable, a wrong one is not):
331
- * - Only PascalCase, JSX-rendering functions (`isComponentShapedFunction`
332
- * + `containsJsx`) — a plain helper that happens to destructure an
333
- * options bag named `props` is NOT a component and is left alone.
334
- * - The initializer must be the bare first-parameter identifier
335
- * (`= props`), unwrapped through paren / `as` / `satisfies` / `!`.
336
- * `const { x } = props.nested` and `= someOtherObject` are NOT
337
- * flagged (rarer shapes; out of the canonical scope).
338
- * - The destructure must be at the component-body top scope. A nested
339
- * function boundary (`onClick` handler, `effect(() => …)`, a returned
340
- * reactive accessor) re-reads `props` on each invocation, so those
341
- * destructures are reactivity-correct — the walk does NOT descend
342
- * into nested functions.
343
- * - The first parameter must itself be a plain identifier; the
344
- * parameter-destructure shape (`({ x }) => …`) is the existing
345
- * `detectPropsDestructured`'s job, not this one.
346
- */
347
- function detectPropsDestructuredBody(
348
- ctx: DetectContext,
349
- node: ts.ArrowFunction | ts.FunctionDeclaration | ts.FunctionExpression,
350
- ): void {
351
- if (!isComponentShapedFunction(node)) return
352
- if (!containsJsx(node)) return
353
- if (!node.parameters.length) return
354
- const first = node.parameters[0]
355
- // First param must be a plain identifier — the destructured-param
356
- // shape is detectPropsDestructured's domain.
357
- if (!first || !ts.isIdentifier(first.name)) return
358
- const paramName = first.name.text
359
- const body = node.body
360
- if (!body || !ts.isBlock(body)) return
361
-
362
- function walk(n: ts.Node): void {
363
- // Do NOT descend into nested functions: a `const { x } = props`
364
- // inside a handler / effect / returned accessor re-reads on every
365
- // invocation and is reactivity-correct.
366
- if (
367
- ts.isArrowFunction(n) ||
368
- ts.isFunctionExpression(n) ||
369
- ts.isFunctionDeclaration(n) ||
370
- ts.isMethodDeclaration(n) ||
371
- ts.isGetAccessorDeclaration(n) ||
372
- ts.isSetAccessorDeclaration(n)
373
- ) {
374
- return
375
- }
376
- if (
377
- ts.isVariableDeclaration(n) &&
378
- ts.isObjectBindingPattern(n.name) &&
379
- n.name.elements.length > 0 &&
380
- n.initializer
381
- ) {
382
- const base = unwrapInitializer(n.initializer)
383
- if (ts.isIdentifier(base) && base.text === paramName) {
384
- pushDiag(
385
- ctx,
386
- n,
387
- 'props-destructured-body',
388
- `Destructuring \`${paramName}\` in the component body captures the values ONCE during setup — the compiler emits signal-driven props as getters, so the destructured locals are dead snapshots that never update when the parent rewrites them. Read \`${paramName}.x\` directly inside the reactive scope (JSX / effect / computed), or use \`splitProps(${paramName}, ['x', ...])\` to carve out a group while preserving reactivity.`,
389
- getNodeText(ctx, n),
390
- `// read ${paramName}.x directly, or: const [local] = splitProps(${paramName}, ['x'])`,
391
- false,
392
- )
393
- }
394
- }
395
- ts.forEachChild(n, walk)
396
- }
397
- for (const stmt of body.statements) walk(stmt)
398
- }
399
-
400
- // ═══════════════════════════════════════════════════════════════════════════════
401
- // Pattern: typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'
402
- // ═══════════════════════════════════════════════════════════════════════════════
403
-
404
- function isTypeofProcess(node: ts.Expression): boolean {
405
- if (!ts.isBinaryExpression(node)) return false
406
- if (node.operatorToken.kind !== ts.SyntaxKind.ExclamationEqualsEqualsToken) return false
407
- if (!ts.isTypeOfExpression(node.left)) return false
408
- if (!ts.isIdentifier(node.left.expression) || node.left.expression.text !== 'process') return false
409
- return ts.isStringLiteral(node.right) && node.right.text === 'undefined'
410
- }
411
-
412
- function isProcessNodeEnvProdGuard(node: ts.Expression): boolean {
413
- if (!ts.isBinaryExpression(node)) return false
414
- if (node.operatorToken.kind !== ts.SyntaxKind.ExclamationEqualsEqualsToken) return false
415
- // process.env.NODE_ENV
416
- const left = node.left
417
- if (!ts.isPropertyAccessExpression(left)) return false
418
- if (!ts.isIdentifier(left.name) || left.name.text !== 'NODE_ENV') return false
419
- if (!ts.isPropertyAccessExpression(left.expression)) return false
420
- if (
421
- !ts.isIdentifier(left.expression.name) ||
422
- left.expression.name.text !== 'env'
423
- ) {
424
- return false
425
- }
426
- if (!ts.isIdentifier(left.expression.expression)) return false
427
- if (left.expression.expression.text !== 'process') return false
428
- return ts.isStringLiteral(node.right) && node.right.text === 'production'
429
- }
430
-
431
- function detectProcessDevGate(ctx: DetectContext, node: ts.BinaryExpression): void {
432
- if (node.operatorToken.kind !== ts.SyntaxKind.AmpersandAmpersandToken) return
433
- // left: typeof process !== 'undefined', right: process.env.NODE_ENV !== 'production'
434
- // (or either side in either order)
435
- const match =
436
- (isTypeofProcess(node.left) && isProcessNodeEnvProdGuard(node.right)) ||
437
- (isTypeofProcess(node.right) && isProcessNodeEnvProdGuard(node.left))
438
- if (!match) return
439
-
440
- pushDiag(
441
- ctx,
442
- node,
443
- 'process-dev-gate',
444
- '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.',
445
- getNodeText(ctx, node),
446
- 'import.meta.env?.DEV === true',
447
- // No `migrate_pyreon` tool yet — claiming fixable would mislead.
448
- false,
449
- )
450
- }
451
-
452
- // ═══════════════════════════════════════════════════════════════════════════════
453
- // Pattern: .theme({}) empty chain
454
- // ═══════════════════════════════════════════════════════════════════════════════
455
-
456
- function detectEmptyTheme(ctx: DetectContext, node: ts.CallExpression): void {
457
- const callee = node.expression
458
- if (!ts.isPropertyAccessExpression(callee)) return
459
- if (!ts.isIdentifier(callee.name) || callee.name.text !== 'theme') return
460
- if (node.arguments.length !== 1) return
461
- const arg = node.arguments[0]
462
- if (!arg || !ts.isObjectLiteralExpression(arg)) return
463
- if (arg.properties.length !== 0) return
464
-
465
- pushDiag(
466
- ctx,
467
- node,
468
- 'empty-theme',
469
- '`.theme({})` is a no-op chain. If the component needs no base theme, skip `.theme()` entirely rather than calling it with an empty object.',
470
- getNodeText(ctx, node),
471
- getNodeText(ctx, callee.expression),
472
- // No `migrate_pyreon` tool yet — claiming fixable would mislead.
473
- false,
474
- )
475
- }
476
-
477
- // ═══════════════════════════════════════════════════════════════════════════════
478
- // Pattern: @pyreon/query hook options passed as an object literal
479
- // ═══════════════════════════════════════════════════════════════════════════════
480
-
481
- // `useQuery` / `useInfiniteQuery` / `useQueries` / `useSuspenseQuery` take
482
- // options as a FUNCTION so `queryKey` (etc.) can read Pyreon signals —
483
- // changing a tracked signal re-runs the options and refetches. An object
484
- // LITERAL is evaluated once at call time, so the query never reacts to
485
- // signal changes. `useMutation` is deliberately NOT flagged: its options
486
- // are a plain object (mutations are imperative, no tracking).
487
- const QUERY_OPTS_HOOKS = new Set([
488
- 'useQuery',
489
- 'useInfiniteQuery',
490
- 'useQueries',
491
- 'useSuspenseQuery',
492
- ])
493
-
494
- function detectQueryOptionsAsFunction(
495
- ctx: DetectContext,
496
- node: ts.CallExpression,
497
- ): void {
498
- if (!ts.isIdentifier(node.expression)) return
499
- const hook = node.expression.text
500
- if (!QUERY_OPTS_HOOKS.has(hook)) return
501
- const arg0 = node.arguments[0]
502
- // Only the unambiguous object-literal-first-arg shape. An identifier /
503
- // call / function arg can't be statically proven wrong — stay silent.
504
- if (!arg0 || !ts.isObjectLiteralExpression(arg0)) return
505
-
506
- const objText = getNodeText(ctx, arg0)
507
- pushDiag(
508
- ctx,
509
- node,
510
- 'query-options-as-function',
511
- `\`${hook}\` takes options as a FUNCTION so \`queryKey\` can read signals and refetch reactively — an object literal is captured once and never reacts. Wrap it: \`${hook}(() => (...))\`.`,
512
- getNodeText(ctx, node),
513
- `${hook}(() => (${objText}))`,
514
- // No `migrate_pyreon` tool yet — claiming fixable would mislead.
515
- false,
516
- )
517
- }
518
-
519
- // ═══════════════════════════════════════════════════════════════════════════════
520
- // Pattern: raw addEventListener / removeEventListener
521
- // ═══════════════════════════════════════════════════════════════════════════════
522
-
523
- function detectRawEventListener(ctx: DetectContext, node: ts.CallExpression): void {
524
- const callee = node.expression
525
- if (!ts.isPropertyAccessExpression(callee)) return
526
- if (!ts.isIdentifier(callee.name)) return
527
- const method = callee.name.text
528
- if (method !== 'addEventListener' && method !== 'removeEventListener') return
529
-
530
- // Only flag when the target is `window` / `document` / an identifier
531
- // that looks like a DOM element. Property-access chains (e.g.
532
- // `editor.dom.addEventListener`) are generally CodeMirror / framework
533
- // hosts — leave those alone.
534
- const target = callee.expression
535
- const targetName = ts.isIdentifier(target)
536
- ? target.text
537
- : ts.isPropertyAccessExpression(target) && ts.isIdentifier(target.name)
538
- ? target.name.text
539
- : ''
540
-
541
- const flagTargets = new Set(['window', 'document', 'body', 'el', 'element', 'node', 'target'])
542
- if (!flagTargets.has(targetName)) return
543
-
544
- if (method === 'addEventListener') {
545
- pushDiag(
546
- ctx,
547
- node,
548
- 'raw-add-event-listener',
549
- 'Raw `addEventListener` in a component / hook body bypasses Pyreon\'s lifecycle cleanup — listeners leak on unmount. Use `useEventListener` from `@pyreon/hooks` for auto-cleanup.',
550
- getNodeText(ctx, node),
551
- 'useEventListener(target, event, handler)',
552
- false,
553
- )
554
- } else {
555
- pushDiag(
556
- ctx,
557
- node,
558
- 'raw-remove-event-listener',
559
- 'Raw `removeEventListener` is the symptom of manual listener management. Replace the paired `addEventListener` with `useEventListener` from `@pyreon/hooks` — it registers the cleanup automatically.',
560
- getNodeText(ctx, node),
561
- 'useEventListener(target, event, handler) // cleanup is automatic',
562
- false,
563
- )
564
- }
565
- }
566
-
567
- // ═══════════════════════════════════════════════════════════════════════════════
568
- // Pattern: Date.now() + Math.random() for IDs
569
- // ═══════════════════════════════════════════════════════════════════════════════
570
-
571
- function isCallTo(node: ts.Node, object: string, method: string): boolean {
572
- return (
573
- ts.isCallExpression(node) &&
574
- ts.isPropertyAccessExpression(node.expression) &&
575
- ts.isIdentifier(node.expression.expression) &&
576
- node.expression.expression.text === object &&
577
- ts.isIdentifier(node.expression.name) &&
578
- node.expression.name.text === method
579
- )
580
- }
581
-
582
- function subtreeHas(node: ts.Node, predicate: (n: ts.Node) => boolean): boolean {
583
- let found = false
584
- function walk(n: ts.Node): void {
585
- if (found) return
586
- if (predicate(n)) {
587
- found = true
588
- return
589
- }
590
- ts.forEachChild(n, walk)
591
- }
592
- walk(node)
593
- return found
594
- }
595
-
596
- function detectDateMathRandomId(ctx: DetectContext, node: ts.Expression): void {
597
- const hasDate = subtreeHas(node, (n) => isCallTo(n, 'Date', 'now'))
598
- if (!hasDate) return
599
- const hasRandom = subtreeHas(node, (n) => isCallTo(n, 'Math', 'random'))
600
- if (!hasRandom) return
601
-
602
- pushDiag(
603
- ctx,
604
- node,
605
- 'date-math-random-id',
606
- '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.',
607
- getNodeText(ctx, node),
608
- 'let _counter = 0; const nextId = () => String(++_counter)',
609
- false,
610
- )
611
- }
612
-
613
- // ═══════════════════════════════════════════════════════════════════════════════
614
- // Pattern: onClick={undefined}
615
- // ═══════════════════════════════════════════════════════════════════════════════
616
-
617
- function detectOnClickUndefined(ctx: DetectContext, node: ts.JsxAttribute): void {
618
- if (!ts.isIdentifier(node.name)) return
619
- const attrName = node.name.text
620
- if (!attrName.startsWith('on') || attrName.length < 3) return
621
- if (!node.initializer || !ts.isJsxExpression(node.initializer)) return
622
- const expr = node.initializer.expression
623
- if (!expr) return
624
- const isExplicitUndefined =
625
- (ts.isIdentifier(expr) && expr.text === 'undefined') ||
626
- expr.kind === ts.SyntaxKind.VoidExpression
627
-
628
- if (!isExplicitUndefined) return
629
-
630
- pushDiag(
631
- ctx,
632
- node,
633
- 'on-click-undefined',
634
- `\`${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}\`.`,
635
- getNodeText(ctx, node),
636
- `/* omit ${attrName} when the handler is not defined */`,
637
- // No `migrate_pyreon` tool yet — claiming fixable would mislead.
638
- false,
639
- )
640
- }
641
-
642
- // ═══════════════════════════════════════════════════════════════════════════════
643
- // Pattern: signal-write-as-call (sig(value) instead of sig.set(value))
644
- // ═══════════════════════════════════════════════════════════════════════════════
645
-
646
- /**
647
- * Walks the file and collects every identifier bound to a `signal(...)` or
648
- * `computed(...)` call. Only `const` declarations are tracked — `let`/`var`
649
- * may be reassigned to non-signal values, so a use-site call wouldn't be a
650
- * reliable signal-write.
651
- *
652
- * The collection is intentionally scope-blind: a name shadowed in a nested
653
- * scope (`const x = signal(0); function f() { const x = 5; x(7) }`) would
654
- * produce a false positive on `x(7)`. That tradeoff is acceptable because
655
- * (1) shadowing a signal name with a non-signal is itself unusual and
656
- * (2) the detector message points at exactly the wrong-shape call so a
657
- * human reviewer can dismiss the rare false positive in seconds.
658
- */
659
- function collectSignalBindings(sf: ts.SourceFile): Set<string> {
660
- const names = new Set<string>()
661
- function isSignalFactoryCall(init: ts.Expression | undefined): boolean {
662
- if (!init || !ts.isCallExpression(init)) return false
663
- const callee = init.expression
664
- if (!ts.isIdentifier(callee)) return false
665
- return callee.text === 'signal' || callee.text === 'computed'
666
- }
667
- function walk(node: ts.Node): void {
668
- if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
669
- // Only `const` — find the parent VariableDeclarationList to check.
670
- const list = node.parent
671
- if (
672
- ts.isVariableDeclarationList(list) &&
673
- (list.flags & ts.NodeFlags.Const) !== 0 &&
674
- isSignalFactoryCall(node.initializer)
675
- ) {
676
- names.add(node.name.text)
677
- }
678
- }
679
- ts.forEachChild(node, walk)
680
- }
681
- walk(sf)
682
- return names
683
- }
684
-
685
- function detectSignalWriteAsCall(ctx: DetectContext, node: ts.CallExpression): void {
686
- if (ctx.signalBindings.size === 0) return
687
- const callee = node.expression
688
- if (!ts.isIdentifier(callee)) return
689
- if (!ctx.signalBindings.has(callee.text)) return
690
- // `sig()` (zero args) is a READ — that's the intended Pyreon API.
691
- if (node.arguments.length === 0) return
692
- // `sig.set(x)` / `sig.update(fn)` / `sig.peek()` — the proper write/read
693
- // surface — go through PropertyAccess, not direct CallExpression on the
694
- // identifier. So if we got here, the call is `sig(value)` or
695
- // `sig(value, ..)` which is the buggy shape.
696
- pushDiag(
697
- ctx,
698
- node,
699
- 'signal-write-as-call',
700
- `\`${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.`,
701
- getNodeText(ctx, node),
702
- `${callee.text}.set(${node.arguments.map((a) => getNodeText(ctx, a)).join(', ')})`,
703
- false,
704
- )
705
- }
706
-
707
- // ═══════════════════════════════════════════════════════════════════════════════
708
- // Pattern: static-return-null-conditional in component bodies
709
- // ═══════════════════════════════════════════════════════════════════════════════
710
-
711
- /**
712
- * `if (cond) return null` at the top of a component body runs ONCE — Pyreon
713
- * components mount and never re-execute their function bodies. A signal
714
- * change inside `cond` therefore never re-evaluates the condition; the
715
- * component is permanently stuck on whichever branch the first run picked.
716
- *
717
- * The fix is to wrap the conditional in a returned reactive accessor:
718
- * return (() => { if (!cond()) return null; return <div /> })
719
- *
720
- * Detection:
721
- * - The function contains JSX (i.e. it's a component)
722
- * - The function body has an `IfStatement` whose `thenStatement` is
723
- * `return null` (either bare `return null` or `{ return null }`)
724
- * - The `if` is at the function body's top level, NOT inside a returned
725
- * arrow / IIFE (those are reactive scopes — flagging them would be a
726
- * false positive)
727
- */
728
- function returnsNullStatement(stmt: ts.Statement): boolean {
729
- if (ts.isReturnStatement(stmt)) {
730
- const expr = stmt.expression
731
- return !!expr && expr.kind === ts.SyntaxKind.NullKeyword
732
- }
733
- if (ts.isBlock(stmt)) {
734
- return stmt.statements.length === 1 && returnsNullStatement(stmt.statements[0]!)
735
- }
736
- return false
737
- }
738
-
739
- /**
740
- * Returns true if the function looks like a top-level component:
741
- * - `function PascalName(...) { ... }` (FunctionDeclaration with PascalCase id), OR
742
- * - `const PascalName = (...) => { ... }` (arrow inside a VariableDeclaration whose name is PascalCase).
743
- *
744
- * Anonymous nested arrows — most importantly the reactive accessor
745
- * `return (() => { if (!cond()) return null; return <div /> })` — are
746
- * NOT considered components here, even when they contain JSX. Without
747
- * this filter the detector would fire on the very pattern the
748
- * diagnostic recommends as the fix.
749
- */
750
- function isComponentShapedFunction(
751
- node: ts.ArrowFunction | ts.FunctionDeclaration | ts.FunctionExpression,
752
- ): boolean {
753
- if (ts.isFunctionDeclaration(node)) {
754
- return !!node.name && /^[A-Z]/.test(node.name.text)
755
- }
756
- // Arrow / FunctionExpression: check VariableDeclaration parent.
757
- const parent = node.parent
758
- if (parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) {
759
- return /^[A-Z]/.test(parent.name.text)
760
- }
761
- return false
762
- }
763
-
764
- function detectStaticReturnNullConditional(
765
- ctx: DetectContext,
766
- node: ts.ArrowFunction | ts.FunctionDeclaration | ts.FunctionExpression,
767
- ): void {
768
- // Only component-shaped functions (must render JSX AND be named with
769
- // PascalCase) — see isComponentShapedFunction for why the name check
770
- // matters: it filters out the reactive-accessor-as-fix pattern.
771
- if (!isComponentShapedFunction(node)) return
772
- if (!containsJsx(node)) return
773
- const body = node.body
774
- if (!body || !ts.isBlock(body)) return
775
-
776
- for (const stmt of body.statements) {
777
- if (!ts.isIfStatement(stmt)) continue
778
- if (!returnsNullStatement(stmt.thenStatement)) continue
779
- // Found `if (cond) return null` at top-level component body scope.
780
- pushDiag(
781
- ctx,
782
- stmt,
783
- 'static-return-null-conditional',
784
- '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.',
785
- getNodeText(ctx, stmt),
786
- 'return (() => { if (!cond()) return null; return <JSX /> })',
787
- false,
788
- )
789
- // Only flag the FIRST occurrence per component to avoid noise on
790
- // chained early-returns (often a single mistake, not three).
791
- return
792
- }
793
- }
794
-
795
- // ═══════════════════════════════════════════════════════════════════════════════
796
- // Pattern: `expr as unknown as VNodeChild`
797
- // ═══════════════════════════════════════════════════════════════════════════════
798
-
799
- /**
800
- * `JSX.Element` (which is what JSX evaluates to) is already assignable to
801
- * `VNodeChild`. The `as unknown as VNodeChild` double-cast is unnecessary
802
- * — it's been showing up in `@pyreon/ui-primitives` as a defensive habit
803
- * carried over from earlier framework versions. The cast is never load-
804
- * bearing today; removing it never changes runtime behavior. Pure cosmetic
805
- * but a useful proxy for non-idiomatic Pyreon code in primitives.
806
- */
807
- function detectAsUnknownAsVNodeChild(ctx: DetectContext, node: ts.AsExpression): void {
808
- // Outer cast: `... as VNodeChild`
809
- const outerType = node.type
810
- if (!ts.isTypeReferenceNode(outerType)) return
811
- if (!ts.isIdentifier(outerType.typeName) || outerType.typeName.text !== 'VNodeChild') return
812
- // Inner: `<expr> as unknown`
813
- const inner = node.expression
814
- if (!ts.isAsExpression(inner)) return
815
- if (inner.type.kind !== ts.SyntaxKind.UnknownKeyword) return
816
-
817
- pushDiag(
818
- ctx,
819
- node,
820
- 'as-unknown-as-vnodechild',
821
- '`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.',
822
- getNodeText(ctx, node),
823
- getNodeText(ctx, inner.expression),
824
- false,
825
- )
826
- }
827
-
828
- // ═══════════════════════════════════════════════════════════════════════════════
829
- // Island never-with-registry detection
830
- // ═══════════════════════════════════════════════════════════════════════════════
831
-
832
- /**
833
- * Pre-pass: walk the source for `island(loader, { name: 'X', hydrate: 'never' })`
834
- * call expressions and collect the `name` field of each never-strategy island.
835
- *
836
- * Recognized shape (mirrors `@pyreon/vite-plugin`'s `scanIslandDeclarations`):
837
- *
838
- * island(() => import('./X'), { name: 'X', hydrate: 'never' })
839
- *
840
- * Edge cases the AST-walker deliberately doesn't cover (unrecognized calls
841
- * fall through and don't populate the set — false-negatives, not false
842
- * positives):
843
- *
844
- * - Loader is a variable, not an inline arrow
845
- * - Name is a variable / template / spread, not a string literal
846
- * - Options come from a spread (`island(loader, opts)`)
847
- *
848
- * The same rules apply on the registry side (`detectIslandNeverWithRegistry`):
849
- * unrecognized keys won't match. Both halves are syntactic — a semantic
850
- * cross-package audit lives in `pyreon doctor --check-islands` (separate PR).
851
- */
852
- function collectNeverIslandNames(sf: ts.SourceFile): Set<string> {
853
- const names = new Set<string>()
854
- function walk(node: ts.Node): void {
855
- if (
856
- ts.isCallExpression(node) &&
857
- ts.isIdentifier(node.expression) &&
858
- node.expression.text === 'island' &&
859
- node.arguments.length >= 2
860
- ) {
861
- const opts = node.arguments[1]
862
- if (opts && ts.isObjectLiteralExpression(opts)) {
863
- let nameVal: string | undefined
864
- let hydrateVal: string | undefined
865
- for (const prop of opts.properties) {
866
- if (!ts.isPropertyAssignment(prop)) continue
867
- const key = prop.name
868
- const keyText = ts.isIdentifier(key)
869
- ? key.text
870
- : ts.isStringLiteral(key)
871
- ? key.text
872
- : ''
873
- if (keyText === 'name' && ts.isStringLiteral(prop.initializer)) {
874
- nameVal = prop.initializer.text
875
- } else if (keyText === 'hydrate' && ts.isStringLiteral(prop.initializer)) {
876
- hydrateVal = prop.initializer.text
877
- }
878
- }
879
- if (nameVal && hydrateVal === 'never') {
880
- names.add(nameVal)
881
- }
882
- }
883
- }
884
- ts.forEachChild(node, walk)
885
- }
886
- walk(sf)
887
- return names
888
- }
889
-
890
- /**
891
- * Flag entries in `hydrateIslands({ X: () => import('./X'), ... })` whose
892
- * key matches an `island()` name declared with `hydrate: 'never'` in the
893
- * same file. Each matching entry produces one diagnostic at the property's
894
- * location so the IDE highlights exactly which key needs to go.
895
- */
896
- function detectIslandNeverWithRegistry(ctx: DetectContext, node: ts.CallExpression): void {
897
- if (ctx.neverIslandNames.size === 0) return
898
- const callee = node.expression
899
- if (!ts.isIdentifier(callee) || callee.text !== 'hydrateIslands') return
900
- const arg = node.arguments[0]
901
- if (!arg || !ts.isObjectLiteralExpression(arg)) return
902
- for (const prop of arg.properties) {
903
- if (!ts.isPropertyAssignment(prop) && !ts.isShorthandPropertyAssignment(prop)) continue
904
- const key = prop.name
905
- const keyText = ts.isIdentifier(key)
906
- ? key.text
907
- : ts.isStringLiteral(key)
908
- ? key.text
909
- : ''
910
- if (!keyText || !ctx.neverIslandNames.has(keyText)) continue
911
- pushDiag(
912
- ctx,
913
- prop,
914
- 'island-never-with-registry-entry',
915
- `island "${keyText}" was declared with \`hydrate: 'never'\` and MUST NOT be registered in \`hydrateIslands({ ... })\`. The whole point of the \`'never'\` strategy is shipping zero client JS — registering pulls the component module into the client bundle graph (the runtime short-circuits never-strategy before the registry lookup, but the bundler still includes the import). Drop this entry; the framework handles never-strategy islands at SSR with no client-side wiring.`,
916
- getNodeText(ctx, prop),
917
- `// remove the "${keyText}" entry — never-strategy islands need no registry entry`,
918
- false,
919
- )
920
- }
921
- }
922
-
923
- // ═══════════════════════════════════════════════════════════════════════════════
924
- // Visitor
925
- // ═══════════════════════════════════════════════════════════════════════════════
926
-
927
- function visitNode(ctx: DetectContext, node: ts.Node): void {
928
- if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) {
929
- detectForKeying(ctx, node)
930
- }
931
- if (
932
- ts.isArrowFunction(node) ||
933
- ts.isFunctionDeclaration(node) ||
934
- ts.isFunctionExpression(node)
935
- ) {
936
- detectPropsDestructured(ctx, node)
937
- detectPropsDestructuredBody(ctx, node)
938
- detectStaticReturnNullConditional(ctx, node)
939
- }
940
- if (ts.isBinaryExpression(node)) {
941
- detectProcessDevGate(ctx, node)
942
- detectDateMathRandomId(ctx, node)
943
- }
944
- if (ts.isTemplateExpression(node)) {
945
- detectDateMathRandomId(ctx, node)
946
- }
947
- if (ts.isCallExpression(node)) {
948
- detectEmptyTheme(ctx, node)
949
- detectRawEventListener(ctx, node)
950
- detectSignalWriteAsCall(ctx, node)
951
- detectIslandNeverWithRegistry(ctx, node)
952
- detectQueryOptionsAsFunction(ctx, node)
953
- }
954
- if (ts.isJsxAttribute(node)) {
955
- detectOnClickUndefined(ctx, node)
956
- }
957
- if (ts.isAsExpression(node)) {
958
- detectAsUnknownAsVNodeChild(ctx, node)
959
- }
960
- }
961
-
962
- function visit(ctx: DetectContext, node: ts.Node): void {
963
- ts.forEachChild(node, (child) => {
964
- visitNode(ctx, child)
965
- visit(ctx, child)
966
- })
967
- }
968
-
969
- // ═══════════════════════════════════════════════════════════════════════════════
970
- // Public API
971
- // ═══════════════════════════════════════════════════════════════════════════════
972
-
973
- export function detectPyreonPatterns(code: string, filename = 'input.tsx'): PyreonDiagnostic[] {
974
- const sf = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TSX)
975
- const ctx: DetectContext = {
976
- sf,
977
- code,
978
- diagnostics: [],
979
- signalBindings: collectSignalBindings(sf),
980
- neverIslandNames: collectNeverIslandNames(sf),
981
- }
982
- visit(ctx, sf)
983
- // Sort by (line, column) for stable ordering when multiple patterns fire.
984
- ctx.diagnostics.sort((a, b) => a.line - b.line || a.column - b.column)
985
- return ctx.diagnostics
986
- }
987
-
988
- /** Fast regex pre-filter — returns true if the code is worth a full AST walk. */
989
- export function hasPyreonPatterns(code: string): boolean {
990
- return (
991
- /\bFor\b[^=]*\beach\s*=/.test(code) ||
992
- /\btypeof\s+process\b/.test(code) ||
993
- /\.theme\s*\(\s*\{\s*\}\s*\)/.test(code) ||
994
- /\b(?:add|remove)EventListener\s*\(/.test(code) ||
995
- (/\bDate\.now\s*\(/.test(code) && /\bMath\.random\s*\(/.test(code)) ||
996
- // Bounded `\w{0,60}` cap on the handler identifier — real `on*`
997
- // names are at most ~25 chars (`onPointerLeaveCapture`); 60 leaves
998
- // headroom. The unbounded `\w*` form was flagged by CodeQL
999
- // `js/polynomial-redos` (alert #65) as polynomial-time on inputs
1000
- // like `onAAAA…` (long runs of `[A-Z]`): per starting position
1001
- // the greedy `\w*` consumes O(N) chars before the trailing `=`
1002
- // fails to match, giving O(N²) overall on N starting positions.
1003
- // The cap keeps the regex linear regardless of input shape.
1004
- /on[A-Z]\w{0,60}\s*=\s*\{\s*undefined\s*\}/.test(code) ||
1005
- // Bounded `{0,500}` / `{1,500}` quantifiers — this is a pre-filter
1006
- // scan before the precise AST walker, so losing detector recall on
1007
- // a pathologically long single-line input is acceptable.
1008
- /=\s*\(\s*\{[^}]{1,500}\}\s*[:)]/.test(code) ||
1009
- // props-destructured-body: `const { … } = <ident>` anywhere.
1010
- /\b(?:const|let|var)\s+\{[^}]{0,500}\}\s*=\s*[A-Za-z_$]/.test(code) ||
1011
- // signal-write-as-call: `const X = signal(` declaration anywhere
1012
- /\b(?:signal|computed)\s*[<(]/.test(code) ||
1013
- // static-return-null-conditional: `if (...) return null` anywhere.
1014
- // `[\s{]*` (single class) instead of `\s*\{?\s*` (overlapping
1015
- // quantifiers) — the latter is polynomial on long whitespace runs.
1016
- /\bif\s*\([^)]{1,500}\)[\s{]{0,20}return\s+null\b/.test(code) ||
1017
- // as-unknown-as-vnodechild
1018
- /\bas\s+unknown\s+as\s+VNodeChild\b/.test(code) ||
1019
- // query-options-as-function: a query hook called with an object literal
1020
- /\b(?:useQuery|useInfiniteQuery|useQueries|useSuspenseQuery)\s*\(\s*\{/.test(
1021
- code,
1022
- ) ||
1023
- // island-never-with-registry-entry: a never-strategy declaration AND a
1024
- // hydrateIslands call must both appear in the same source for the bug
1025
- // shape to trigger. Pre-filter on EITHER half — the AST walker fast-
1026
- // exits when the never-island set is empty.
1027
- (/\bisland\s*\(/.test(code) && /\bhydrate\s*:\s*['"]never['"]/.test(code))
1028
- )
1029
- }