@pyreon/compiler 0.24.5 → 0.24.6

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.
Files changed (64) hide show
  1. package/package.json +11 -13
  2. package/src/defer-inline.ts +0 -686
  3. package/src/event-names.ts +0 -65
  4. package/src/index.ts +0 -61
  5. package/src/island-audit.ts +0 -675
  6. package/src/jsx.ts +0 -2792
  7. package/src/load-native.ts +0 -156
  8. package/src/lpih.ts +0 -270
  9. package/src/manifest.ts +0 -280
  10. package/src/project-scanner.ts +0 -214
  11. package/src/pyreon-intercept.ts +0 -1029
  12. package/src/react-intercept.ts +0 -1217
  13. package/src/reactivity-lens.ts +0 -190
  14. package/src/ssg-audit.ts +0 -513
  15. package/src/test-audit.ts +0 -435
  16. package/src/tests/backend-parity-r7-r9.test.ts +0 -91
  17. package/src/tests/backend-prop-derived-callback-divergence.test.ts +0 -74
  18. package/src/tests/collapse-bail-census.test.ts +0 -330
  19. package/src/tests/collapse-key-source-hygiene.test.ts +0 -88
  20. package/src/tests/component-child-no-wrap.test.ts +0 -204
  21. package/src/tests/defer-inline.test.ts +0 -387
  22. package/src/tests/depth-stress.test.ts +0 -16
  23. package/src/tests/detector-tag-consistency.test.ts +0 -101
  24. package/src/tests/dynamic-collapse-detector.test.ts +0 -164
  25. package/src/tests/dynamic-collapse-emit.test.ts +0 -192
  26. package/src/tests/dynamic-collapse-scan.test.ts +0 -111
  27. package/src/tests/element-valued-const-child.test.ts +0 -61
  28. package/src/tests/falsy-child-characterization.test.ts +0 -48
  29. package/src/tests/island-audit.test.ts +0 -524
  30. package/src/tests/jsx.test.ts +0 -2908
  31. package/src/tests/load-native.test.ts +0 -53
  32. package/src/tests/lpih.test.ts +0 -404
  33. package/src/tests/malformed-input-resilience.test.ts +0 -50
  34. package/src/tests/manifest-snapshot.test.ts +0 -55
  35. package/src/tests/native-equivalence.test.ts +0 -924
  36. package/src/tests/partial-collapse-detector.test.ts +0 -121
  37. package/src/tests/partial-collapse-emit.test.ts +0 -104
  38. package/src/tests/partial-collapse-robustness.test.ts +0 -53
  39. package/src/tests/project-scanner.test.ts +0 -269
  40. package/src/tests/prop-derived-shadow.test.ts +0 -96
  41. package/src/tests/pure-call-reactive-args.test.ts +0 -50
  42. package/src/tests/pyreon-intercept.test.ts +0 -816
  43. package/src/tests/r13-callback-stmt-equivalence.test.ts +0 -58
  44. package/src/tests/r14-ssr-mode-parity.test.ts +0 -51
  45. package/src/tests/r15-elemconst-propderived.test.ts +0 -47
  46. package/src/tests/r19-defer-inline-robust.test.ts +0 -54
  47. package/src/tests/r20-backend-equivalence-sweep.test.ts +0 -50
  48. package/src/tests/react-intercept.test.ts +0 -1104
  49. package/src/tests/reactivity-lens.test.ts +0 -170
  50. package/src/tests/rocketstyle-collapse.test.ts +0 -208
  51. package/src/tests/runtime/control-flow.test.ts +0 -159
  52. package/src/tests/runtime/dom-properties.test.ts +0 -138
  53. package/src/tests/runtime/events.test.ts +0 -301
  54. package/src/tests/runtime/harness.ts +0 -94
  55. package/src/tests/runtime/pr-352-shapes.test.ts +0 -121
  56. package/src/tests/runtime/reactive-props.test.ts +0 -81
  57. package/src/tests/runtime/signals.test.ts +0 -129
  58. package/src/tests/runtime/whitespace.test.ts +0 -106
  59. package/src/tests/signal-autocall-shadow.test.ts +0 -86
  60. package/src/tests/sourcemap-fidelity.test.ts +0 -77
  61. package/src/tests/ssg-audit.test.ts +0 -402
  62. package/src/tests/static-text-baking.test.ts +0 -64
  63. package/src/tests/test-audit.test.ts +0 -549
  64. package/src/tests/transform-state-isolation.test.ts +0 -49
