@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.
- package/package.json +11 -13
- package/src/defer-inline.ts +0 -686
- package/src/event-names.ts +0 -65
- package/src/index.ts +0 -61
- package/src/island-audit.ts +0 -675
- package/src/jsx.ts +0 -2792
- package/src/load-native.ts +0 -156
- package/src/lpih.ts +0 -270
- package/src/manifest.ts +0 -280
- package/src/project-scanner.ts +0 -214
- package/src/pyreon-intercept.ts +0 -1029
- package/src/react-intercept.ts +0 -1217
- package/src/reactivity-lens.ts +0 -190
- package/src/ssg-audit.ts +0 -513
- package/src/test-audit.ts +0 -435
- package/src/tests/backend-parity-r7-r9.test.ts +0 -91
- package/src/tests/backend-prop-derived-callback-divergence.test.ts +0 -74
- package/src/tests/collapse-bail-census.test.ts +0 -330
- package/src/tests/collapse-key-source-hygiene.test.ts +0 -88
- package/src/tests/component-child-no-wrap.test.ts +0 -204
- package/src/tests/defer-inline.test.ts +0 -387
- package/src/tests/depth-stress.test.ts +0 -16
- package/src/tests/detector-tag-consistency.test.ts +0 -101
- package/src/tests/dynamic-collapse-detector.test.ts +0 -164
- package/src/tests/dynamic-collapse-emit.test.ts +0 -192
- package/src/tests/dynamic-collapse-scan.test.ts +0 -111
- package/src/tests/element-valued-const-child.test.ts +0 -61
- package/src/tests/falsy-child-characterization.test.ts +0 -48
- package/src/tests/island-audit.test.ts +0 -524
- package/src/tests/jsx.test.ts +0 -2908
- package/src/tests/load-native.test.ts +0 -53
- package/src/tests/lpih.test.ts +0 -404
- package/src/tests/malformed-input-resilience.test.ts +0 -50
- package/src/tests/manifest-snapshot.test.ts +0 -55
- package/src/tests/native-equivalence.test.ts +0 -924
- package/src/tests/partial-collapse-detector.test.ts +0 -121
- package/src/tests/partial-collapse-emit.test.ts +0 -104
- package/src/tests/partial-collapse-robustness.test.ts +0 -53
- package/src/tests/project-scanner.test.ts +0 -269
- package/src/tests/prop-derived-shadow.test.ts +0 -96
- package/src/tests/pure-call-reactive-args.test.ts +0 -50
- package/src/tests/pyreon-intercept.test.ts +0 -816
- package/src/tests/r13-callback-stmt-equivalence.test.ts +0 -58
- package/src/tests/r14-ssr-mode-parity.test.ts +0 -51
- package/src/tests/r15-elemconst-propderived.test.ts +0 -47
- package/src/tests/r19-defer-inline-robust.test.ts +0 -54
- package/src/tests/r20-backend-equivalence-sweep.test.ts +0 -50
- package/src/tests/react-intercept.test.ts +0 -1104
- package/src/tests/reactivity-lens.test.ts +0 -170
- package/src/tests/rocketstyle-collapse.test.ts +0 -208
- package/src/tests/runtime/control-flow.test.ts +0 -159
- package/src/tests/runtime/dom-properties.test.ts +0 -138
- package/src/tests/runtime/events.test.ts +0 -301
- package/src/tests/runtime/harness.ts +0 -94
- package/src/tests/runtime/pr-352-shapes.test.ts +0 -121
- package/src/tests/runtime/reactive-props.test.ts +0 -81
- package/src/tests/runtime/signals.test.ts +0 -129
- package/src/tests/runtime/whitespace.test.ts +0 -106
- package/src/tests/signal-autocall-shadow.test.ts +0 -86
- package/src/tests/sourcemap-fidelity.test.ts +0 -77
- package/src/tests/ssg-audit.test.ts +0 -402
- package/src/tests/static-text-baking.test.ts +0 -64
- package/src/tests/test-audit.test.ts +0 -549
- package/src/tests/transform-state-isolation.test.ts +0 -49
package/src/pyreon-intercept.ts
DELETED
|
@@ -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
|
-
}
|