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