@pyreon/compiler 0.15.0 → 0.16.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.
@@ -483,4 +483,145 @@ describe('detectPyreonPatterns', () => {
483
483
  expect(diags.filter((d) => d.code === 'as-unknown-as-vnodechild')).toEqual([])
484
484
  })
485
485
  })
486
+
487
+ describe('island-never-with-registry-entry', () => {
488
+ it('flags a hydrateIslands key matching a hydrate: "never" island declaration', () => {
489
+ const code = `
490
+ import { island } from '@pyreon/server'
491
+ import { hydrateIslands } from '@pyreon/server/client'
492
+ export const StaticBadge = island(() => import('./StaticBadge'), {
493
+ name: 'StaticBadge',
494
+ hydrate: 'never',
495
+ })
496
+ hydrateIslands({
497
+ StaticBadge: () => import('./StaticBadge'),
498
+ })
499
+ `
500
+ const diags = detectPyreonPatterns(code)
501
+ const hits = diags.filter((d) => d.code === 'island-never-with-registry-entry')
502
+ expect(hits).toHaveLength(1)
503
+ expect(hits[0]!.message).toContain('StaticBadge')
504
+ expect(hits[0]!.message).toContain("'never'")
505
+ })
506
+
507
+ it('does NOT flag a hydrateIslands key for a non-never island', () => {
508
+ const code = `
509
+ import { island } from '@pyreon/server'
510
+ import { hydrateIslands } from '@pyreon/server/client'
511
+ export const Counter = island(() => import('./Counter'), {
512
+ name: 'Counter',
513
+ hydrate: 'load',
514
+ })
515
+ hydrateIslands({
516
+ Counter: () => import('./Counter'),
517
+ })
518
+ `
519
+ const diags = detectPyreonPatterns(code)
520
+ expect(diags.filter((d) => d.code === 'island-never-with-registry-entry')).toEqual([])
521
+ })
522
+
523
+ it('does NOT flag a never-island when no hydrateIslands call appears', () => {
524
+ const code = `
525
+ import { island } from '@pyreon/server'
526
+ export const StaticBadge = island(() => import('./StaticBadge'), {
527
+ name: 'StaticBadge',
528
+ hydrate: 'never',
529
+ })
530
+ `
531
+ const diags = detectPyreonPatterns(code)
532
+ expect(diags.filter((d) => d.code === 'island-never-with-registry-entry')).toEqual([])
533
+ })
534
+
535
+ it('does NOT flag when hydrateIslands omits never-strategy islands (canonical)', () => {
536
+ const code = `
537
+ import { island } from '@pyreon/server'
538
+ import { hydrateIslands } from '@pyreon/server/client'
539
+ export const Counter = island(() => import('./Counter'), {
540
+ name: 'Counter',
541
+ hydrate: 'load',
542
+ })
543
+ export const StaticBadge = island(() => import('./StaticBadge'), {
544
+ name: 'StaticBadge',
545
+ hydrate: 'never',
546
+ })
547
+ hydrateIslands({
548
+ Counter: () => import('./Counter'),
549
+ // StaticBadge intentionally omitted
550
+ })
551
+ `
552
+ const diags = detectPyreonPatterns(code)
553
+ expect(diags.filter((d) => d.code === 'island-never-with-registry-entry')).toEqual([])
554
+ })
555
+
556
+ it('flags multiple never-islands registered in the same hydrateIslands call', () => {
557
+ const code = `
558
+ import { island } from '@pyreon/server'
559
+ import { hydrateIslands } from '@pyreon/server/client'
560
+ export const A = island(() => import('./A'), { name: 'A', hydrate: 'never' })
561
+ export const B = island(() => import('./B'), { name: 'B', hydrate: 'never' })
562
+ hydrateIslands({
563
+ A: () => import('./A'),
564
+ B: () => import('./B'),
565
+ })
566
+ `
567
+ const diags = detectPyreonPatterns(code)
568
+ const hits = diags.filter((d) => d.code === 'island-never-with-registry-entry')
569
+ expect(hits).toHaveLength(2)
570
+ expect(hits.map((h) => h.message).join('|')).toContain('"A"')
571
+ expect(hits.map((h) => h.message).join('|')).toContain('"B"')
572
+ })
573
+
574
+ it('handles string-literal property keys in hydrateIslands', () => {
575
+ const code = `
576
+ import { island } from '@pyreon/server'
577
+ import { hydrateIslands } from '@pyreon/server/client'
578
+ export const X = island(() => import('./X'), { name: 'X', hydrate: 'never' })
579
+ hydrateIslands({
580
+ 'X': () => import('./X'),
581
+ })
582
+ `
583
+ const diags = detectPyreonPatterns(code)
584
+ expect(
585
+ diags.filter((d) => d.code === 'island-never-with-registry-entry'),
586
+ ).toHaveLength(1)
587
+ })
588
+
589
+ it('does NOT flag non-string `hydrate` values (variable indirection)', () => {
590
+ const code = `
591
+ import { island } from '@pyreon/server'
592
+ import { hydrateIslands } from '@pyreon/server/client'
593
+ const STRATEGY = 'never'
594
+ export const X = island(() => import('./X'), { name: 'X', hydrate: STRATEGY })
595
+ hydrateIslands({
596
+ X: () => import('./X'),
597
+ })
598
+ `
599
+ // The detector intentionally only recognizes string-literal hydrate
600
+ // values — variable indirection takes us past the static-detection
601
+ // surface into pyreon doctor --check-islands territory.
602
+ const diags = detectPyreonPatterns(code)
603
+ expect(diags.filter((d) => d.code === 'island-never-with-registry-entry')).toEqual([])
604
+ })
605
+
606
+ it('reports `fixable: false` (no auto-fix; manual edit required)', () => {
607
+ const code = `
608
+ import { island } from '@pyreon/server'
609
+ import { hydrateIslands } from '@pyreon/server/client'
610
+ export const X = island(() => import('./X'), { name: 'X', hydrate: 'never' })
611
+ hydrateIslands({
612
+ X: () => import('./X'),
613
+ })
614
+ `
615
+ const diags = detectPyreonPatterns(code)
616
+ const hit = diags.find((d) => d.code === 'island-never-with-registry-entry')
617
+ expect(hit).toBeDefined()
618
+ expect(hit!.fixable).toBe(false)
619
+ })
620
+
621
+ it('hasPyreonPatterns regex pre-filter recognizes the never-strategy form', () => {
622
+ expect(
623
+ hasPyreonPatterns(`island(() => import('./X'), { name: 'X', hydrate: 'never' })`),
624
+ ).toBe(true)
625
+ })
626
+ })
486
627
  })
