@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
package/src/ssg-audit.ts
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
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
|
+
// Dynamic route iff filename contains `[...]` or `[name]` brackets.
|
|
282
|
+
if (!/\[.+\]/.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 (/\[.+\]/.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
|
+
}
|