@pyreon/compiler 0.15.0 → 0.18.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 +17 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +1314 -21
- package/lib/types/index.d.ts +167 -2
- package/package.json +15 -5
- package/src/defer-inline.ts +446 -0
- package/src/index.ts +19 -0
- package/src/island-audit.ts +675 -0
- package/src/jsx.ts +68 -33
- package/src/load-native.ts +155 -0
- package/src/pyreon-intercept.ts +127 -1
- package/src/ssg-audit.ts +513 -0
- package/src/tests/defer-inline.test.ts +199 -0
- package/src/tests/detector-tag-consistency.test.ts +28 -15
- package/src/tests/island-audit.test.ts +524 -0
- package/src/tests/jsx.test.ts +23 -3
- package/src/tests/load-native.test.ts +53 -0
- package/src/tests/pyreon-intercept.test.ts +141 -0
- package/src/tests/ssg-audit.test.ts +402 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { transformDeferInline } from '../defer-inline'
|
|
2
|
+
|
|
3
|
+
describe('transformDeferInline — basic rewrites', () => {
|
|
4
|
+
test('rewrites <Defer when={x}><Modal /></Defer> with named import', () => {
|
|
5
|
+
const input = `
|
|
6
|
+
import { Defer } from '@pyreon/core'
|
|
7
|
+
import { Modal } from './Modal'
|
|
8
|
+
|
|
9
|
+
export function App() {
|
|
10
|
+
const open = () => true
|
|
11
|
+
return <Defer when={open}><Modal /></Defer>
|
|
12
|
+
}
|
|
13
|
+
`
|
|
14
|
+
const result = transformDeferInline(input, 'app.tsx')
|
|
15
|
+
expect(result.changed).toBe(true)
|
|
16
|
+
expect(result.code).not.toContain("import { Modal } from './Modal'")
|
|
17
|
+
expect(result.code).toContain(`chunk={() => import('./Modal').then((__m) => ({ default: __m.Modal }))}`)
|
|
18
|
+
expect(result.code).toContain('{(__C) => <__C />}')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('rewrites with default import', () => {
|
|
22
|
+
const input = `
|
|
23
|
+
import { Defer } from '@pyreon/core'
|
|
24
|
+
import Modal from './Modal'
|
|
25
|
+
|
|
26
|
+
export function App() {
|
|
27
|
+
return <Defer when={() => true}><Modal /></Defer>
|
|
28
|
+
}
|
|
29
|
+
`
|
|
30
|
+
const result = transformDeferInline(input, 'app.tsx')
|
|
31
|
+
expect(result.changed).toBe(true)
|
|
32
|
+
expect(result.code).not.toContain('import Modal from')
|
|
33
|
+
expect(result.code).toContain(`chunk={() => import('./Modal')}`)
|
|
34
|
+
expect(result.code).not.toContain(`.then((__m) =>`)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('preserves other props on Defer (fallback, when, on)', () => {
|
|
38
|
+
const input = `
|
|
39
|
+
import { Defer } from '@pyreon/core'
|
|
40
|
+
import { Modal } from './Modal'
|
|
41
|
+
export function App() {
|
|
42
|
+
return <Defer when={() => true} fallback={<span>loading</span>}><Modal /></Defer>
|
|
43
|
+
}
|
|
44
|
+
`
|
|
45
|
+
const result = transformDeferInline(input, 'app.tsx')
|
|
46
|
+
expect(result.changed).toBe(true)
|
|
47
|
+
expect(result.code).toContain('when={() => true}')
|
|
48
|
+
expect(result.code).toContain('fallback={<span>loading</span>}')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('works for on="visible" trigger', () => {
|
|
52
|
+
const input = `
|
|
53
|
+
import { Defer } from '@pyreon/core'
|
|
54
|
+
import { Comments } from './Comments'
|
|
55
|
+
export function Post() {
|
|
56
|
+
return <Defer on="visible"><Comments /></Defer>
|
|
57
|
+
}
|
|
58
|
+
`
|
|
59
|
+
const result = transformDeferInline(input, 'post.tsx')
|
|
60
|
+
expect(result.changed).toBe(true)
|
|
61
|
+
expect(result.code).toContain('on="visible"')
|
|
62
|
+
expect(result.code).toContain(`chunk={() => import('./Comments').then((__m) => ({ default: __m.Comments }))}`)
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('transformDeferInline — bail-out cases', () => {
|
|
67
|
+
test('leaves unchanged when chunk prop is already provided', () => {
|
|
68
|
+
const input = `
|
|
69
|
+
import { Defer } from '@pyreon/core'
|
|
70
|
+
import { Modal } from './Modal'
|
|
71
|
+
export function App() {
|
|
72
|
+
return (
|
|
73
|
+
<Defer chunk={() => import('./Modal')} when={() => true}>
|
|
74
|
+
{Modal => <Modal />}
|
|
75
|
+
</Defer>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
`
|
|
79
|
+
const result = transformDeferInline(input, 'app.tsx')
|
|
80
|
+
expect(result.changed).toBe(false)
|
|
81
|
+
expect(result.code).toBe(input)
|
|
82
|
+
expect(result.warnings).toEqual([])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('warns when inline child is also used outside the Defer', () => {
|
|
86
|
+
const input = `
|
|
87
|
+
import { Defer } from '@pyreon/core'
|
|
88
|
+
import { Modal } from './Modal'
|
|
89
|
+
const eagerCopy = <Modal />
|
|
90
|
+
export function App() {
|
|
91
|
+
return <Defer when={() => true}><Modal /></Defer>
|
|
92
|
+
}
|
|
93
|
+
`
|
|
94
|
+
const result = transformDeferInline(input, 'app.tsx')
|
|
95
|
+
expect(result.changed).toBe(false)
|
|
96
|
+
expect(result.warnings).toHaveLength(1)
|
|
97
|
+
expect(result.warnings[0]!.code).toBe('defer-inline/import-used-elsewhere')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('warns when inline child is not imported', () => {
|
|
101
|
+
const input = `
|
|
102
|
+
import { Defer } from '@pyreon/core'
|
|
103
|
+
export function App() {
|
|
104
|
+
return <Defer when={() => true}><LocalThing /></Defer>
|
|
105
|
+
}
|
|
106
|
+
function LocalThing() { return null }
|
|
107
|
+
`
|
|
108
|
+
const result = transformDeferInline(input, 'app.tsx')
|
|
109
|
+
expect(result.changed).toBe(false)
|
|
110
|
+
expect(result.warnings).toHaveLength(1)
|
|
111
|
+
expect(result.warnings[0]!.code).toBe('defer-inline/import-not-found')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('skips Defer with multiple children (still requires render-prop form)', () => {
|
|
115
|
+
const input = `
|
|
116
|
+
import { Defer } from '@pyreon/core'
|
|
117
|
+
import { Modal } from './Modal'
|
|
118
|
+
import { Spinner } from './Spinner'
|
|
119
|
+
export function App() {
|
|
120
|
+
return <Defer when={() => true}><Modal /><Spinner /></Defer>
|
|
121
|
+
}
|
|
122
|
+
`
|
|
123
|
+
const result = transformDeferInline(input, 'app.tsx')
|
|
124
|
+
// No transform fires (multi-child shape doesn't match the inline-eligible
|
|
125
|
+
// single-component-child pattern). No warning either — v1 just leaves it
|
|
126
|
+
// alone; downstream Defer's runtime behaviour handles the malformed shape.
|
|
127
|
+
expect(result.changed).toBe(false)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('skips Defer whose child has props (multi-prop closure capture)', () => {
|
|
131
|
+
const input = `
|
|
132
|
+
import { Defer } from '@pyreon/core'
|
|
133
|
+
import { Modal } from './Modal'
|
|
134
|
+
export function App() {
|
|
135
|
+
return <Defer when={() => true}><Modal title="hi" /></Defer>
|
|
136
|
+
}
|
|
137
|
+
`
|
|
138
|
+
const result = transformDeferInline(input, 'app.tsx')
|
|
139
|
+
expect(result.changed).toBe(false)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('fast-path: no Defer in source returns unchanged', () => {
|
|
143
|
+
const input = `
|
|
144
|
+
import { signal } from '@pyreon/reactivity'
|
|
145
|
+
export const count = signal(0)
|
|
146
|
+
`
|
|
147
|
+
const result = transformDeferInline(input, 'count.ts')
|
|
148
|
+
expect(result.changed).toBe(false)
|
|
149
|
+
expect(result.code).toBe(input)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
test('does not blow up on syntactically-invalid source — returns unchanged', () => {
|
|
153
|
+
const input = `import {{{ Defer broken syntax`
|
|
154
|
+
const result = transformDeferInline(input, 'broken.tsx')
|
|
155
|
+
expect(result.changed).toBe(false)
|
|
156
|
+
// Returns the input unchanged; downstream parser will surface the real error.
|
|
157
|
+
expect(result.code).toBe(input)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('skips renamed imports — { Modal as M } not handled in v1', () => {
|
|
161
|
+
const input = `
|
|
162
|
+
import { Defer } from '@pyreon/core'
|
|
163
|
+
import { Modal as M } from './Modal'
|
|
164
|
+
export function App() {
|
|
165
|
+
return <Defer when={() => true}><M /></Defer>
|
|
166
|
+
}
|
|
167
|
+
`
|
|
168
|
+
const result = transformDeferInline(input, 'app.tsx')
|
|
169
|
+
expect(result.changed).toBe(false)
|
|
170
|
+
// Renamed-import case is not yet supported — falls through to the
|
|
171
|
+
// import-not-found warning (no specifier whose `local.name === 'M'`
|
|
172
|
+
// AND `imported.name === local.name` matches).
|
|
173
|
+
expect(result.warnings[0]?.code).toBe('defer-inline/import-not-found')
|
|
174
|
+
})
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
describe('transformDeferInline — multiple Defers in one file', () => {
|
|
178
|
+
test('rewrites two independent Defers with distinct imports', () => {
|
|
179
|
+
const input = `
|
|
180
|
+
import { Defer } from '@pyreon/core'
|
|
181
|
+
import { Modal } from './Modal'
|
|
182
|
+
import { Comments } from './Comments'
|
|
183
|
+
export function App() {
|
|
184
|
+
return (
|
|
185
|
+
<div>
|
|
186
|
+
<Defer when={() => true}><Modal /></Defer>
|
|
187
|
+
<Defer on="visible"><Comments /></Defer>
|
|
188
|
+
</div>
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
`
|
|
192
|
+
const result = transformDeferInline(input, 'app.tsx')
|
|
193
|
+
expect(result.changed).toBe(true)
|
|
194
|
+
expect(result.code).not.toContain("import { Modal } from './Modal'")
|
|
195
|
+
expect(result.code).not.toContain("import { Comments } from './Comments'")
|
|
196
|
+
expect(result.code).toContain(`chunk={() => import('./Modal').then((__m) => ({ default: __m.Modal }))}`)
|
|
197
|
+
expect(result.code).toContain(`chunk={() => import('./Comments').then((__m) => ({ default: __m.Comments }))}`)
|
|
198
|
+
})
|
|
199
|
+
})
|
|
@@ -2,18 +2,22 @@ import { readFileSync } from 'node:fs'
|
|
|
2
2
|
import { dirname, resolve } from 'node:path'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
4
|
|
|
5
|
-
// Drift guard between
|
|
6
|
-
//
|
|
5
|
+
// Drift guard between Pyreon's static detectors (compiler + lint) and the
|
|
6
|
+
// `[detector: <code>]` annotations on `.claude/rules/anti-patterns.md`.
|
|
7
7
|
// Without this test, a new bullet can land without a detector tag, or
|
|
8
8
|
// a detector code can be renamed without updating the doc. Either
|
|
9
9
|
// direction is a silent inconsistency — consumers read the doc and
|
|
10
10
|
// expect the detector to back it up.
|
|
11
11
|
//
|
|
12
12
|
// The test does one thing: every `[detector: CODE]` tag in the doc
|
|
13
|
-
// must reference a
|
|
14
|
-
//
|
|
15
|
-
// PyreonDiagnosticCode must appear at least once in the doc
|
|
16
|
-
// tag-documentation loop is closed.
|
|
13
|
+
// must reference a known detector (compiler PyreonDiagnosticCode OR
|
|
14
|
+
// @pyreon/lint rule ID without the `pyreon/` prefix), and every
|
|
15
|
+
// compiler PyreonDiagnosticCode must appear at least once in the doc
|
|
16
|
+
// so the tag-documentation loop is closed.
|
|
17
|
+
//
|
|
18
|
+
// Lint rules are NOT required to appear in anti-patterns.md (some are
|
|
19
|
+
// stylistic, not anti-pattern shaped). When they DO appear with a
|
|
20
|
+
// `[detector:]` tag, the tag must match the rule ID's local part.
|
|
17
21
|
|
|
18
22
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
19
23
|
const REPO_ROOT = resolve(HERE, '../../../../../')
|
|
@@ -22,7 +26,7 @@ const ANTI_PATTERNS_PATH = resolve(REPO_ROOT, '.claude/rules/anti-patterns.md')
|
|
|
22
26
|
// Kept in sync with the `PyreonDiagnosticCode` union in
|
|
23
27
|
// `pyreon-intercept.ts`. When adding a new code, ALSO add a bullet
|
|
24
28
|
// (with the `[detector: <code>]` tag) to `anti-patterns.md`.
|
|
25
|
-
const
|
|
29
|
+
const COMPILER_CODES = [
|
|
26
30
|
'for-missing-by',
|
|
27
31
|
'for-with-key',
|
|
28
32
|
'props-destructured',
|
|
@@ -35,8 +39,17 @@ const KNOWN_CODES = [
|
|
|
35
39
|
'signal-write-as-call',
|
|
36
40
|
'static-return-null-conditional',
|
|
37
41
|
'as-unknown-as-vnodechild',
|
|
42
|
+
'island-never-with-registry-entry',
|
|
43
|
+
] as const
|
|
44
|
+
type CompilerCode = (typeof COMPILER_CODES)[number]
|
|
45
|
+
|
|
46
|
+
// `@pyreon/lint` rule IDs that may appear as `[detector:]` tags. Listed
|
|
47
|
+
// WITHOUT the `pyreon/` prefix (the tag convention strips it for
|
|
48
|
+
// readability). Add the rule ID here when documenting a new lint rule
|
|
49
|
+
// in anti-patterns.md.
|
|
50
|
+
const LINT_RULE_DETECTORS = [
|
|
51
|
+
'storage-signal-v-forwarding',
|
|
38
52
|
] as const
|
|
39
|
-
type KnownCode = (typeof KNOWN_CODES)[number]
|
|
40
53
|
|
|
41
54
|
function readAntiPatterns(): string {
|
|
42
55
|
return readFileSync(ANTI_PATTERNS_PATH, 'utf8')
|
|
@@ -57,20 +70,20 @@ function extractDetectorTags(doc: string): string[] {
|
|
|
57
70
|
return found
|
|
58
71
|
}
|
|
59
72
|
|
|
60
|
-
describe('anti-patterns.md detector tags vs
|
|
73
|
+
describe('anti-patterns.md detector tags vs static detectors', () => {
|
|
61
74
|
const doc = readAntiPatterns()
|
|
62
75
|
const tags = extractDetectorTags(doc)
|
|
63
76
|
|
|
64
|
-
it('every [detector: CODE] tag references a known
|
|
65
|
-
const validCodes = new Set<string>(
|
|
77
|
+
it('every [detector: CODE] tag references a known detector (compiler or lint)', () => {
|
|
78
|
+
const validCodes = new Set<string>([...COMPILER_CODES, ...LINT_RULE_DETECTORS])
|
|
66
79
|
const unknown = tags.filter((t) => !validCodes.has(t) && t !== 'N/A')
|
|
67
80
|
expect(unknown).toEqual([])
|
|
68
81
|
})
|
|
69
82
|
|
|
70
83
|
it('every PyreonDiagnosticCode appears at least once as a [detector:] tag', () => {
|
|
71
84
|
const tagSet = new Set(tags)
|
|
72
|
-
const missing:
|
|
73
|
-
for (const code of
|
|
85
|
+
const missing: CompilerCode[] = []
|
|
86
|
+
for (const code of COMPILER_CODES) {
|
|
74
87
|
if (!tagSet.has(code)) missing.push(code)
|
|
75
88
|
}
|
|
76
89
|
// If this fails, add a bullet for the new detector code to
|
|
@@ -80,7 +93,7 @@ describe('anti-patterns.md detector tags vs PyreonDiagnosticCode', () => {
|
|
|
80
93
|
expect(missing).toEqual([])
|
|
81
94
|
})
|
|
82
95
|
|
|
83
|
-
it('reports at least as many tags as detector codes (multi-code bullets allowed)', () => {
|
|
84
|
-
expect(tags.length).toBeGreaterThanOrEqual(
|
|
96
|
+
it('reports at least as many tags as compiler detector codes (multi-code bullets allowed)', () => {
|
|
97
|
+
expect(tags.length).toBeGreaterThanOrEqual(COMPILER_CODES.length)
|
|
85
98
|
})
|
|
86
99
|
})
|