@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.
- package/package.json +11 -13
- package/src/defer-inline.ts +0 -686
- package/src/event-names.ts +0 -65
- package/src/index.ts +0 -61
- package/src/island-audit.ts +0 -675
- package/src/jsx.ts +0 -2792
- package/src/load-native.ts +0 -156
- package/src/lpih.ts +0 -270
- package/src/manifest.ts +0 -280
- package/src/project-scanner.ts +0 -214
- package/src/pyreon-intercept.ts +0 -1029
- package/src/react-intercept.ts +0 -1217
- package/src/reactivity-lens.ts +0 -190
- package/src/ssg-audit.ts +0 -513
- package/src/test-audit.ts +0 -435
- package/src/tests/backend-parity-r7-r9.test.ts +0 -91
- package/src/tests/backend-prop-derived-callback-divergence.test.ts +0 -74
- package/src/tests/collapse-bail-census.test.ts +0 -330
- package/src/tests/collapse-key-source-hygiene.test.ts +0 -88
- package/src/tests/component-child-no-wrap.test.ts +0 -204
- package/src/tests/defer-inline.test.ts +0 -387
- package/src/tests/depth-stress.test.ts +0 -16
- package/src/tests/detector-tag-consistency.test.ts +0 -101
- package/src/tests/dynamic-collapse-detector.test.ts +0 -164
- package/src/tests/dynamic-collapse-emit.test.ts +0 -192
- package/src/tests/dynamic-collapse-scan.test.ts +0 -111
- package/src/tests/element-valued-const-child.test.ts +0 -61
- package/src/tests/falsy-child-characterization.test.ts +0 -48
- package/src/tests/island-audit.test.ts +0 -524
- package/src/tests/jsx.test.ts +0 -2908
- package/src/tests/load-native.test.ts +0 -53
- package/src/tests/lpih.test.ts +0 -404
- package/src/tests/malformed-input-resilience.test.ts +0 -50
- package/src/tests/manifest-snapshot.test.ts +0 -55
- package/src/tests/native-equivalence.test.ts +0 -924
- package/src/tests/partial-collapse-detector.test.ts +0 -121
- package/src/tests/partial-collapse-emit.test.ts +0 -104
- package/src/tests/partial-collapse-robustness.test.ts +0 -53
- package/src/tests/project-scanner.test.ts +0 -269
- package/src/tests/prop-derived-shadow.test.ts +0 -96
- package/src/tests/pure-call-reactive-args.test.ts +0 -50
- package/src/tests/pyreon-intercept.test.ts +0 -816
- package/src/tests/r13-callback-stmt-equivalence.test.ts +0 -58
- package/src/tests/r14-ssr-mode-parity.test.ts +0 -51
- package/src/tests/r15-elemconst-propderived.test.ts +0 -47
- package/src/tests/r19-defer-inline-robust.test.ts +0 -54
- package/src/tests/r20-backend-equivalence-sweep.test.ts +0 -50
- package/src/tests/react-intercept.test.ts +0 -1104
- package/src/tests/reactivity-lens.test.ts +0 -170
- package/src/tests/rocketstyle-collapse.test.ts +0 -208
- package/src/tests/runtime/control-flow.test.ts +0 -159
- package/src/tests/runtime/dom-properties.test.ts +0 -138
- package/src/tests/runtime/events.test.ts +0 -301
- package/src/tests/runtime/harness.ts +0 -94
- package/src/tests/runtime/pr-352-shapes.test.ts +0 -121
- package/src/tests/runtime/reactive-props.test.ts +0 -81
- package/src/tests/runtime/signals.test.ts +0 -129
- package/src/tests/runtime/whitespace.test.ts +0 -106
- package/src/tests/signal-autocall-shadow.test.ts +0 -86
- package/src/tests/sourcemap-fidelity.test.ts +0 -77
- package/src/tests/ssg-audit.test.ts +0 -402
- package/src/tests/static-text-baking.test.ts +0 -64
- package/src/tests/test-audit.test.ts +0 -549
- package/src/tests/transform-state-isolation.test.ts +0 -49
package/src/reactivity-lens.ts
DELETED
|
@@ -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
|
-
}
|