@@ -1,190 +0,0 @@
1
- /**
2
- * Reactivity Lens — surface the compiler's already-computed reactivity
3
- * analysis back to the author at the source.
4
- *
5
- * Pyreon's #1 silent footgun class: whether code is reactive is invisible at
6
- * the moment you write it. `const {x}=props` compiles fine, types fine,
7
- * renders once, and is dead. `<div>{x}</div>` where `x` isn't a signal bakes
8
- * once. The `@pyreon/compiler` ALREADY decides this per-expression (it has to,
9
- * for codegen) and then throws the analysis away. This module pipes it back.
10
- *
11
- * `analyzeReactivity()` is the single entry point. It returns a sorted list of
12
- * {@link ReactivityFinding}s built from TWO faithful sources, neither of which
13
- * is a fresh approximation:
14
- *
15
- * 1. **Compiler structural facts** — `TransformResult.reactivityLens`. Each
16
- * span is a *record* of a codegen decision (`_bind`/`_bindText`/`_rp`/
17
- * hoist/static-text). The positive "this is live" claim is the codegen
18
- * branch itself, so it is correct by construction (drift-gated).
19
- * 2. **Footgun negatives** — the existing `detectPyreonPatterns` AST
20
- * detectors (`props-destructured`, `signal-write-as-call`, …). Already
21
- * shipped, already AST-based; the lens just unifies them under one
22
- * editor-facing taxonomy.
23
- *
24
- * Absence of a finding is "not asserted", NEVER an implicit static claim —
25
- * see the asymmetric-precision commitment in `.claude/plans/reactivity-lens.md`.
26
- *
27
- * JS-backend only (Phase 1). The native Rust binary emits byte-identical
28
- * codegen (527 cross-backend equivalence tests), so the JS path is a sound
29
- * oracle for the analysis; Rust-path parity is Phase 3.
30
- *
31
- * @module
32
- */
33
-
34
- import { transformJSX_JS } from './jsx'
35
- import type { ReactivityKind, ReactivitySpan } from './jsx'
36
- import { detectPyreonPatterns } from './pyreon-intercept'
37
- import type { PyreonDiagnosticCode } from './pyreon-intercept'
38
-
39
- export type { ReactivityKind, ReactivitySpan } from './jsx'
40
-
41
- /** A footgun finding adds `'footgun'` to the structural codegen kinds. */
42
- export type ReactivityFindingKind = ReactivityKind | 'footgun'
43
-
44
- export interface ReactivityFinding {
45
- /** Structural codegen decision, or `'footgun'` for a detected anti-pattern. */
46
- kind: ReactivityFindingKind
47
- /** 1-based line. */
48
- line: number
49
- /** 0-based column. */
50
- column: number
51
- /** 1-based end line. */
52
- endLine: number
53
- /** 0-based end column. */
54
- endColumn: number
55
- /** Editor-facing one-liner. For footguns, the detector's message. */
56
- detail: string
57
- /**
58
- * For `'footgun'` findings: the static-detector code (e.g.
59
- * `props-destructured`) so the editor surface can deep-link the
60
- * anti-pattern catalogue. Absent for structural findings.
61
- */
62
- code?: PyreonDiagnosticCode
63
- /** For `'footgun'` findings: whether a mechanical auto-fix is safe. */
64
- fixable?: boolean
65
- }
66
-
67
- export interface AnalyzeReactivityResult {
68
- /** Sorted (line, column) findings — structural facts + footguns merged. */
69
- findings: ReactivityFinding[]
70
- /**
71
- * Raw compiler spans (pre-merge), kept so the drift gate can assert the
72
- * lens kind faithfully records the codegen decision without re-deriving.
73
- */
74
- spans: ReactivitySpan[]
75
- }
76
-
77
- function spanToFinding(s: ReactivitySpan): ReactivityFinding {
78
- return {
79
- kind: s.kind,
80
- line: s.line,
81
- column: s.column,
82
- endLine: s.endLine,
83
- endColumn: s.endColumn,
84
- detail: s.detail,
85
- }
86
- }
87
-
88
- /**
89
- * Analyze a source file's reactivity. Pure, side-effect-free, deterministic.
90
- *
91
- * @param code Source text (`.tsx` / `.jsx` / `.ts`).
92
- * @param filename Used only for parse-mode (`tsx` vs `jsx`) detection.
93
- * @param options `knownSignals` is forwarded to the compiler so
94
- * cross-module imported signals are auto-call-aware.
95
- *
96
- * @example
97
- * const { findings } = analyzeReactivity(
98
- * `function C(){ const {x}=props; return <div>{count()}</div> }`,
99
- * )
100
- * // → footgun(props-destructured) on `{x}`, reactive on `count()`
101
- */
102
- export function analyzeReactivity(
103
- code: string,
104
- filename = 'input.tsx',
105
- options: { knownSignals?: string[] } = {},
106
- ): AnalyzeReactivityResult {
107
- let spans: ReactivitySpan[] = []
108
- try {
109
- const r = transformJSX_JS(code, filename, {
110
- reactivityLens: true,
111
- ...(options.knownSignals ? { knownSignals: options.knownSignals } : {}),
112
- })
113
- spans = r.reactivityLens ?? []
114
- } catch {
115
- // Parse failure → no structural facts. Footguns may still be derivable
116
- // (detectPyreonPatterns uses the TS compiler API independently).
117
- spans = []
118
- }
119
-
120
- const findings: ReactivityFinding[] = spans.map(spanToFinding)
121
-
122
- let footguns: ReturnType<typeof detectPyreonPatterns> = []
123
- try {
124
- footguns = detectPyreonPatterns(code, filename)
125
- } catch {
126
- footguns = []
127
- }
128
- for (const d of footguns) {
129
- // detectPyreonPatterns gives 1-based line / 0-based column + `current`
130
- // (the offending source text). Approximate the end as same-line +
131
- // current length; multi-line `current` is rare and the editor only
132
- // needs a reasonable highlight range.
133
- const firstLineLen = d.current.split('\n')[0]?.length ?? d.current.length
134
- findings.push({
135
- kind: 'footgun',
136
- line: d.line,
137
- column: d.column,
138
- endLine: d.line,
139
- endColumn: d.column + firstLineLen,
140
- detail: d.message,
141
- code: d.code,
142
- fixable: d.fixable,
143
- })
144
- }
145
-
146
- findings.sort((a, b) => a.line - b.line || a.column - b.column)
147
- return { findings, spans }
148
- }
149
-
150
- const KIND_BADGE: Record<ReactivityFindingKind, string> = {
151
- reactive: '◆ live',
152
- 'reactive-prop': '◆ live prop',
153
- 'reactive-attr': '◆ live attr',
154
- 'static-text': '○ baked once',
155
- 'hoisted-static': '○ hoisted static',
156
- footgun: '⚠ footgun',
157
- }
158
-
159
- /**
160
- * Render an annotated source view for CLI / debugging — every analyzed line
161
- * followed by its reactivity findings. Not the production surface (that's the
162
- * LSP inlay hints); this is the spike's "can you see reactivity flow" probe
163
- * and a stable diff target for tests.
164
- */
165
- export function formatReactivityLens(
166
- code: string,
167
- result: AnalyzeReactivityResult,
168
- ): string {
169
- const lines = code.split('\n')
170
- const byLine = new Map<number, ReactivityFinding[]>()
171
- for (const f of result.findings) {
172
- const arr = byLine.get(f.line) ?? []
173
- arr.push(f)
174
- byLine.set(f.line, arr)
175
- }
176
- const out: string[] = []
177
- for (let i = 0; i < lines.length; i++) {
178
- const lineNo = i + 1
179
- out.push(`${String(lineNo).padStart(4)} | ${lines[i]}`)
180
- const fs = byLine.get(lineNo)
181
- if (fs) {
182
- for (const f of fs) {
183
- const pad = ' '.repeat(7 + f.column)
184
- const tag = f.code ? ` [${f.code}]` : ''
185
- out.push(`${pad}^ ${KIND_BADGE[f.kind]}${tag} — ${f.detail}`)
186
- }
187
- }
188
- }
189
- return out.join('\n')
190
- }
package/src/ssg-audit.ts DELETED
@@ -1,513 +0,0 @@
1
- /**
2
- * Project-wide SSG audit — scans route files for SSG / ISR foot-guns
3
- * surfaced by the SSG roadmap PRs (L5, A, I). Three detector codes ship
4
- * today:
5
- *
6
- * - **`404-outside-layout-dir`** (PR L5 carve-out): a `_404.tsx` (or
7
- * `_not-found.tsx`) file NOT co-located with a `_layout.tsx`. PR L5's
8
- * `findNotFoundFallback` filters to layout records with `children`;
9
- * a standalone `_404.tsx` outside a layout directory renders via the
10
- * SSG entry's pre-L5 standalone path (no layout chrome). The audit
11
- * catches this at the filesystem level so users move their
12
- * `_404.tsx` into the canonical `_layout` directory.
13
- *
14
- * - **`dynamic-route-missing-get-static-paths`** (PR A consequence): a
15
- * dynamic route file (`[id].tsx`, `[...slug].tsx`) that lacks a
16
- * `getStaticPaths` export. The SSG plugin silently SKIPS the route
17
- * during auto-detect — the user thinks `/posts/1` etc. are
18
- * prerendered but the dist has no `dist/posts/<id>/index.html`. The
19
- * audit catches this at scan time so users add the enumerator OR
20
- * declare the route as runtime-only.
21
- *
22
- * - **`non-literal-revalidate-export`** (PR I limitation): a route
23
- * file exports `export const revalidate = TTL` (variable reference)
24
- * or `export const revalidate = ...` (expression). The literal-
25
- * capture path in `extractLiteralExport` skips non-literals — the
26
- * manifest's revalidate entry is omitted, platform-driven ISR is
27
- * silently unconfigured for that route. The audit catches this so
28
- * users inline the literal (`export const revalidate = 60`).
29
- *
30
- * Real-app coverage:
31
- * - Per-code synthetic-fixture tests in `tests/ssg-audit.test.ts`
32
- * (one fixture per finding type, bisect-verified by reverting the
33
- * detector's match condition)
34
- * - Doctor wiring at `packages/tools/cli/src/doctor.ts:checkSsg`,
35
- * CLI flag `pyreon doctor --check-ssg [--json]`
36
- *
37
- * Same syntactic-only style as `island-audit.ts` — no type-check pass,
38
- * no module resolution. False negatives acceptable; false positives
39
- * must be rare. Every finding ships with file path + line/column +
40
- * actionable fix suggestion.
41
- */
42
- import { readdirSync, readFileSync, statSync } from 'node:fs'
43
- import { dirname, join, relative, resolve } from 'node:path'
44
- import ts from 'typescript'
45
-
46
- export type SsgFindingCode =
47
- | '404-outside-layout-dir'
48
- | 'dynamic-route-missing-get-static-paths'
49
- | 'non-literal-revalidate-export'
50
-
51
- export interface SsgLocation {
52
- /** Absolute path */
53
- path: string
54
- /** Path relative to the repo root for readable reporting */
55
- relPath: string
56
- /** 1-based line number */
57
- line: number
58
- /** 1-based column number */
59
- column: number
60
- }
61
-
62
- export interface SsgFinding {
63
- code: SsgFindingCode
64
- /** One-paragraph human-readable explanation, including the fix path. */
65
- message: string
66
- /** Where the finding surfaces. */
67
- location: SsgLocation
68
- /**
69
- * Companion locations for cross-file findings. Not currently emitted
70
- * by any detector but kept in the contract so future codes have the
71
- * shape available without an API change.
72
- */
73
- related?: SsgLocation[] | undefined
74
- }
75
-
76
- export interface SsgAuditResult {
77
- root: string | null
78
- findings: SsgFinding[]
79
- summary: {
80
- filesScanned: number
81
- routesScanned: number
82
- dynamicRoutes: number
83
- revalidateExports: number
84
- findingsByCode: Record<SsgFindingCode, number>
85
- }
86
- }
87
-
88
- // ═══════════════════════════════════════════════════════════════════════════════
89
- // Discovery
90
- // ═══════════════════════════════════════════════════════════════════════════════
91
-
92
- function findMonorepoRoot(startDir: string): string | null {
93
- let dir = resolve(startDir)
94
- for (let i = 0; i < 30; i++) {
95
- try {
96
- if (statSync(join(dir, 'packages')).isDirectory()) return dir
97
- } catch {
98
- // fall through
99
- }
100
- const parent = dirname(dir)
101
- if (parent === dir) return null
102
- dir = parent
103
- }
104
- return null
105
- }
106
-
107
- /**
108
- * Walk a directory looking for files under any `routes/` subdirectory.
109
- * fs-router treats files under `src/routes/` as routes; we mirror the
110
- * convention. Skips node_modules / lib / dist / test directories.
111
- */
112
- function findRouteFiles(rootDir: string, out: string[], depth = 0): void {
113
- if (depth > 12) return
114
- let entries: string[]
115
- try {
116
- entries = readdirSync(rootDir)
117
- } catch {
118
- return
119
- }
120
- for (const name of entries) {
121
- if (name.startsWith('.')) continue
122
- if (name === 'node_modules' || name === 'lib' || name === 'dist') continue
123
- if (name === '__tests__' || name === 'tests') continue
124
- const full = join(rootDir, name)
125
- let isDir = false
126
- try {
127
- isDir = statSync(full).isDirectory()
128
- } catch {
129
- continue
130
- }
131
- if (isDir) {
132
- // If this directory is named `routes`, descend and collect every
133
- // route file under it. Otherwise recurse into the directory
134
- // looking for nested `routes/` directories (handles
135
- // `examples/<app>/src/routes/`).
136
- if (name === 'routes') {
137
- walkRoutesDir(full, out)
138
- } else {
139
- findRouteFiles(full, out, depth + 1)
140
- }
141
- continue
142
- }
143
- }
144
- }
145
-
146
- function walkRoutesDir(dir: string, out: string[]): void {
147
- let entries: string[]
148
- try {
149
- entries = readdirSync(dir)
150
- } catch {
151
- return
152
- }
153
- for (const name of entries) {
154
- if (name.startsWith('.')) continue
155
- if (name === 'node_modules') continue
156
- const full = join(dir, name)
157
- let stat
158
- try {
159
- stat = statSync(full)
160
- } catch {
161
- continue
162
- }
163
- if (stat.isDirectory()) {
164
- walkRoutesDir(full, out)
165
- continue
166
- }
167
- if (/\.(tsx?|jsx?)$/.test(name) && !/\.(test|spec)\.(tsx?|jsx?)$/.test(name)) {
168
- out.push(full)
169
- }
170
- }
171
- }
172
-
173
- // ═══════════════════════════════════════════════════════════════════════════════
174
- // AST parse helpers (shared shape with island-audit.ts)
175
- // ═══════════════════════════════════════════════════════════════════════════════
176
-
177
- function parseSourceFile(filePath: string): ts.SourceFile | null {
178
- let source: string
179
- try {
180
- source = readFileSync(filePath, 'utf8')
181
- } catch {
182
- return null
183
- }
184
- return ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true)
185
- }
186
-
187
- function locOf(source: ts.SourceFile, node: ts.Node): { line: number; column: number } {
188
- const pos = source.getLineAndCharacterOfPosition(node.getStart(source))
189
- return { line: pos.line + 1, column: pos.character + 1 }
190
- }
191
-
192
- function makeLocation(
193
- absPath: string,
194
- source: ts.SourceFile,
195
- node: ts.Node,
196
- rootForRel: string,
197
- ): SsgLocation {
198
- const { line, column } = locOf(source, node)
199
- return {
200
- path: absPath,
201
- relPath: relative(rootForRel, absPath),
202
- line,
203
- column,
204
- }
205
- }
206
-
207
- // ═══════════════════════════════════════════════════════════════════════════════
208
- // Detectors
209
- // ═══════════════════════════════════════════════════════════════════════════════
210
-
211
- /**
212
- * 1) `_404.tsx` / `_not-found.tsx` outside a `_layout.tsx` directory.
213
- *
214
- * fs-router scans `_404.tsx` / `_not-found.tsx` and attaches the default
215
- * export as `notFoundComponent` on its parent layout's RouteRecord. PR L5's
216
- * `findNotFoundFallback` filters to records with `Array.isArray(r.children)
217
- * && r.children.length > 0` — i.e. layouts only. A standalone `_404.tsx`
218
- * outside a layout directory:
219
- * - Becomes attached to a page record (no children)
220
- * - PR L5's walker skips it
221
- * - SSG entry falls back to the pre-L5 standalone render (no chrome)
222
- *
223
- * The audit catches this at filesystem-walk time, fast and structural.
224
- */
225
- function detect404OutsideLayoutDir(
226
- routeFiles: readonly string[],
227
- rootForRel: string,
228
- ): SsgFinding[] {
229
- const findings: SsgFinding[] = []
230
- // Build a Set of directories that contain a `_layout.{tsx,ts,jsx,js}` file.
231
- const layoutDirs = new Set<string>()
232
- for (const file of routeFiles) {
233
- const base = file.split('/').pop() ?? ''
234
- if (/^_layout\.(tsx?|jsx?)$/.test(base)) {
235
- layoutDirs.add(dirname(file))
236
- }
237
- }
238
- for (const file of routeFiles) {
239
- const base = file.split('/').pop() ?? ''
240
- if (!/^_(404|not-found)\.(tsx?|jsx?)$/.test(base)) continue
241
- const dir = dirname(file)
242
- if (layoutDirs.has(dir)) continue
243
- // Synthesize a location at line 1 col 1 — the FILE itself is the
244
- // finding, not a specific line inside it.
245
- findings.push({
246
- code: '404-outside-layout-dir',
247
- message:
248
- `${base} is not co-located with a _layout.tsx — without a parent layout, PR L5's ` +
249
- `findNotFoundFallback won't pick it up at SSG time and the 404 will render WITHOUT ` +
250
- `layout chrome (nav, footer, providers). Move ${base} into a directory that contains ` +
251
- `_layout.tsx (the canonical pattern: src/routes/_layout.tsx + src/routes/_404.tsx).`,
252
- location: {
253
- path: file,
254
- relPath: relative(rootForRel, file),
255
- line: 1,
256
- column: 1,
257
- },
258
- })
259
- }
260
- return findings
261
- }
262
-
263
- /**
264
- * 2) Dynamic route file missing `getStaticPaths` export.
265
- *
266
- * `[id].tsx`, `[...slug].tsx` — under SSG mode without a `getStaticPaths`,
267
- * the auto-detect step silently skips the route. User expects
268
- * `dist/posts/1/index.html` but never gets it.
269
- *
270
- * We syntactically scan for `export const getStaticPaths` or
271
- * `export function getStaticPaths`. Re-exports / async-function form
272
- * supported. Same literal-extraction shape used in fs-router's scanner.
273
- */
274
- function detectDynamicRouteMissingGetStaticPaths(
275
- routeFiles: readonly string[],
276
- rootForRel: string,
277
- ): SsgFinding[] {
278
- const findings: SsgFinding[] = []
279
- for (const file of routeFiles) {
280
- const base = file.split('/').pop() ?? ''
281
- // `[^\]]+` instead of `.+` — bounded, no backtrack potential.
282
- if (!/\[[^\]]{1,200}\]/.test(base)) continue
283
- // Skip layouts / errors / 404s — only PAGE files take getStaticPaths.
284
- if (/^_(layout|error|loading|404|not-found)\./.test(base)) continue
285
- // Skip API routes under `routes/api/` (path-based convention).
286
- // fs-router treats `api/` as the runtime-handler namespace; pages
287
- // are everything else. Caught originally in M3.B against cpa-pw-blog's
288
- // `api/echo/[...path].ts`.
289
- if (/[/\\]routes[/\\]api[/\\]/.test(file)) continue
290
- const source = parseSourceFile(file)
291
- if (!source) continue
292
- let hasGetStaticPaths = false
293
- let hasDefaultExport = false
294
- function visit(node: ts.Node): void {
295
- if (hasGetStaticPaths && hasDefaultExport) return
296
- if (ts.isVariableStatement(node)) {
297
- const hasExport = node.modifiers?.some(
298
- (m) => m.kind === ts.SyntaxKind.ExportKeyword,
299
- )
300
- if (hasExport) {
301
- for (const decl of node.declarationList.declarations) {
302
- if (ts.isIdentifier(decl.name) && decl.name.text === 'getStaticPaths') {
303
- hasGetStaticPaths = true
304
- }
305
- }
306
- }
307
- }
308
- if (ts.isFunctionDeclaration(node)) {
309
- const hasExport = node.modifiers?.some(
310
- (m) => m.kind === ts.SyntaxKind.ExportKeyword,
311
- )
312
- const isDefault = node.modifiers?.some(
313
- (m) => m.kind === ts.SyntaxKind.DefaultKeyword,
314
- )
315
- if (hasExport && node.name?.text === 'getStaticPaths') {
316
- hasGetStaticPaths = true
317
- }
318
- if (hasExport && isDefault) {
319
- hasDefaultExport = true
320
- }
321
- }
322
- if (ts.isExportAssignment(node) && !node.isExportEquals) {
323
- // `export default <expr>`
324
- hasDefaultExport = true
325
- }
326
- ts.forEachChild(node, visit)
327
- }
328
- visit(source)
329
- // Files without `export default` are API routes by structure. Skip.
330
- // Page routes require a default-exported component (fs-router renders
331
- // `route.component`); files exporting only method handlers
332
- // (`GET` / `POST` / etc.) without a default are API routes wherever
333
- // they sit in the tree.
334
- if (!hasDefaultExport) continue
335
- if (!hasGetStaticPaths) {
336
- findings.push({
337
- code: 'dynamic-route-missing-get-static-paths',
338
- message:
339
- `Dynamic route "${base}" has no \`getStaticPaths\` export — under \`mode: 'ssg'\` ` +
340
- `the auto-detect step SILENTLY SKIPS this route, so the dist won't contain prerendered HTML. ` +
341
- `Either add \`export const getStaticPaths = () => [{ params: { ... } }, ...]\` enumerating ` +
342
- `the concrete values, OR declare the route as runtime-only by switching to mode: 'ssr' / 'isr'.`,
343
- location: {
344
- path: file,
345
- relPath: relative(rootForRel, file),
346
- line: 1,
347
- column: 1,
348
- },
349
- })
350
- }
351
- }
352
- return findings
353
- }
354
-
355
- /**
356
- * 3) `export const revalidate = X` where X is NOT a pure literal.
357
- *
358
- * PR I's `extractLiteralExport` skips re-export forms (`const x = 60;
359
- * export { x as revalidate }`) and non-literal expressions
360
- * (`export const revalidate = TTL` where TTL is a const elsewhere). The
361
- * manifest emission skips the entry silently — user thinks ISR is wired
362
- * but `_pyreon-revalidate.json` is missing the path. The audit catches
363
- * the syntactic shape and warns.
364
- *
365
- * Valid literals: NumericLiteral (`60`), FalseKeyword (`false`).
366
- * Anything else — Identifier reference, BinaryExpression, CallExpression,
367
- * TemplateLiteral — flagged.
368
- */
369
- function detectNonLiteralRevalidateExport(
370
- routeFiles: readonly string[],
371
- rootForRel: string,
372
- ): SsgFinding[] {
373
- const findings: SsgFinding[] = []
374
- for (const file of routeFiles) {
375
- const parsed = parseSourceFile(file)
376
- if (!parsed) continue
377
- const source: ts.SourceFile = parsed
378
- function visit(node: ts.Node): void {
379
- if (ts.isVariableStatement(node)) {
380
- const hasExport = node.modifiers?.some(
381
- (m) => m.kind === ts.SyntaxKind.ExportKeyword,
382
- )
383
- if (!hasExport) {
384
- ts.forEachChild(node, visit)
385
- return
386
- }
387
- for (const decl of node.declarationList.declarations) {
388
- if (!ts.isIdentifier(decl.name) || decl.name.text !== 'revalidate') continue
389
- const init = decl.initializer
390
- if (!init) continue
391
- // Accept NumericLiteral and `false` keyword.
392
- if (ts.isNumericLiteral(init)) continue
393
- if (init.kind === ts.SyntaxKind.FalseKeyword) continue
394
- // Anything else is a non-literal that PR I's extractor skips.
395
- findings.push({
396
- code: 'non-literal-revalidate-export',
397
- message:
398
- `\`export const revalidate\` must be a NUMERIC LITERAL (e.g. \`60\`, \`3600\`) or ` +
399
- `\`false\` — non-literal expressions (variable references, math, function calls, ` +
400
- `template literals) are silently dropped from the build-time ISR manifest (PR I's ` +
401
- `extractLiteralExport limitation). Inline the value: \`export const revalidate = 60\`.`,
402
- location: makeLocation(file, source, init, rootForRel),
403
- })
404
- }
405
- }
406
- ts.forEachChild(node, visit)
407
- }
408
- visit(source)
409
- }
410
- return findings
411
- }
412
-
413
- // ═══════════════════════════════════════════════════════════════════════════════
414
- // Entry point
415
- // ═══════════════════════════════════════════════════════════════════════════════
416
-
417
- export function auditSsg(rootDir: string): SsgAuditResult {
418
- const root = findMonorepoRoot(rootDir) ?? rootDir
419
- const routeFiles: string[] = []
420
- findRouteFiles(rootDir, routeFiles)
421
-
422
- // Count dynamic routes + revalidate exports for the summary (independent
423
- // of whether each emitted a finding) — useful signal in the JSON output.
424
- let dynamicRoutes = 0
425
- let revalidateExports = 0
426
- for (const file of routeFiles) {
427
- const base = file.split('/').pop() ?? ''
428
- if (/\[[^\]]{1,200}\]/.test(base) && !/^_(layout|error|loading|404|not-found)\./.test(base)) {
429
- dynamicRoutes++
430
- }
431
- const source = parseSourceFile(file)
432
- if (!source) continue
433
- function visit(node: ts.Node): void {
434
- if (ts.isVariableStatement(node)) {
435
- const hasExport = node.modifiers?.some(
436
- (m) => m.kind === ts.SyntaxKind.ExportKeyword,
437
- )
438
- if (hasExport) {
439
- for (const decl of node.declarationList.declarations) {
440
- if (ts.isIdentifier(decl.name) && decl.name.text === 'revalidate') {
441
- revalidateExports++
442
- }
443
- }
444
- }
445
- }
446
- ts.forEachChild(node, visit)
447
- }
448
- visit(source)
449
- }
450
-
451
- const findings: SsgFinding[] = [
452
- ...detect404OutsideLayoutDir(routeFiles, root),
453
- ...detectDynamicRouteMissingGetStaticPaths(routeFiles, root),
454
- ...detectNonLiteralRevalidateExport(routeFiles, root),
455
- ]
456
-
457
- const findingsByCode: Record<SsgFindingCode, number> = {
458
- '404-outside-layout-dir': 0,
459
- 'dynamic-route-missing-get-static-paths': 0,
460
- 'non-literal-revalidate-export': 0,
461
- }
462
- for (const f of findings) findingsByCode[f.code]++
463
-
464
- return {
465
- root,
466
- findings,
467
- summary: {
468
- filesScanned: routeFiles.length,
469
- routesScanned: routeFiles.length,
470
- dynamicRoutes,
471
- revalidateExports,
472
- findingsByCode,
473
- },
474
- }
475
- }
476
-
477
- // ═══════════════════════════════════════════════════════════════════════════════
478
- // Formatter (mirrors formatIslandAudit)
479
- // ═══════════════════════════════════════════════════════════════════════════════
480
-
481
- export interface SsgAuditFormatOptions {
482
- /** Filter findings to a minimum severity. Currently all SSG findings
483
- * are 'warning'-level; reserved for future severity tiers. */
484
- minSeverity?: 'warning' | 'error' | undefined
485
- }
486
-
487
- export function formatSsgAudit(
488
- result: SsgAuditResult,
489
- _options: SsgAuditFormatOptions = {},
490
- ): string {
491
- const lines: string[] = []
492
- lines.push('── SSG audit ─────────────────────────────────────────────────────')
493
- lines.push('')
494
- lines.push(
495
- `Scanned ${result.summary.routesScanned} route file(s), ${result.summary.dynamicRoutes} dynamic route(s), ${result.summary.revalidateExports} revalidate export(s).`,
496
- )
497
- lines.push('')
498
- if (result.findings.length === 0) {
499
- lines.push('✓ No SSG / ISR issues found.')
500
- lines.push('')
501
- return lines.join('\n')
502
- }
503
- lines.push(`Found ${result.findings.length} issue(s):`)
504
- for (const f of result.findings) {
505
- lines.push('')
506
- lines.push(` [${f.code}] ${f.location.relPath}:${f.location.line}:${f.location.column}`)
507
- lines.push(` ${f.message}`)
508
- }
509
- lines.push('')
510
- lines.push('Run `pyreon doctor --check-ssg --json` for machine-readable output.')
511
- lines.push('')
512
- return lines.join('\n')
513
- }