@pyreon/lint 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.
- package/bin/pyreon-lint.js +2 -0
- package/lib/analysis/cli.js.html +1 -1
- package/lib/analysis/index.js.html +1 -1
- package/lib/cli.js +251 -9
- package/lib/index.js +251 -9
- package/lib/types/index.d.ts +1 -1
- package/package.json +3 -2
- package/src/rules/index.ts +15 -1
- package/src/rules/reactivity/storage-signal-v-forwarding.ts +184 -0
- package/src/rules/ssg/index.ts +3 -0
- package/src/rules/ssg/invalid-loader-export.ts +84 -0
- package/src/rules/ssg/missing-get-static-paths.ts +103 -0
- package/src/rules/ssg/revalidate-not-pure-literal.ts +69 -0
- package/src/runner.ts +8 -8
- package/src/tests/runner.test.ts +8 -5
- package/src/tests/ssg-rules.test.ts +211 -0
- package/src/tests/storage-signal-v-forwarding.test.ts +224 -0
- package/src/types.ts +1 -0
- package/src/utils/validate-options.ts +1 -1
package/src/rules/index.ts
CHANGED
|
@@ -55,11 +55,16 @@ import { noSignalInProps } from './reactivity/no-signal-in-props'
|
|
|
55
55
|
import { noSignalLeak } from './reactivity/no-signal-leak'
|
|
56
56
|
import { noUnbatchedUpdates } from './reactivity/no-unbatched-updates'
|
|
57
57
|
import { preferComputed } from './reactivity/prefer-computed'
|
|
58
|
+
import { storageSignalVForwarding } from './reactivity/storage-signal-v-forwarding'
|
|
58
59
|
// Router
|
|
59
60
|
import { noHrefNavigation } from './router/no-href-navigation'
|
|
60
61
|
import { noImperativeNavigateInRender } from './router/no-imperative-navigate-in-render'
|
|
61
62
|
import { noMissingFallback } from './router/no-missing-fallback'
|
|
62
63
|
import { preferUseIsActive } from './router/prefer-use-is-active'
|
|
64
|
+
// SSG (M3.5)
|
|
65
|
+
import { invalidLoaderExport } from './ssg/invalid-loader-export'
|
|
66
|
+
import { missingGetStaticPaths } from './ssg/missing-get-static-paths'
|
|
67
|
+
import { revalidateNotPureLiteral } from './ssg/revalidate-not-pure-literal'
|
|
63
68
|
import { noMismatchRisk } from './ssr/no-mismatch-risk'
|
|
64
69
|
// SSR
|
|
65
70
|
import { noWindowInSsr } from './ssr/no-window-in-ssr'
|
|
@@ -75,7 +80,7 @@ import { noThemeOutsideProvider } from './styling/no-theme-outside-provider'
|
|
|
75
80
|
import { preferCx } from './styling/prefer-cx'
|
|
76
81
|
|
|
77
82
|
export const allRules: Rule[] = [
|
|
78
|
-
// Reactivity (
|
|
83
|
+
// Reactivity (13)
|
|
79
84
|
noAsyncEffect,
|
|
80
85
|
noBareSignalInJsx,
|
|
81
86
|
noContextDestructure,
|
|
@@ -88,6 +93,7 @@ export const allRules: Rule[] = [
|
|
|
88
93
|
noEffectAssignment,
|
|
89
94
|
noSignalLeak,
|
|
90
95
|
noSignalCallWrite,
|
|
96
|
+
storageSignalVForwarding,
|
|
91
97
|
// JSX (11)
|
|
92
98
|
noMapInJsx,
|
|
93
99
|
useByNotKey,
|
|
@@ -149,6 +155,10 @@ export const allRules: Rule[] = [
|
|
|
149
155
|
noImperativeNavigateInRender,
|
|
150
156
|
noMissingFallback,
|
|
151
157
|
preferUseIsActive,
|
|
158
|
+
// SSG (3) — M3.5
|
|
159
|
+
invalidLoaderExport,
|
|
160
|
+
missingGetStaticPaths,
|
|
161
|
+
revalidateNotPureLiteral,
|
|
152
162
|
]
|
|
153
163
|
|
|
154
164
|
// Re-export all rules individually
|
|
@@ -223,6 +233,10 @@ export {
|
|
|
223
233
|
preferRequestContext,
|
|
224
234
|
preferShowOverDisplay,
|
|
225
235
|
preferUseIsActive,
|
|
236
|
+
// SSG (M3.5)
|
|
237
|
+
invalidLoaderExport,
|
|
238
|
+
missingGetStaticPaths,
|
|
239
|
+
revalidateNotPureLiteral,
|
|
226
240
|
// Accessibility
|
|
227
241
|
toastA11y,
|
|
228
242
|
useByNotKey,
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
|
+
import { getSpan } from '../../utils/ast'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Flag signal-wrapper callables that delegate `.direct` to a base signal
|
|
6
|
+
* but do NOT also forward the internal `_v` field via `Object.defineProperty`.
|
|
7
|
+
*
|
|
8
|
+
* Background: the compiler emits `_bindText(source, textNode)` for JSX
|
|
9
|
+
* shape `{() => signal()}` where `signal` is a callable. `_bindText`'s
|
|
10
|
+
* fast path reads `source._v` directly (skipping the function call) for
|
|
11
|
+
* BOTH the initial render AND every subscriber re-run. A wrapper that
|
|
12
|
+
* delegates `.direct` enables that fast path — but if `_v` is missing,
|
|
13
|
+
* the binding writes `String(undefined)` → `''` and re-writes empty on
|
|
14
|
+
* every change. localStorage / sessionStorage / cookies still update;
|
|
15
|
+
* the DOM stays empty.
|
|
16
|
+
*
|
|
17
|
+
* Bug shipped in `@pyreon/storage` from package inception for ~9 months
|
|
18
|
+
* before being fixed in PR #546. This rule prevents recurrence in:
|
|
19
|
+
* - Future framework backends (additional `createStorage` callers,
|
|
20
|
+
* new wrapper helpers extracted from the 4 existing factories)
|
|
21
|
+
* - User-side custom backends built without `createStorage()`
|
|
22
|
+
* - Third-party signal-like adapters
|
|
23
|
+
*
|
|
24
|
+
* Detected shapes (per-function scope):
|
|
25
|
+
* x.direct = y.direct
|
|
26
|
+
* x.direct = (...) => y.direct(...)
|
|
27
|
+
* x.direct = function (...) { return y.direct(...) }
|
|
28
|
+
*
|
|
29
|
+
* Acceptable companions (in the same function scope):
|
|
30
|
+
* Object.defineProperty(x, '_v', { get: () => y._v })
|
|
31
|
+
* x._v = y._v
|
|
32
|
+
* Object.defineProperty(x, '_v', { value: ... })
|
|
33
|
+
*
|
|
34
|
+
* Out of scope: cross-function tracking, identifier aliasing
|
|
35
|
+
* (`const w = x; w.direct = …; Object.defineProperty(x, '_v', …)`).
|
|
36
|
+
* The lint heuristic operates at single-function granularity — that's
|
|
37
|
+
* the level the bug originally lived at (createStorageSignal inside
|
|
38
|
+
* `packages/fundamentals/storage/src/local.ts`).
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
interface ScopeFrame {
|
|
42
|
+
/** Object identifier → AssignmentExpression node for the `.direct =` site */
|
|
43
|
+
directAssigns: Map<string, unknown>
|
|
44
|
+
/** Object identifiers that have `_v` forwarded in this scope */
|
|
45
|
+
vForwards: Set<string>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isDirectDelegation(rhs: any): boolean {
|
|
49
|
+
if (!rhs) return false
|
|
50
|
+
|
|
51
|
+
// x.direct = y.direct
|
|
52
|
+
if (rhs.type === 'MemberExpression' && rhs.property?.name === 'direct') {
|
|
53
|
+
return true
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// x.direct = (...) => y.direct(...)
|
|
57
|
+
// x.direct = function (...) { return y.direct(...) }
|
|
58
|
+
if (rhs.type === 'ArrowFunctionExpression' || rhs.type === 'FunctionExpression') {
|
|
59
|
+
const body = rhs.body
|
|
60
|
+
if (!body) return false
|
|
61
|
+
|
|
62
|
+
// Arrow with implicit-return expression body
|
|
63
|
+
if (body.type !== 'BlockStatement') {
|
|
64
|
+
return isDirectCall(body)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Block body — look for a single `return y.direct(...)` statement
|
|
68
|
+
const stmts = body.body
|
|
69
|
+
if (!stmts || stmts.length !== 1) return false
|
|
70
|
+
const stmt = stmts[0]
|
|
71
|
+
if (stmt.type !== 'ReturnStatement') return false
|
|
72
|
+
return isDirectCall(stmt.argument)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return false
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isDirectCall(expr: any): boolean {
|
|
79
|
+
if (!expr || expr.type !== 'CallExpression') return false
|
|
80
|
+
const callee = expr.callee
|
|
81
|
+
return Boolean(
|
|
82
|
+
callee &&
|
|
83
|
+
callee.type === 'MemberExpression' &&
|
|
84
|
+
callee.property?.name === 'direct',
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function getStringLiteralValue(node: any): string | null {
|
|
89
|
+
if (!node) return null
|
|
90
|
+
if (node.type === 'Literal' && typeof node.value === 'string') return node.value
|
|
91
|
+
// oxc emits StringLiteral for plain string literals
|
|
92
|
+
if (node.type === 'StringLiteral' && typeof node.value === 'string') return node.value
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const storageSignalVForwarding: Rule = {
|
|
97
|
+
meta: {
|
|
98
|
+
id: 'pyreon/storage-signal-v-forwarding',
|
|
99
|
+
category: 'reactivity',
|
|
100
|
+
description:
|
|
101
|
+
'Signal-wrapper callables delegating `.direct` to a base signal must also forward the internal `_v` field. Without forwarding, the compiler-emitted `_bindText` fast path reads `undefined` and renders empty text post-hydration.',
|
|
102
|
+
severity: 'error',
|
|
103
|
+
fixable: false,
|
|
104
|
+
},
|
|
105
|
+
create(context) {
|
|
106
|
+
const stack: ScopeFrame[] = []
|
|
107
|
+
|
|
108
|
+
const enter = () => {
|
|
109
|
+
stack.push({ directAssigns: new Map(), vForwards: new Set() })
|
|
110
|
+
}
|
|
111
|
+
const exit = () => {
|
|
112
|
+
const scope = stack.pop()
|
|
113
|
+
if (!scope) return
|
|
114
|
+
for (const [name, node] of scope.directAssigns) {
|
|
115
|
+
if (scope.vForwards.has(name)) continue
|
|
116
|
+
context.report({
|
|
117
|
+
message:
|
|
118
|
+
`Signal wrapper '${name}' delegates \`.direct\` to a base signal but ` +
|
|
119
|
+
`does not forward \`_v\`. The compiler-emitted \`_bindText\` fast path reads ` +
|
|
120
|
+
`\`${name}._v\` directly — without forwarding, the binding writes ` +
|
|
121
|
+
`\`''\` on initial render AND every subscriber notification, even after ` +
|
|
122
|
+
`\`.set()\` calls. Add: \`Object.defineProperty(${name}, '_v', ` +
|
|
123
|
+
`{ get: () => sig._v, configurable: true })\` in the same scope. Reference: ` +
|
|
124
|
+
`\`packages/fundamentals/storage/src/local.ts:createStorageSignal\` ` +
|
|
125
|
+
`for the canonical shape.`,
|
|
126
|
+
span: getSpan(node as { start: number; end: number }),
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const callbacks: VisitorCallbacks = {
|
|
132
|
+
Program: enter,
|
|
133
|
+
'Program:exit': exit,
|
|
134
|
+
FunctionDeclaration: enter,
|
|
135
|
+
'FunctionDeclaration:exit': exit,
|
|
136
|
+
FunctionExpression: enter,
|
|
137
|
+
'FunctionExpression:exit': exit,
|
|
138
|
+
ArrowFunctionExpression: enter,
|
|
139
|
+
'ArrowFunctionExpression:exit': exit,
|
|
140
|
+
|
|
141
|
+
AssignmentExpression(node: any) {
|
|
142
|
+
const scope = stack[stack.length - 1]
|
|
143
|
+
if (!scope) return
|
|
144
|
+
|
|
145
|
+
const left = node.left
|
|
146
|
+
if (left?.type !== 'MemberExpression') return
|
|
147
|
+
if (left.object?.type !== 'Identifier') return
|
|
148
|
+
const objName = left.object.name
|
|
149
|
+
|
|
150
|
+
const propName = left.property?.name ?? getStringLiteralValue(left.property)
|
|
151
|
+
|
|
152
|
+
if (propName === 'direct' && isDirectDelegation(node.right)) {
|
|
153
|
+
scope.directAssigns.set(objName, node)
|
|
154
|
+
} else if (propName === '_v') {
|
|
155
|
+
// Plain `x._v = ...` counts as forwarding (rare but valid)
|
|
156
|
+
scope.vForwards.add(objName)
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
CallExpression(node: any) {
|
|
161
|
+
const scope = stack[stack.length - 1]
|
|
162
|
+
if (!scope) return
|
|
163
|
+
|
|
164
|
+
const callee = node.callee
|
|
165
|
+
if (!callee || callee.type !== 'MemberExpression') return
|
|
166
|
+
if (callee.object?.type !== 'Identifier' || callee.object.name !== 'Object') return
|
|
167
|
+
if (callee.property?.name !== 'defineProperty') return
|
|
168
|
+
|
|
169
|
+
const args = node.arguments
|
|
170
|
+
if (!args || args.length < 2) return
|
|
171
|
+
|
|
172
|
+
const target = args[0]
|
|
173
|
+
if (target?.type !== 'Identifier') return
|
|
174
|
+
|
|
175
|
+
const propValue = getStringLiteralValue(args[1])
|
|
176
|
+
if (propValue !== '_v') return
|
|
177
|
+
|
|
178
|
+
scope.vForwards.add(target.name)
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return callbacks
|
|
183
|
+
},
|
|
184
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M3.5 — `pyreon/invalid-loader-export`.
|
|
3
|
+
*
|
|
4
|
+
* `export const loader = X` where X isn't a function. fs-router treats
|
|
5
|
+
* `loader` as a callable: `loader(ctx: LoaderContext)`. If the user
|
|
6
|
+
* exports `loader = { data: ... }` (object) or `loader = await fetch(...)`
|
|
7
|
+
* (a resolved value) — both occasionally happen when learning the API —
|
|
8
|
+
* the SSR / SSG runtime crashes with `TypeError: loader is not a
|
|
9
|
+
* function`, often deep inside the prefetch loop with a stack trace
|
|
10
|
+
* that doesn't name the route.
|
|
11
|
+
*
|
|
12
|
+
* The rule fires on `export const loader = <non-arrow / non-function /
|
|
13
|
+
* non-identifier>`. Function declarations (`export async function
|
|
14
|
+
* loader()`) and arrow / function expressions are obviously fine.
|
|
15
|
+
* Identifier references (`export const loader = myImportedLoader`) are
|
|
16
|
+
* accepted at lint time — the rule can't resolve the binding's
|
|
17
|
+
* callability cross-module without a type-check.
|
|
18
|
+
*
|
|
19
|
+
* Scoped to route files (`src/routes/`).
|
|
20
|
+
*/
|
|
21
|
+
import type { Rule, VisitorCallbacks } from '../../types'
|
|
22
|
+
import { getSpan } from '../../utils/ast'
|
|
23
|
+
|
|
24
|
+
const ROUTES_PATH_RE = /[/\\]routes[/\\]/
|
|
25
|
+
|
|
26
|
+
function isLikelyCallable(node: any): boolean {
|
|
27
|
+
if (!node) return false
|
|
28
|
+
// Direct function shapes.
|
|
29
|
+
if (node.type === 'ArrowFunctionExpression') return true
|
|
30
|
+
if (node.type === 'FunctionExpression') return true
|
|
31
|
+
// Identifier — defer to type-check / cross-file resolver. We assume the
|
|
32
|
+
// binding might be a function and don't flag. Catches the
|
|
33
|
+
// `export const loader = sharedLoader` pattern.
|
|
34
|
+
if (node.type === 'Identifier') return true
|
|
35
|
+
// Call expression — caller pattern (`export const loader =
|
|
36
|
+
// makeLoader(...)`). Assume the factory returns a function.
|
|
37
|
+
if (node.type === 'CallExpression') return true
|
|
38
|
+
// Class methods — `export const loader = MyClass.prototype.fetch`.
|
|
39
|
+
if (node.type === 'MemberExpression') return true
|
|
40
|
+
// TS type-as-call (`as Loader<...>`) — strip the cast and re-check.
|
|
41
|
+
if (node.type === 'TSAsExpression' || node.type === 'TSSatisfiesExpression') {
|
|
42
|
+
return isLikelyCallable(node.expression)
|
|
43
|
+
}
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const invalidLoaderExport: Rule = {
|
|
48
|
+
meta: {
|
|
49
|
+
id: 'pyreon/invalid-loader-export',
|
|
50
|
+
category: 'ssg',
|
|
51
|
+
description:
|
|
52
|
+
'`export const loader` must be a function — non-callable exports crash the SSR runtime with `loader is not a function`.',
|
|
53
|
+
severity: 'error',
|
|
54
|
+
fixable: false,
|
|
55
|
+
},
|
|
56
|
+
create(context) {
|
|
57
|
+
const filePath = context.getFilePath()
|
|
58
|
+
if (!ROUTES_PATH_RE.test(filePath)) return {}
|
|
59
|
+
|
|
60
|
+
const callbacks: VisitorCallbacks = {
|
|
61
|
+
ExportNamedDeclaration(node: any) {
|
|
62
|
+
const decl = node.declaration
|
|
63
|
+
if (!decl) return
|
|
64
|
+
// `export const loader = ...` shape only — function declarations
|
|
65
|
+
// (`export async function loader()`) are obviously callable.
|
|
66
|
+
if (decl.type !== 'VariableDeclaration') return
|
|
67
|
+
for (const declarator of decl.declarations ?? []) {
|
|
68
|
+
if (declarator.type !== 'VariableDeclarator') continue
|
|
69
|
+
const id = declarator.id
|
|
70
|
+
if (id?.type !== 'Identifier' || id.name !== 'loader') continue
|
|
71
|
+
const init = declarator.init
|
|
72
|
+
if (!init) continue
|
|
73
|
+
if (isLikelyCallable(init)) continue
|
|
74
|
+
context.report({
|
|
75
|
+
message:
|
|
76
|
+
'`export const loader` must be a function (arrow, function expression, or identifier reference). Got a non-callable expression — the SSR runtime will crash with `TypeError: loader is not a function`. If you meant to export static data, use `export const meta = { ... }` instead.',
|
|
77
|
+
span: getSpan(init),
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
return callbacks
|
|
83
|
+
},
|
|
84
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M3.5 — `pyreon/missing-get-static-paths`.
|
|
3
|
+
*
|
|
4
|
+
* A dynamic route file (filename contains `[param]` or `[...rest]`)
|
|
5
|
+
* that lacks an `export const getStaticPaths` (or `export async
|
|
6
|
+
* function getStaticPaths`). Under `mode: 'ssg'`, the SSG plugin's
|
|
7
|
+
* auto-detect step SILENTLY SKIPS such routes — `dist/posts/<id>/
|
|
8
|
+
* index.html` never gets emitted, the user thinks prerendering worked
|
|
9
|
+
* but production serves 404s on every dynamic URL.
|
|
10
|
+
*
|
|
11
|
+
* The rule fires on the route file itself (filename signal alone is
|
|
12
|
+
* enough — `[id].tsx` / `[...slug].tsx` shapes). Scopes to files under
|
|
13
|
+
* `src/routes/` because the dynamic-route convention is fs-router-
|
|
14
|
+
* specific.
|
|
15
|
+
*
|
|
16
|
+
* **Skips API routes.** Files under `src/routes/api/` AND any file
|
|
17
|
+
* that doesn't export a `default` page component are API handlers —
|
|
18
|
+
* they're runtime-only by definition (fs-router invokes them per
|
|
19
|
+
* request, never prerenders them), so `getStaticPaths` doesn't apply.
|
|
20
|
+
* Caught originally in M3.B against `examples/cpa-pw-blog`'s
|
|
21
|
+
* `api/echo/[...path].ts`. Both checks fire together as defense in
|
|
22
|
+
* depth: the path check catches the convention, the export-shape
|
|
23
|
+
* check catches anyone who puts an API route outside `api/`.
|
|
24
|
+
*
|
|
25
|
+
* Fires `warn` because dynamic routes that intentionally run as SSR
|
|
26
|
+
* (mode: 'ssr' / 'isr') don't need `getStaticPaths` — the rule can't
|
|
27
|
+
* read `vite.config.ts` to know which mode the app uses. The warn
|
|
28
|
+
* level signals "review whether this is intentional" without blocking
|
|
29
|
+
* the build.
|
|
30
|
+
*/
|
|
31
|
+
import type { Rule, VisitorCallbacks } from '../../types'
|
|
32
|
+
|
|
33
|
+
const ROUTES_PATH_RE = /[/\\]routes[/\\]/
|
|
34
|
+
const API_PATH_RE = /[/\\]routes[/\\]api[/\\]/
|
|
35
|
+
const DYNAMIC_FILENAME_RE = /\[.+\]\.(tsx?|jsx?)$/
|
|
36
|
+
const SPECIAL_ROUTE_RE = /[/\\]_(layout|error|loading|404|not-found)\./
|
|
37
|
+
|
|
38
|
+
export const missingGetStaticPaths: Rule = {
|
|
39
|
+
meta: {
|
|
40
|
+
id: 'pyreon/missing-get-static-paths',
|
|
41
|
+
category: 'ssg',
|
|
42
|
+
description:
|
|
43
|
+
'Dynamic route files (`[id].tsx`, `[...slug].tsx`) should export `getStaticPaths` — under `mode: "ssg"` the SSG plugin silently skips routes without it.',
|
|
44
|
+
severity: 'warn',
|
|
45
|
+
fixable: false,
|
|
46
|
+
},
|
|
47
|
+
create(context) {
|
|
48
|
+
const filePath = context.getFilePath()
|
|
49
|
+
if (!ROUTES_PATH_RE.test(filePath)) return {}
|
|
50
|
+
// Skip API routes (`src/routes/api/`) — they're runtime handlers,
|
|
51
|
+
// never prerendered. Page-vs-API by file-system convention.
|
|
52
|
+
if (API_PATH_RE.test(filePath)) return {}
|
|
53
|
+
if (!DYNAMIC_FILENAME_RE.test(filePath)) return {}
|
|
54
|
+
if (SPECIAL_ROUTE_RE.test(filePath)) return {}
|
|
55
|
+
|
|
56
|
+
let hasGetStaticPaths = false
|
|
57
|
+
let hasDefaultExport = false
|
|
58
|
+
let programSpan: { start: number; end: number } | null = null
|
|
59
|
+
|
|
60
|
+
const callbacks: VisitorCallbacks = {
|
|
61
|
+
Program(node: any) {
|
|
62
|
+
programSpan = { start: node.start ?? 0, end: node.end ?? 0 }
|
|
63
|
+
},
|
|
64
|
+
ExportNamedDeclaration(node: any) {
|
|
65
|
+
const decl = node.declaration
|
|
66
|
+
if (!decl) return
|
|
67
|
+
if (decl.type === 'VariableDeclaration') {
|
|
68
|
+
for (const declarator of decl.declarations ?? []) {
|
|
69
|
+
if (declarator.type !== 'VariableDeclarator') continue
|
|
70
|
+
const id = declarator.id
|
|
71
|
+
if (id?.type === 'Identifier' && id.name === 'getStaticPaths') {
|
|
72
|
+
hasGetStaticPaths = true
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} else if (decl.type === 'FunctionDeclaration') {
|
|
76
|
+
if (decl.id?.name === 'getStaticPaths') {
|
|
77
|
+
hasGetStaticPaths = true
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
ExportDefaultDeclaration() {
|
|
82
|
+
hasDefaultExport = true
|
|
83
|
+
},
|
|
84
|
+
'Program:exit'() {
|
|
85
|
+
// No `export default` → it's an API route by structure. Skip.
|
|
86
|
+
// Page routes structurally require a default-exported component
|
|
87
|
+
// (the fs-router renders `route.component`). Files exporting only
|
|
88
|
+
// method handlers (`GET` / `POST` / etc.) without a default are
|
|
89
|
+
// API routes wherever they sit in the tree.
|
|
90
|
+
if (!hasDefaultExport) return
|
|
91
|
+
if (hasGetStaticPaths || !programSpan) return
|
|
92
|
+
context.report({
|
|
93
|
+
message:
|
|
94
|
+
'Dynamic route file is missing `export const getStaticPaths` — under `mode: "ssg"` the SSG plugin silently skips this route, so the dist won\'t contain prerendered HTML. Either add `export const getStaticPaths = () => [{ params: { ... } }, ...]` enumerating the concrete values, OR declare the route as runtime-only by switching to `mode: "ssr"` / `mode: "isr"`.',
|
|
95
|
+
// Report at the start of the file so the diagnostic is visible
|
|
96
|
+
// in editor gutters without scrolling.
|
|
97
|
+
span: { start: programSpan.start, end: Math.min(programSpan.start + 1, programSpan.end) },
|
|
98
|
+
})
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
return callbacks
|
|
102
|
+
},
|
|
103
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M3.5 — `pyreon/revalidate-not-pure-literal`.
|
|
3
|
+
*
|
|
4
|
+
* `export const revalidate = X` where X isn't a numeric literal or
|
|
5
|
+
* `false`. PR I's `extractLiteralExport` skips non-literal expressions
|
|
6
|
+
* silently — the build-time ISR manifest (`_pyreon-revalidate.json`)
|
|
7
|
+
* omits the entry, platform-driven ISR is silently unconfigured for
|
|
8
|
+
* that route. The user thinks ISR is wired but production stays stale
|
|
9
|
+
* forever.
|
|
10
|
+
*
|
|
11
|
+
* The rule scopes to route files (anything under `src/routes/`) — the
|
|
12
|
+
* `revalidate` convention only has meaning there. Module-level
|
|
13
|
+
* `const revalidate = X` in unrelated files is fine.
|
|
14
|
+
*/
|
|
15
|
+
import type { Rule, VisitorCallbacks } from '../../types'
|
|
16
|
+
import { getSpan } from '../../utils/ast'
|
|
17
|
+
|
|
18
|
+
const ROUTES_PATH_RE = /[/\\]routes[/\\]/
|
|
19
|
+
|
|
20
|
+
function isLiteralOk(node: any): boolean {
|
|
21
|
+
if (!node) return false
|
|
22
|
+
// `60`, `3600`, etc.
|
|
23
|
+
if (node.type === 'Literal' && typeof node.value === 'number') return true
|
|
24
|
+
if (node.type === 'NumericLiteral' && typeof node.value === 'number') return true
|
|
25
|
+
// `false` (oxc emits `Literal` for booleans too; some shapes emit
|
|
26
|
+
// `BooleanLiteral`). Accept both.
|
|
27
|
+
if (node.type === 'Literal' && node.value === false) return true
|
|
28
|
+
if (node.type === 'BooleanLiteral' && node.value === false) return true
|
|
29
|
+
return false
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const revalidateNotPureLiteral: Rule = {
|
|
33
|
+
meta: {
|
|
34
|
+
id: 'pyreon/revalidate-not-pure-literal',
|
|
35
|
+
category: 'ssg',
|
|
36
|
+
description:
|
|
37
|
+
'`export const revalidate = X` must be a numeric literal or `false` — non-literal forms are silently dropped from the build-time ISR manifest (PR I limitation).',
|
|
38
|
+
severity: 'error',
|
|
39
|
+
fixable: false,
|
|
40
|
+
},
|
|
41
|
+
create(context) {
|
|
42
|
+
const filePath = context.getFilePath()
|
|
43
|
+
// Only scope to route files. The `revalidate` convention is fs-router-
|
|
44
|
+
// specific; module-level `const revalidate = X` in unrelated files is
|
|
45
|
+
// a different code path.
|
|
46
|
+
if (!ROUTES_PATH_RE.test(filePath)) return {}
|
|
47
|
+
|
|
48
|
+
const callbacks: VisitorCallbacks = {
|
|
49
|
+
ExportNamedDeclaration(node: any) {
|
|
50
|
+
const decl = node.declaration
|
|
51
|
+
if (!decl || decl.type !== 'VariableDeclaration') return
|
|
52
|
+
for (const declarator of decl.declarations ?? []) {
|
|
53
|
+
if (declarator.type !== 'VariableDeclarator') continue
|
|
54
|
+
const id = declarator.id
|
|
55
|
+
if (id?.type !== 'Identifier' || id.name !== 'revalidate') continue
|
|
56
|
+
const init = declarator.init
|
|
57
|
+
if (!init) continue
|
|
58
|
+
if (isLiteralOk(init)) continue
|
|
59
|
+
context.report({
|
|
60
|
+
message:
|
|
61
|
+
'`export const revalidate` must be a numeric literal (e.g. `60`, `3600`) or `false` — non-literal expressions (variable references, math, function calls, template literals) are silently dropped from the build-time ISR manifest. Inline the value: `export const revalidate = 60`.',
|
|
62
|
+
span: getSpan(init),
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
return callbacks
|
|
68
|
+
},
|
|
69
|
+
}
|
package/src/runner.ts
CHANGED
|
@@ -161,8 +161,8 @@ export function lintFile(
|
|
|
161
161
|
// Validate options against the rule's declared schema. Cached per
|
|
162
162
|
// (rule, options) pair — config doesn't change within a run.
|
|
163
163
|
const cacheKey = `${rule.meta.id}::${JSON.stringify(options)}`
|
|
164
|
-
let
|
|
165
|
-
if (!
|
|
164
|
+
let validation = VALIDATION_CACHE.get(cacheKey)
|
|
165
|
+
if (!validation) {
|
|
166
166
|
const { errors, warnings } = validateRuleOptions(rule, options)
|
|
167
167
|
const configDiags: ConfigDiagnostic[] = []
|
|
168
168
|
for (const message of warnings) {
|
|
@@ -171,17 +171,17 @@ export function lintFile(
|
|
|
171
171
|
for (const message of errors) {
|
|
172
172
|
configDiags.push({ ruleId: rule.meta.id, severity: 'error', message })
|
|
173
173
|
}
|
|
174
|
-
|
|
175
|
-
VALIDATION_CACHE.set(cacheKey,
|
|
174
|
+
validation = { ok: errors.length === 0, diagnostics: configDiags }
|
|
175
|
+
VALIDATION_CACHE.set(cacheKey, validation)
|
|
176
176
|
}
|
|
177
177
|
// Surface config diagnostics once per (rule, options) pair: prefer
|
|
178
178
|
// the caller-supplied sink (so `lint()` can put them on LintResult);
|
|
179
179
|
// fall back to stderr for standalone `lintFile` usage.
|
|
180
|
-
if (
|
|
180
|
+
if (validation.diagnostics.length > 0) {
|
|
181
181
|
if (configDiagnosticsSink) {
|
|
182
182
|
// Dedupe within the sink by (ruleId, message) so two different rules
|
|
183
183
|
// that happen to produce an identical message don't collapse.
|
|
184
|
-
for (const d of
|
|
184
|
+
for (const d of validation.diagnostics) {
|
|
185
185
|
if (
|
|
186
186
|
!configDiagnosticsSink.some(
|
|
187
187
|
(x) => x.ruleId === d.ruleId && x.message === d.message,
|
|
@@ -191,7 +191,7 @@ export function lintFile(
|
|
|
191
191
|
}
|
|
192
192
|
}
|
|
193
193
|
} else {
|
|
194
|
-
for (const d of
|
|
194
|
+
for (const d of validation.diagnostics) {
|
|
195
195
|
// oxlint-disable-next-line no-console
|
|
196
196
|
const emit = d.severity === 'error' ? console.error : console.warn
|
|
197
197
|
emit(`[pyreon-lint] ${d.message}`)
|
|
@@ -199,7 +199,7 @@ export function lintFile(
|
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
201
|
// Hard error in options → skip this rule entirely for the run.
|
|
202
|
-
if (!
|
|
202
|
+
if (!validation.ok) continue
|
|
203
203
|
|
|
204
204
|
const ctx = createRuleContext(
|
|
205
205
|
rule,
|
package/src/tests/runner.test.ts
CHANGED
|
@@ -53,8 +53,8 @@ function lintWith(ruleId: string, source: string, filePath?: string) {
|
|
|
53
53
|
// ── Rule Metadata ───────────────────────────────────────────────────────────
|
|
54
54
|
|
|
55
55
|
describe('Rule metadata', () => {
|
|
56
|
-
it('should have
|
|
57
|
-
expect(allRules.length).toBe(
|
|
56
|
+
it('should have 66 rules', () => {
|
|
57
|
+
expect(allRules.length).toBe(66)
|
|
58
58
|
})
|
|
59
59
|
|
|
60
60
|
it('should have unique rule IDs', () => {
|
|
@@ -83,6 +83,7 @@ describe('Rule metadata', () => {
|
|
|
83
83
|
'hooks',
|
|
84
84
|
'accessibility',
|
|
85
85
|
'router',
|
|
86
|
+
'ssg',
|
|
86
87
|
])
|
|
87
88
|
for (const rule of allRules) {
|
|
88
89
|
expect(validCategories.has(rule.meta.category)).toBe(true)
|
|
@@ -94,7 +95,7 @@ describe('Rule metadata', () => {
|
|
|
94
95
|
for (const rule of allRules) {
|
|
95
96
|
counts[rule.meta.category] = (counts[rule.meta.category] ?? 0) + 1
|
|
96
97
|
}
|
|
97
|
-
expect(counts.reactivity).toBe(
|
|
98
|
+
expect(counts.reactivity).toBe(13)
|
|
98
99
|
expect(counts.jsx).toBe(11)
|
|
99
100
|
expect(counts.lifecycle).toBe(5)
|
|
100
101
|
expect(counts.performance).toBe(4)
|
|
@@ -106,6 +107,8 @@ describe('Rule metadata', () => {
|
|
|
106
107
|
expect(counts.hooks).toBe(3)
|
|
107
108
|
expect(counts.accessibility).toBe(3)
|
|
108
109
|
expect(counts.router).toBe(4)
|
|
110
|
+
// M3.5 — SSG rules.
|
|
111
|
+
expect(counts.ssg).toBe(3)
|
|
109
112
|
})
|
|
110
113
|
})
|
|
111
114
|
|
|
@@ -1955,7 +1958,7 @@ describe('Ignore filter', () => {
|
|
|
1955
1958
|
describe('Presets', () => {
|
|
1956
1959
|
it('recommended should include all rules', () => {
|
|
1957
1960
|
const config = getPreset('recommended')
|
|
1958
|
-
expect(Object.keys(config.rules).length).toBe(
|
|
1961
|
+
expect(Object.keys(config.rules).length).toBe(66)
|
|
1959
1962
|
})
|
|
1960
1963
|
|
|
1961
1964
|
it('strict should promote all warns to errors', () => {
|
|
@@ -3020,7 +3023,7 @@ describe('config-file round-trip', () => {
|
|
|
3020
3023
|
const base = getPreset('recommended')
|
|
3021
3024
|
const runtimeCfg: LintConfig = {
|
|
3022
3025
|
...base,
|
|
3023
|
-
rules: { ...base.rules, ...
|
|
3026
|
+
rules: { ...base.rules, ...loaded?.rules },
|
|
3024
3027
|
}
|
|
3025
3028
|
|
|
3026
3029
|
// In an exempt path — rule silent.
|