@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.
@@ -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 the static `detectPyreonPatterns` detector codes
6
- // and the `[detector: <code>]` annotations on `.claude/rules/anti-patterns.md`.
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 PyreonDiagnosticCode (or the literal `N/A` for
14
- // bullets explicitly declared doc-only), and every
15
- // PyreonDiagnosticCode must appear at least once in the doc so the
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 KNOWN_CODES = [
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 PyreonDiagnosticCode', () => {
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 PyreonDiagnosticCode', () => {
65
- const validCodes = new Set<string>(KNOWN_CODES)
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: KnownCode[] = []
73
- for (const code of KNOWN_CODES) {
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(KNOWN_CODES.length)
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
  })