@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
|
@@ -236,4 +236,34 @@ describe('project-scanner — collectSourceFiles', () => {
|
|
|
236
236
|
expect(ctx.islands).toEqual([])
|
|
237
237
|
expect(ctx.version).toBe('unknown')
|
|
238
238
|
})
|
|
239
|
+
|
|
240
|
+
test('skips hidden directories (dot-prefixed)', () => {
|
|
241
|
+
const dir = createTempProject({
|
|
242
|
+
'package.json': JSON.stringify({ name: 'test', version: '1.0.0' }),
|
|
243
|
+
'src/app.tsx': 'export const App = () => <div />',
|
|
244
|
+
'.hidden/secret.tsx': 'export const Secret = () => <div />',
|
|
245
|
+
})
|
|
246
|
+
try {
|
|
247
|
+
const ctx = generateContext(dir)
|
|
248
|
+
const files = ctx.components.map((c) => c.file)
|
|
249
|
+
for (const f of files) {
|
|
250
|
+
expect(f).not.toContain('.hidden')
|
|
251
|
+
}
|
|
252
|
+
} finally {
|
|
253
|
+
cleanupDir(dir)
|
|
254
|
+
}
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
test('version falls back to "unknown" when package.json has no version', () => {
|
|
258
|
+
const dir = createTempProject({
|
|
259
|
+
'package.json': JSON.stringify({ name: 'test' }),
|
|
260
|
+
})
|
|
261
|
+
try {
|
|
262
|
+
const ctx = generateContext(dir)
|
|
263
|
+
// No @pyreon deps and no version field → 'unknown' (line 210 branch)
|
|
264
|
+
expect(ctx.version).toBe('unknown')
|
|
265
|
+
} finally {
|
|
266
|
+
cleanupDir(dir)
|
|
267
|
+
}
|
|
268
|
+
})
|
|
239
269
|
})
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { detectPyreonPatterns, hasPyreonPatterns } from '../pyreon-intercept'
|
|
2
|
+
|
|
3
|
+
describe('detectPyreonPatterns', () => {
|
|
4
|
+
describe('for-missing-by', () => {
|
|
5
|
+
it('flags <For each={...}> without a `by` prop', () => {
|
|
6
|
+
const code = `
|
|
7
|
+
const items = signal([1, 2, 3])
|
|
8
|
+
const UI = () => <For each={items()}>{(n) => <li>{n}</li>}</For>
|
|
9
|
+
`
|
|
10
|
+
const diags = detectPyreonPatterns(code)
|
|
11
|
+
expect(diags).toHaveLength(1)
|
|
12
|
+
expect(diags[0]!.code).toBe('for-missing-by')
|
|
13
|
+
expect(diags[0]!.message).toContain('keyed reconciler')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('does NOT flag <For> that carries a `by`', () => {
|
|
17
|
+
const code = `
|
|
18
|
+
const UI = () => <For each={items()} by={(i) => i.id}>{(i) => <li>{i.name}</li>}</For>
|
|
19
|
+
`
|
|
20
|
+
const diags = detectPyreonPatterns(code)
|
|
21
|
+
expect(diags.filter((d) => d.code === 'for-missing-by')).toEqual([])
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('for-with-key', () => {
|
|
26
|
+
it('flags <For key={...}> as the wrong keying prop', () => {
|
|
27
|
+
const code = `
|
|
28
|
+
const UI = () => <For each={items()} key={(i) => i.id}>{(i) => <li>{i.name}</li>}</For>
|
|
29
|
+
`
|
|
30
|
+
const diags = detectPyreonPatterns(code)
|
|
31
|
+
const withKey = diags.find((d) => d.code === 'for-with-key')
|
|
32
|
+
expect(withKey).toBeDefined()
|
|
33
|
+
expect(withKey!.suggested).toContain('by={')
|
|
34
|
+
// fixable stays `false` until a `migrate_pyreon` tool ships; see
|
|
35
|
+
// the top-of-file note on detectPyreonPatterns.
|
|
36
|
+
expect(withKey!.fixable).toBe(false)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('does not ALSO flag for-missing-by when for-with-key fires', () => {
|
|
40
|
+
// Otherwise consumers would see two entries for the same mistake.
|
|
41
|
+
const code = `<For each={items} key={(i) => i.id}>{(i) => <li />}</For>`
|
|
42
|
+
const diags = detectPyreonPatterns(code)
|
|
43
|
+
expect(diags.filter((d) => d.code === 'for-missing-by')).toEqual([])
|
|
44
|
+
expect(diags.filter((d) => d.code === 'for-with-key')).toHaveLength(1)
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('props-destructured', () => {
|
|
49
|
+
it('flags destructured props on arrow component functions', () => {
|
|
50
|
+
const code = `
|
|
51
|
+
const Greeting = ({ name }: { name: string }) => <div>Hello {name}</div>
|
|
52
|
+
`
|
|
53
|
+
const diags = detectPyreonPatterns(code)
|
|
54
|
+
expect(diags).toHaveLength(1)
|
|
55
|
+
expect(diags[0]!.code).toBe('props-destructured')
|
|
56
|
+
expect(diags[0]!.message).toContain('ONCE')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('flags destructured props on function declarations that render JSX', () => {
|
|
60
|
+
const code = `
|
|
61
|
+
function Greeting({ name }: { name: string }) {
|
|
62
|
+
return <div>Hello {name}</div>
|
|
63
|
+
}
|
|
64
|
+
`
|
|
65
|
+
const diags = detectPyreonPatterns(code)
|
|
66
|
+
expect(diags.filter((d) => d.code === 'props-destructured')).toHaveLength(1)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('does NOT flag destructured params on non-component callbacks', () => {
|
|
70
|
+
const code = `
|
|
71
|
+
const handler = ({ value }: { value: string }) => console.log(value)
|
|
72
|
+
const reduce = ({ a, b }: { a: number; b: number }) => a + b
|
|
73
|
+
`
|
|
74
|
+
const diags = detectPyreonPatterns(code)
|
|
75
|
+
expect(diags.filter((d) => d.code === 'props-destructured')).toEqual([])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('does NOT flag components that accept a single `props` parameter', () => {
|
|
79
|
+
const code = `
|
|
80
|
+
const Greeting = (props: { name: string }) => <div>Hello {props.name}</div>
|
|
81
|
+
`
|
|
82
|
+
const diags = detectPyreonPatterns(code)
|
|
83
|
+
expect(diags.filter((d) => d.code === 'props-destructured')).toEqual([])
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('process-dev-gate', () => {
|
|
88
|
+
it('flags typeof process + NODE_ENV production gates', () => {
|
|
89
|
+
const code = `
|
|
90
|
+
if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production') {
|
|
91
|
+
console.warn('dev only')
|
|
92
|
+
}
|
|
93
|
+
`
|
|
94
|
+
const diags = detectPyreonPatterns(code)
|
|
95
|
+
expect(diags).toHaveLength(1)
|
|
96
|
+
expect(diags[0]!.code).toBe('process-dev-gate')
|
|
97
|
+
expect(diags[0]!.suggested).toContain('import.meta.env')
|
|
98
|
+
expect(diags[0]!.fixable).toBe(false)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('flags the reversed operand order', () => {
|
|
102
|
+
const code = `
|
|
103
|
+
const IS_DEV = process.env.NODE_ENV !== 'production' && typeof process !== 'undefined'
|
|
104
|
+
`
|
|
105
|
+
const diags = detectPyreonPatterns(code)
|
|
106
|
+
expect(diags.filter((d) => d.code === 'process-dev-gate')).toHaveLength(1)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('does NOT flag plain typeof process checks (server-side code is fine)', () => {
|
|
110
|
+
const code = `
|
|
111
|
+
if (typeof process !== 'undefined') {
|
|
112
|
+
process.exit(0)
|
|
113
|
+
}
|
|
114
|
+
`
|
|
115
|
+
const diags = detectPyreonPatterns(code)
|
|
116
|
+
expect(diags).toEqual([])
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('empty-theme', () => {
|
|
121
|
+
it('flags `.theme({})` as a no-op chain', () => {
|
|
122
|
+
const code = `
|
|
123
|
+
const Button = rocketstyle('button').attrs({ tag: 'button' }).theme({})
|
|
124
|
+
`
|
|
125
|
+
const diags = detectPyreonPatterns(code)
|
|
126
|
+
expect(diags).toHaveLength(1)
|
|
127
|
+
expect(diags[0]!.code).toBe('empty-theme')
|
|
128
|
+
expect(diags[0]!.fixable).toBe(false)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('does NOT flag `.theme(...)` with actual content', () => {
|
|
132
|
+
const code = `
|
|
133
|
+
const Button = rocketstyle('button').theme({ color: 'red' })
|
|
134
|
+
`
|
|
135
|
+
const diags = detectPyreonPatterns(code)
|
|
136
|
+
expect(diags).toEqual([])
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe('raw-add-event-listener / raw-remove-event-listener', () => {
|
|
141
|
+
it('flags window.addEventListener with useEventListener suggestion', () => {
|
|
142
|
+
const code = `
|
|
143
|
+
const Panel = () => {
|
|
144
|
+
window.addEventListener('resize', () => console.log('resize'))
|
|
145
|
+
return <div />
|
|
146
|
+
}
|
|
147
|
+
`
|
|
148
|
+
const diags = detectPyreonPatterns(code)
|
|
149
|
+
const add = diags.find((d) => d.code === 'raw-add-event-listener')
|
|
150
|
+
expect(add).toBeDefined()
|
|
151
|
+
expect(add!.suggested).toContain('useEventListener')
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('flags document.removeEventListener', () => {
|
|
155
|
+
const code = `
|
|
156
|
+
document.removeEventListener('click', handler)
|
|
157
|
+
`
|
|
158
|
+
const diags = detectPyreonPatterns(code)
|
|
159
|
+
expect(diags.find((d) => d.code === 'raw-remove-event-listener')).toBeDefined()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('does NOT flag addEventListener on host-owned nested paths (e.g. editor.dom)', () => {
|
|
163
|
+
// `view.dom.ownerDocument...` and similar framework-host chains
|
|
164
|
+
// are intentional — the rule should only flag bare window/document
|
|
165
|
+
// and obvious DOM-element identifiers.
|
|
166
|
+
const code = `
|
|
167
|
+
view.dom.ownerDocument.addEventListener('click', h)
|
|
168
|
+
`
|
|
169
|
+
const diags = detectPyreonPatterns(code)
|
|
170
|
+
expect(diags).toEqual([])
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
describe('date-math-random-id', () => {
|
|
175
|
+
it('flags Date.now() + Math.random() ID patterns', () => {
|
|
176
|
+
const code = `
|
|
177
|
+
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
|
|
178
|
+
`
|
|
179
|
+
const diags = detectPyreonPatterns(code)
|
|
180
|
+
// The binary expression and any enclosing template expressions can both
|
|
181
|
+
// match — dedupe by line to keep the contract observable.
|
|
182
|
+
const atLine = [...new Set(diags.filter((d) => d.code === 'date-math-random-id').map((d) => d.line))]
|
|
183
|
+
expect(atLine.length).toBeGreaterThanOrEqual(1)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('flags template-literal variants', () => {
|
|
187
|
+
const code = 'const id = `${Date.now()}-${Math.random()}`'
|
|
188
|
+
const diags = detectPyreonPatterns(code)
|
|
189
|
+
expect(diags.find((d) => d.code === 'date-math-random-id')).toBeDefined()
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('does NOT flag Date.now() alone', () => {
|
|
193
|
+
const code = `const now = Date.now()`
|
|
194
|
+
const diags = detectPyreonPatterns(code)
|
|
195
|
+
expect(diags).toEqual([])
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
describe('on-click-undefined', () => {
|
|
200
|
+
it('flags explicit onClick={undefined}', () => {
|
|
201
|
+
const code = `<button onClick={undefined}>Go</button>`
|
|
202
|
+
const diags = detectPyreonPatterns(code)
|
|
203
|
+
expect(diags).toHaveLength(1)
|
|
204
|
+
expect(diags[0]!.code).toBe('on-click-undefined')
|
|
205
|
+
expect(diags[0]!.fixable).toBe(false)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('flags other on* handlers set to undefined', () => {
|
|
209
|
+
const code = `<input onInput={undefined} />`
|
|
210
|
+
const diags = detectPyreonPatterns(code)
|
|
211
|
+
expect(diags.find((d) => d.code === 'on-click-undefined')).toBeDefined()
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('does NOT flag onClick={cond ? handler : undefined} (conditional is safe)', () => {
|
|
215
|
+
const code = `<button onClick={condition ? handler : undefined}>Go</button>`
|
|
216
|
+
const diags = detectPyreonPatterns(code)
|
|
217
|
+
expect(diags.filter((d) => d.code === 'on-click-undefined')).toEqual([])
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
describe('hasPyreonPatterns (regex pre-filter)', () => {
|
|
222
|
+
it('returns true for every detected pattern', () => {
|
|
223
|
+
const samples = [
|
|
224
|
+
`<For each={x}>{(i) => <li />}</For>`,
|
|
225
|
+
`typeof process !== 'undefined'`,
|
|
226
|
+
`.theme({})`,
|
|
227
|
+
`window.addEventListener('x', h)`,
|
|
228
|
+
`const id = \`\${Date.now()}-\${Math.random()}\``,
|
|
229
|
+
`<button onClick={undefined}>x</button>`,
|
|
230
|
+
`const X = ({ name }) => <div>{name}</div>`,
|
|
231
|
+
]
|
|
232
|
+
for (const s of samples) {
|
|
233
|
+
expect(hasPyreonPatterns(s)).toBe(true)
|
|
234
|
+
}
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('returns false for unrelated code', () => {
|
|
238
|
+
expect(hasPyreonPatterns(`const x = 1 + 2`)).toBe(false)
|
|
239
|
+
expect(hasPyreonPatterns(`console.log('ok')`)).toBe(false)
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
describe('combined scenarios', () => {
|
|
244
|
+
it('finds every distinct pattern in a multi-issue file', () => {
|
|
245
|
+
const code = `
|
|
246
|
+
const List = ({ items }) => <For each={items}>{(i) => <li />}</For>
|
|
247
|
+
const flag = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'
|
|
248
|
+
window.addEventListener('resize', () => {})
|
|
249
|
+
const Styled = rocketstyle('div').theme({})
|
|
250
|
+
const id = Date.now() + Math.random()
|
|
251
|
+
const Btn = () => <button onClick={undefined}>x</button>
|
|
252
|
+
`
|
|
253
|
+
const diags = detectPyreonPatterns(code)
|
|
254
|
+
const codes = new Set(diags.map((d) => d.code))
|
|
255
|
+
expect(codes.has('for-missing-by')).toBe(true)
|
|
256
|
+
expect(codes.has('props-destructured')).toBe(true)
|
|
257
|
+
expect(codes.has('process-dev-gate')).toBe(true)
|
|
258
|
+
expect(codes.has('raw-add-event-listener')).toBe(true)
|
|
259
|
+
expect(codes.has('empty-theme')).toBe(true)
|
|
260
|
+
expect(codes.has('date-math-random-id')).toBe(true)
|
|
261
|
+
expect(codes.has('on-click-undefined')).toBe(true)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('returns an empty array for idiomatic Pyreon code', () => {
|
|
265
|
+
const code = `
|
|
266
|
+
import { signal, effect } from '@pyreon/reactivity'
|
|
267
|
+
import { useEventListener } from '@pyreon/hooks'
|
|
268
|
+
|
|
269
|
+
const Counter = (props: { initial?: number }) => {
|
|
270
|
+
const count = signal(props.initial ?? 0)
|
|
271
|
+
useEventListener(window, 'keydown', () => count.update((n) => n + 1))
|
|
272
|
+
return (
|
|
273
|
+
<For each={items()} by={(i) => i.id}>
|
|
274
|
+
{(i) => <li>{i.name}: {count()}</li>}
|
|
275
|
+
</For>
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
`
|
|
279
|
+
expect(detectPyreonPatterns(code)).toEqual([])
|
|
280
|
+
})
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
describe('fixable contract — ALL Pyreon codes are fixable:false', () => {
|
|
284
|
+
// Binding invariant: until a `migrate_pyreon` tool exists, every
|
|
285
|
+
// Pyreon diagnostic must report `fixable: false`. Claiming a code
|
|
286
|
+
// is auto-fixable while no migrator handles it would mislead
|
|
287
|
+
// consumers building on the flag. Flip to `true` only when the
|
|
288
|
+
// companion migrator lands in a subsequent PR.
|
|
289
|
+
it('never emits a Pyreon diagnostic with fixable: true', () => {
|
|
290
|
+
const snippets = [
|
|
291
|
+
`<For each={x} key={(i) => i.id}>{(i) => <li />}</For>`, // for-with-key
|
|
292
|
+
`<For each={x}>{(i) => <li />}</For>`, // for-missing-by
|
|
293
|
+
`const X = ({ y }) => <div>{y}</div>`, // props-destructured
|
|
294
|
+
`typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'`, // process-dev-gate
|
|
295
|
+
`rocketstyle('div').theme({})`, // empty-theme
|
|
296
|
+
`window.addEventListener('x', h)`, // raw-add-event-listener
|
|
297
|
+
`document.removeEventListener('x', h)`, // raw-remove-event-listener
|
|
298
|
+
'const id = `${Date.now()}-${Math.random()}`', // date-math-random-id
|
|
299
|
+
`<button onClick={undefined}>x</button>`, // on-click-undefined
|
|
300
|
+
]
|
|
301
|
+
for (const code of snippets) {
|
|
302
|
+
const diags = detectPyreonPatterns(code)
|
|
303
|
+
for (const d of diags) {
|
|
304
|
+
expect(d.fixable, `${d.code} must be fixable:false`).toBe(false)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
})
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
describe('diagnostic shape', () => {
|
|
311
|
+
it('emits 1-based line + 0-based column with trimmed current/suggested', () => {
|
|
312
|
+
const code = `\n\n <For each={items}>{(i) => <li />}</For>`
|
|
313
|
+
const diags = detectPyreonPatterns(code)
|
|
314
|
+
expect(diags[0]!.line).toBe(3)
|
|
315
|
+
expect(diags[0]!.column).toBeGreaterThanOrEqual(0)
|
|
316
|
+
expect(diags[0]!.current).not.toMatch(/^\s/)
|
|
317
|
+
expect(diags[0]!.suggested).not.toMatch(/^\s/)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('sorts diagnostics by line ascending', () => {
|
|
321
|
+
const code = `
|
|
322
|
+
const A = () => <button onClick={undefined} />
|
|
323
|
+
const B = () => <For each={x} />
|
|
324
|
+
const C = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'
|
|
325
|
+
`
|
|
326
|
+
const diags = detectPyreonPatterns(code)
|
|
327
|
+
const lines = diags.map((d) => d.line)
|
|
328
|
+
expect(lines).toEqual([...lines].sort((a, b) => a - b))
|
|
329
|
+
})
|
|
330
|
+
})
|
|
331
|
+
})
|