@@ -0,0 +1,402 @@
1
+ /**
2
+ * Fixture-based tests for `auditSsg` (M3.4 of the SSG roadmap).
3
+ *
4
+ * Each finding type gets a parallel pair:
5
+ * - "broken" fixture → finding fires
6
+ * - "fixed" fixture → no finding fires
7
+ *
8
+ * Bisect-verified by removing the detector body and asserting the
9
+ * broken-shape test fails.
10
+ */
11
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
12
+ import { tmpdir } from 'node:os'
13
+ import { dirname, join } from 'node:path'
14
+ import { auditSsg, formatSsgAudit, type SsgFindingCode } from '../ssg-audit'
15
+
16
+ interface Fixture {
17
+ root: string
18
+ write: (relPath: string, body: string) => void
19
+ cleanup: () => void
20
+ }
21
+
22
+ function makeFixture(): Fixture {
23
+ const root = mkdtempSync(join(tmpdir(), 'pyreon-ssg-audit-fixture-'))
24
+ mkdirSync(join(root, 'packages'), { recursive: true })
25
+ return {
26
+ root,
27
+ write: (relPath, body) => {
28
+ const full = join(root, relPath)
29
+ mkdirSync(dirname(full), { recursive: true })
30
+ writeFileSync(full, body)
31
+ },
32
+ cleanup: () => rmSync(root, { recursive: true, force: true }),
33
+ }
34
+ }
35
+
36
+ function findingCodes(result: ReturnType<typeof auditSsg>): SsgFindingCode[] {
37
+ return result.findings.map((f) => f.code)
38
+ }
39
+
40
+ // ═══════════════════════════════════════════════════════════════════════════════
41
+ // Discovery
42
+ // ═══════════════════════════════════════════════════════════════════════════════
43
+
44
+ describe('auditSsg — discovery', () => {
45
+ it('returns empty results for a directory with no routes/ subdir', () => {
46
+ const empty = mkdtempSync(join(tmpdir(), 'pyreon-ssg-audit-empty-'))
47
+ try {
48
+ const result = auditSsg(empty)
49
+ expect(result.findings).toEqual([])
50
+ expect(result.summary.routesScanned).toBe(0)
51
+ } finally {
52
+ rmSync(empty, { recursive: true, force: true })
53
+ }
54
+ })
55
+
56
+ it('counts route files + dynamic routes + revalidate exports in summary', () => {
57
+ const fixture = makeFixture()
58
+ try {
59
+ // Plain page — no [param], no revalidate.
60
+ fixture.write('examples/myapp/src/routes/_layout.tsx', 'export const layout = () => null')
61
+ fixture.write('examples/myapp/src/routes/index.tsx', 'export default () => null')
62
+ fixture.write('examples/myapp/src/routes/about.tsx', 'export default () => null')
63
+ // Dynamic route with getStaticPaths + revalidate
64
+ fixture.write(
65
+ 'examples/myapp/src/routes/posts/[id].tsx',
66
+ `export const getStaticPaths = () => [{ params: { id: '1' } }]
67
+ export const revalidate = 60
68
+ export default () => null`,
69
+ )
70
+ const result = auditSsg(fixture.root)
71
+ expect(result.summary.routesScanned).toBe(4)
72
+ expect(result.summary.dynamicRoutes).toBe(1)
73
+ expect(result.summary.revalidateExports).toBe(1)
74
+ } finally {
75
+ fixture.cleanup()
76
+ }
77
+ })
78
+ })
79
+
80
+ // ═══════════════════════════════════════════════════════════════════════════════
81
+ // 1) 404-outside-layout-dir
82
+ // ═══════════════════════════════════════════════════════════════════════════════
83
+
84
+ describe('auditSsg — 404-outside-layout-dir', () => {
85
+ it('FIRES when _404.tsx has no co-located _layout.tsx', () => {
86
+ const fixture = makeFixture()
87
+ try {
88
+ // Broken shape: _404.tsx alone in the routes dir, no _layout.tsx.
89
+ fixture.write('examples/myapp/src/routes/_404.tsx', 'export default () => null')
90
+ fixture.write('examples/myapp/src/routes/index.tsx', 'export default () => null')
91
+ const result = auditSsg(fixture.root)
92
+ expect(findingCodes(result)).toContain('404-outside-layout-dir')
93
+ const finding = result.findings.find((f) => f.code === '404-outside-layout-dir')!
94
+ expect(finding.location.relPath).toContain('_404.tsx')
95
+ expect(finding.message).toContain('_layout.tsx')
96
+ } finally {
97
+ fixture.cleanup()
98
+ }
99
+ })
100
+
101
+ it('FIRES for _not-found.tsx (alternate filename)', () => {
102
+ const fixture = makeFixture()
103
+ try {
104
+ fixture.write('examples/myapp/src/routes/_not-found.tsx', 'export default () => null')
105
+ const result = auditSsg(fixture.root)
106
+ expect(findingCodes(result)).toContain('404-outside-layout-dir')
107
+ } finally {
108
+ fixture.cleanup()
109
+ }
110
+ })
111
+
112
+ it('does NOT fire when _404.tsx is co-located with _layout.tsx', () => {
113
+ const fixture = makeFixture()
114
+ try {
115
+ // Fixed shape: same directory contains both.
116
+ fixture.write('examples/myapp/src/routes/_layout.tsx', 'export const layout = () => null')
117
+ fixture.write('examples/myapp/src/routes/_404.tsx', 'export default () => null')
118
+ const result = auditSsg(fixture.root)
119
+ expect(findingCodes(result)).not.toContain('404-outside-layout-dir')
120
+ } finally {
121
+ fixture.cleanup()
122
+ }
123
+ })
124
+ })
125
+
126
+ // ═══════════════════════════════════════════════════════════════════════════════
127
+ // 2) dynamic-route-missing-get-static-paths
128
+ // ═══════════════════════════════════════════════════════════════════════════════
129
+
130
+ describe('auditSsg — dynamic-route-missing-get-static-paths', () => {
131
+ it('FIRES for [id].tsx without getStaticPaths', () => {
132
+ const fixture = makeFixture()
133
+ try {
134
+ fixture.write(
135
+ 'examples/myapp/src/routes/posts/[id].tsx',
136
+ 'export default () => null',
137
+ )
138
+ const result = auditSsg(fixture.root)
139
+ expect(findingCodes(result)).toContain('dynamic-route-missing-get-static-paths')
140
+ const finding = result.findings.find(
141
+ (f) => f.code === 'dynamic-route-missing-get-static-paths',
142
+ )!
143
+ expect(finding.location.relPath).toContain('[id].tsx')
144
+ expect(finding.message).toContain('getStaticPaths')
145
+ } finally {
146
+ fixture.cleanup()
147
+ }
148
+ })
149
+
150
+ it('FIRES for catch-all [...slug].tsx without getStaticPaths', () => {
151
+ const fixture = makeFixture()
152
+ try {
153
+ fixture.write(
154
+ 'examples/myapp/src/routes/blog/[...slug].tsx',
155
+ 'export default () => null',
156
+ )
157
+ const result = auditSsg(fixture.root)
158
+ expect(findingCodes(result)).toContain('dynamic-route-missing-get-static-paths')
159
+ } finally {
160
+ fixture.cleanup()
161
+ }
162
+ })
163
+
164
+ it('does NOT fire for [id].tsx WITH `export const getStaticPaths`', () => {
165
+ const fixture = makeFixture()
166
+ try {
167
+ fixture.write(
168
+ 'examples/myapp/src/routes/posts/[id].tsx',
169
+ `export const getStaticPaths = () => [{ params: { id: '1' } }]
170
+ export default () => null`,
171
+ )
172
+ const result = auditSsg(fixture.root)
173
+ expect(findingCodes(result)).not.toContain('dynamic-route-missing-get-static-paths')
174
+ } finally {
175
+ fixture.cleanup()
176
+ }
177
+ })
178
+
179
+ it('does NOT fire for [id].tsx WITH `export async function getStaticPaths`', () => {
180
+ const fixture = makeFixture()
181
+ try {
182
+ fixture.write(
183
+ 'examples/myapp/src/routes/posts/[id].tsx',
184
+ `export async function getStaticPaths() { return [{ params: { id: '1' } }] }
185
+ export default () => null`,
186
+ )
187
+ const result = auditSsg(fixture.root)
188
+ expect(findingCodes(result)).not.toContain('dynamic-route-missing-get-static-paths')
189
+ } finally {
190
+ fixture.cleanup()
191
+ }
192
+ })
193
+
194
+ it('does NOT fire for static routes (no [param] in filename)', () => {
195
+ const fixture = makeFixture()
196
+ try {
197
+ fixture.write('examples/myapp/src/routes/about.tsx', 'export default () => null')
198
+ fixture.write('examples/myapp/src/routes/index.tsx', 'export default () => null')
199
+ const result = auditSsg(fixture.root)
200
+ expect(findingCodes(result)).not.toContain('dynamic-route-missing-get-static-paths')
201
+ } finally {
202
+ fixture.cleanup()
203
+ }
204
+ })
205
+
206
+ it('does NOT fire for _layout / _error / _loading / _404 even with brackets in name', () => {
207
+ // Defensive: special files with bracketed names (unlikely but
208
+ // possible — `_layout.[locale].tsx`) shouldn't be flagged.
209
+ const fixture = makeFixture()
210
+ try {
211
+ fixture.write(
212
+ 'examples/myapp/src/routes/_layout.tsx',
213
+ 'export const layout = () => null',
214
+ )
215
+ fixture.write('examples/myapp/src/routes/_404.tsx', 'export default () => null')
216
+ const result = auditSsg(fixture.root)
217
+ expect(findingCodes(result)).not.toContain('dynamic-route-missing-get-static-paths')
218
+ } finally {
219
+ fixture.cleanup()
220
+ }
221
+ })
222
+
223
+ // M3.B follow-up — false-positive class surfaced by cpa-pw-blog's
224
+ // `api/echo/[...path].ts` (real-world API route with bracket
225
+ // filename). API routes are runtime-only by definition.
226
+ it('does NOT fire for API routes under routes/api/ (path-based skip)', () => {
227
+ const fixture = makeFixture()
228
+ try {
229
+ fixture.write(
230
+ 'examples/myapp/src/routes/api/echo/[...path].ts',
231
+ `export function GET({ params }) {
232
+ return new Response(\`segments: \${params.path}\`)
233
+ }`,
234
+ )
235
+ const result = auditSsg(fixture.root)
236
+ expect(findingCodes(result)).not.toContain('dynamic-route-missing-get-static-paths')
237
+ } finally {
238
+ fixture.cleanup()
239
+ }
240
+ })
241
+
242
+ it('does NOT fire for files without `export default` outside api/ (export-shape skip)', () => {
243
+ // Method-handler-only file outside api/ — covers users who put API
244
+ // routes somewhere non-conventional. Page routes structurally
245
+ // require a default export, so absence is a reliable signal.
246
+ const fixture = makeFixture()
247
+ try {
248
+ fixture.write(
249
+ 'examples/myapp/src/routes/webhook/[id].ts',
250
+ `export function POST({ request }) {
251
+ return new Response('ok')
252
+ }`,
253
+ )
254
+ const result = auditSsg(fixture.root)
255
+ expect(findingCodes(result)).not.toContain('dynamic-route-missing-get-static-paths')
256
+ } finally {
257
+ fixture.cleanup()
258
+ }
259
+ })
260
+
261
+ it('STILL fires on page routes (with default export) missing getStaticPaths', () => {
262
+ // Sanity — the export-shape skip doesn't accidentally silence the
263
+ // rule on legitimate page routes.
264
+ const fixture = makeFixture()
265
+ try {
266
+ fixture.write(
267
+ 'examples/myapp/src/routes/posts/[id].tsx',
268
+ `export const someHelper = 1
269
+ export default function Post() { return null }`,
270
+ )
271
+ const result = auditSsg(fixture.root)
272
+ expect(findingCodes(result)).toContain('dynamic-route-missing-get-static-paths')
273
+ } finally {
274
+ fixture.cleanup()
275
+ }
276
+ })
277
+ })
278
+
279
+ // ═══════════════════════════════════════════════════════════════════════════════
280
+ // 3) non-literal-revalidate-export
281
+ // ═══════════════════════════════════════════════════════════════════════════════
282
+
283
+ describe('auditSsg — non-literal-revalidate-export', () => {
284
+ it('FIRES for `export const revalidate = TTL` (identifier reference)', () => {
285
+ const fixture = makeFixture()
286
+ try {
287
+ fixture.write(
288
+ 'examples/myapp/src/routes/posts/index.tsx',
289
+ `const TTL = 60
290
+ export const revalidate = TTL
291
+ export default () => null`,
292
+ )
293
+ const result = auditSsg(fixture.root)
294
+ expect(findingCodes(result)).toContain('non-literal-revalidate-export')
295
+ const finding = result.findings.find((f) => f.code === 'non-literal-revalidate-export')!
296
+ expect(finding.message).toContain('NUMERIC LITERAL')
297
+ } finally {
298
+ fixture.cleanup()
299
+ }
300
+ })
301
+
302
+ it('FIRES for `export const revalidate = 30 * 60` (arithmetic)', () => {
303
+ const fixture = makeFixture()
304
+ try {
305
+ fixture.write(
306
+ 'examples/myapp/src/routes/posts/index.tsx',
307
+ `export const revalidate = 30 * 60
308
+ export default () => null`,
309
+ )
310
+ const result = auditSsg(fixture.root)
311
+ expect(findingCodes(result)).toContain('non-literal-revalidate-export')
312
+ } finally {
313
+ fixture.cleanup()
314
+ }
315
+ })
316
+
317
+ it('does NOT fire for `export const revalidate = 60` (numeric literal)', () => {
318
+ const fixture = makeFixture()
319
+ try {
320
+ fixture.write(
321
+ 'examples/myapp/src/routes/posts/index.tsx',
322
+ `export const revalidate = 60
323
+ export default () => null`,
324
+ )
325
+ const result = auditSsg(fixture.root)
326
+ expect(findingCodes(result)).not.toContain('non-literal-revalidate-export')
327
+ } finally {
328
+ fixture.cleanup()
329
+ }
330
+ })
331
+
332
+ it('does NOT fire for `export const revalidate = false` (false keyword)', () => {
333
+ const fixture = makeFixture()
334
+ try {
335
+ fixture.write(
336
+ 'examples/myapp/src/routes/posts/index.tsx',
337
+ `export const revalidate = false
338
+ export default () => null`,
339
+ )
340
+ const result = auditSsg(fixture.root)
341
+ expect(findingCodes(result)).not.toContain('non-literal-revalidate-export')
342
+ } finally {
343
+ fixture.cleanup()
344
+ }
345
+ })
346
+
347
+ it('does NOT fire when there is no revalidate export at all', () => {
348
+ const fixture = makeFixture()
349
+ try {
350
+ fixture.write('examples/myapp/src/routes/about.tsx', 'export default () => null')
351
+ const result = auditSsg(fixture.root)
352
+ expect(findingCodes(result)).not.toContain('non-literal-revalidate-export')
353
+ } finally {
354
+ fixture.cleanup()
355
+ }
356
+ })
357
+ })
358
+
359
+ // ═══════════════════════════════════════════════════════════════════════════════
360
+ // Formatter
361
+ // ═══════════════════════════════════════════════════════════════════════════════
362
+
363
+ describe('formatSsgAudit', () => {
364
+ it('renders a clean header when there are no findings', () => {
365
+ const fixture = makeFixture()
366
+ try {
367
+ fixture.write('examples/myapp/src/routes/index.tsx', 'export default () => null')
368
+ const result = auditSsg(fixture.root)
369
+ const output = formatSsgAudit(result)
370
+ expect(output).toContain('SSG audit')
371
+ expect(output).toContain('No SSG / ISR issues found')
372
+ } finally {
373
+ fixture.cleanup()
374
+ }
375
+ })
376
+
377
+ it('renders each finding with relPath:line:col + actionable message', () => {
378
+ const fixture = makeFixture()
379
+ try {
380
+ fixture.write('examples/myapp/src/routes/_404.tsx', 'export default () => null')
381
+ const result = auditSsg(fixture.root)
382
+ const output = formatSsgAudit(result)
383
+ expect(output).toContain('[404-outside-layout-dir]')
384
+ expect(output).toContain('_404.tsx')
385
+ expect(output).toContain('_layout.tsx')
386
+ } finally {
387
+ fixture.cleanup()
388
+ }
389
+ })
390
+
391
+ it('mentions the --json flag for machine-readable output', () => {
392
+ const fixture = makeFixture()
393
+ try {
394
+ fixture.write('examples/myapp/src/routes/posts/[id].tsx', 'export default () => null')
395
+ const result = auditSsg(fixture.root)
396
+ const output = formatSsgAudit(result)
397
+ expect(output).toContain('--json')
398
+ } finally {
399
+ fixture.cleanup()
400
+ }
401
+ })
402
+ })