@pyreon/lint 0.14.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.
@@ -0,0 +1,211 @@
1
+ /**
2
+ * M3.5 — Tests for the 3 SSG lint rules:
3
+ * - pyreon/revalidate-not-pure-literal
4
+ * - pyreon/missing-get-static-paths
5
+ * - pyreon/invalid-loader-export
6
+ *
7
+ * Each rule has a parallel pair of specs (broken / fixed) to keep
8
+ * bisect-verification fast.
9
+ */
10
+ import { getPreset } from '../config/presets'
11
+ import { allRules } from '../rules/index'
12
+ import { lintFile } from '../runner'
13
+
14
+ function lintRoute(source: string, filePath: string) {
15
+ return lintFile(filePath, source, allRules, getPreset('recommended'))
16
+ }
17
+
18
+ function diagIds(result: ReturnType<typeof lintFile>): string[] {
19
+ return result.diagnostics.map((d) => d.ruleId)
20
+ }
21
+
22
+ // ─── 1) pyreon/revalidate-not-pure-literal ─────────────────────────────────
23
+
24
+ describe('pyreon/revalidate-not-pure-literal (M3.5)', () => {
25
+ it('FIRES on `export const revalidate = TTL` (identifier ref) in a route file', () => {
26
+ const source = `
27
+ const TTL = 60
28
+ export const revalidate = TTL
29
+ export default function Page() { return null }
30
+ `
31
+ const result = lintRoute(source, 'src/routes/posts/index.tsx')
32
+ expect(diagIds(result)).toContain('pyreon/revalidate-not-pure-literal')
33
+ })
34
+
35
+ it('FIRES on `export const revalidate = 30 * 60` (arithmetic)', () => {
36
+ const source = `export const revalidate = 30 * 60
37
+ export default function Page() { return null }`
38
+ const result = lintRoute(source, 'src/routes/posts/index.tsx')
39
+ expect(diagIds(result)).toContain('pyreon/revalidate-not-pure-literal')
40
+ })
41
+
42
+ it('does NOT fire on `export const revalidate = 60` (literal)', () => {
43
+ const source = `export const revalidate = 60
44
+ export default function Page() { return null }`
45
+ const result = lintRoute(source, 'src/routes/posts/index.tsx')
46
+ expect(diagIds(result)).not.toContain('pyreon/revalidate-not-pure-literal')
47
+ })
48
+
49
+ it('does NOT fire on `export const revalidate = false`', () => {
50
+ const source = `export const revalidate = false
51
+ export default function Page() { return null }`
52
+ const result = lintRoute(source, 'src/routes/posts/index.tsx')
53
+ expect(diagIds(result)).not.toContain('pyreon/revalidate-not-pure-literal')
54
+ })
55
+
56
+ it('does NOT fire when there is no revalidate export', () => {
57
+ const source = `export default function Page() { return null }`
58
+ const result = lintRoute(source, 'src/routes/posts/index.tsx')
59
+ expect(diagIds(result)).not.toContain('pyreon/revalidate-not-pure-literal')
60
+ })
61
+
62
+ it('does NOT fire outside routes/ (rule is route-scoped)', () => {
63
+ const source = `const TTL = 60
64
+ export const revalidate = TTL`
65
+ const result = lintRoute(source, 'src/lib/helpers.ts')
66
+ expect(diagIds(result)).not.toContain('pyreon/revalidate-not-pure-literal')
67
+ })
68
+ })
69
+
70
+ // ─── 2) pyreon/missing-get-static-paths ────────────────────────────────────
71
+
72
+ describe('pyreon/missing-get-static-paths (M3.5)', () => {
73
+ it('FIRES on [id].tsx without getStaticPaths', () => {
74
+ const source = `export default function Post() { return null }`
75
+ const result = lintRoute(source, 'src/routes/posts/[id].tsx')
76
+ expect(diagIds(result)).toContain('pyreon/missing-get-static-paths')
77
+ })
78
+
79
+ it('FIRES on catch-all [...slug].tsx without getStaticPaths', () => {
80
+ const source = `export default function Blog() { return null }`
81
+ const result = lintRoute(source, 'src/routes/blog/[...slug].tsx')
82
+ expect(diagIds(result)).toContain('pyreon/missing-get-static-paths')
83
+ })
84
+
85
+ it('does NOT fire on [id].tsx WITH `export const getStaticPaths`', () => {
86
+ const source = `export const getStaticPaths = () => [{ params: { id: '1' } }]
87
+ export default function Post() { return null }`
88
+ const result = lintRoute(source, 'src/routes/posts/[id].tsx')
89
+ expect(diagIds(result)).not.toContain('pyreon/missing-get-static-paths')
90
+ })
91
+
92
+ it('does NOT fire on [id].tsx WITH `export async function getStaticPaths`', () => {
93
+ const source = `export async function getStaticPaths() { return [{ params: { id: '1' } }] }
94
+ export default function Post() { return null }`
95
+ const result = lintRoute(source, 'src/routes/posts/[id].tsx')
96
+ expect(diagIds(result)).not.toContain('pyreon/missing-get-static-paths')
97
+ })
98
+
99
+ it('does NOT fire on static routes (no [param] in filename)', () => {
100
+ const source = `export default function About() { return null }`
101
+ const result = lintRoute(source, 'src/routes/about.tsx')
102
+ expect(diagIds(result)).not.toContain('pyreon/missing-get-static-paths')
103
+ })
104
+
105
+ it('does NOT fire on _layout / _error / _404 even if name contains brackets', () => {
106
+ const result = lintRoute(
107
+ `export const layout = () => null`,
108
+ 'src/routes/_layout.tsx',
109
+ )
110
+ expect(diagIds(result)).not.toContain('pyreon/missing-get-static-paths')
111
+ })
112
+
113
+ it('does NOT fire outside routes/', () => {
114
+ const source = `export default function Foo() { return null }`
115
+ const result = lintRoute(source, 'src/components/[fake].tsx')
116
+ expect(diagIds(result)).not.toContain('pyreon/missing-get-static-paths')
117
+ })
118
+
119
+ // M3.B follow-up — false-positive class surfaced by cpa-pw-blog's
120
+ // `api/echo/[...path].ts` (real-world API route with bracket
121
+ // filename). API routes are runtime-only by definition; getStaticPaths
122
+ // doesn't apply.
123
+ it('does NOT fire on API routes under src/routes/api/ (path-based skip)', () => {
124
+ const source = `export function GET({ params }) {
125
+ return new Response(\`segments: \${params.path}\`)
126
+ }`
127
+ const result = lintRoute(source, 'src/routes/api/echo/[...path].ts')
128
+ expect(diagIds(result)).not.toContain('pyreon/missing-get-static-paths')
129
+ })
130
+
131
+ it('does NOT fire on API routes (POST handler)', () => {
132
+ const source = `export async function POST({ request }) {
133
+ return new Response('ok')
134
+ }`
135
+ const result = lintRoute(source, 'src/routes/api/posts/[id].ts')
136
+ expect(diagIds(result)).not.toContain('pyreon/missing-get-static-paths')
137
+ })
138
+
139
+ it('does NOT fire on files without `export default` even outside api/ (export-shape skip)', () => {
140
+ // Method-handler-only file outside api/ — covers users who put API
141
+ // routes somewhere non-conventional. Page routes structurally
142
+ // require a default export, so absence is a reliable signal.
143
+ const source = `export function GET() { return new Response('ok') }
144
+ export function POST() { return new Response('ok') }`
145
+ const result = lintRoute(source, 'src/routes/webhook/[id].ts')
146
+ expect(diagIds(result)).not.toContain('pyreon/missing-get-static-paths')
147
+ })
148
+
149
+ it('STILL fires on page routes (with default export) without getStaticPaths', () => {
150
+ // Sanity — make sure the export-shape skip doesn't accidentally
151
+ // silence the rule on legitimate page routes.
152
+ const source = `export const someHelper = 1
153
+ export default function Page() { return null }`
154
+ const result = lintRoute(source, 'src/routes/posts/[id].tsx')
155
+ expect(diagIds(result)).toContain('pyreon/missing-get-static-paths')
156
+ })
157
+ })
158
+
159
+ // ─── 3) pyreon/invalid-loader-export ───────────────────────────────────────
160
+
161
+ describe('pyreon/invalid-loader-export (M3.5)', () => {
162
+ it('FIRES on `export const loader = { data: 1 }` (object)', () => {
163
+ const source = `export const loader = { data: 1 }
164
+ export default function Page() { return null }`
165
+ const result = lintRoute(source, 'src/routes/posts/index.tsx')
166
+ expect(diagIds(result)).toContain('pyreon/invalid-loader-export')
167
+ })
168
+
169
+ it('FIRES on `export const loader = "string"` (string literal)', () => {
170
+ const source = `export const loader = "static-data"
171
+ export default function Page() { return null }`
172
+ const result = lintRoute(source, 'src/routes/posts/index.tsx')
173
+ expect(diagIds(result)).toContain('pyreon/invalid-loader-export')
174
+ })
175
+
176
+ it('does NOT fire on `export const loader = () => fetch(...)` (arrow fn)', () => {
177
+ const source = `export const loader = () => fetch('/api/posts')
178
+ export default function Page() { return null }`
179
+ const result = lintRoute(source, 'src/routes/posts/index.tsx')
180
+ expect(diagIds(result)).not.toContain('pyreon/invalid-loader-export')
181
+ })
182
+
183
+ it('does NOT fire on `export async function loader()` (function decl)', () => {
184
+ const source = `export async function loader() { return [] }
185
+ export default function Page() { return null }`
186
+ const result = lintRoute(source, 'src/routes/posts/index.tsx')
187
+ expect(diagIds(result)).not.toContain('pyreon/invalid-loader-export')
188
+ })
189
+
190
+ it('does NOT fire on `export const loader = sharedLoader` (identifier ref — defer to TS)', () => {
191
+ const source = `import { sharedLoader } from './shared'
192
+ export const loader = sharedLoader
193
+ export default function Page() { return null }`
194
+ const result = lintRoute(source, 'src/routes/posts/index.tsx')
195
+ expect(diagIds(result)).not.toContain('pyreon/invalid-loader-export')
196
+ })
197
+
198
+ it('does NOT fire on `export const loader = makeLoader(...)` (factory call)', () => {
199
+ const source = `import { makeLoader } from './factory'
200
+ export const loader = makeLoader({ entity: 'post' })
201
+ export default function Page() { return null }`
202
+ const result = lintRoute(source, 'src/routes/posts/index.tsx')
203
+ expect(diagIds(result)).not.toContain('pyreon/invalid-loader-export')
204
+ })
205
+
206
+ it('does NOT fire outside routes/', () => {
207
+ const source = `export const loader = { data: 1 }`
208
+ const result = lintRoute(source, 'src/lib/helpers.ts')
209
+ expect(diagIds(result)).not.toContain('pyreon/invalid-loader-export')
210
+ })
211
+ })
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Tests for `pyreon/storage-signal-v-forwarding`.
3
+ *
4
+ * The rule catches the bug class fixed in PR #546: a wrapper callable
5
+ * that delegates `.direct` to a base signal but forgets to forward `_v`,
6
+ * causing the compiler-emitted `_bindText` fast path to read `undefined`
7
+ * and render empty text post-hydration.
8
+ */
9
+ import { getPreset } from '../config/presets'
10
+ import { allRules } from '../rules/index'
11
+ import { lintFile } from '../runner'
12
+
13
+ function lint(source: string, filePath = 'src/foo.ts') {
14
+ return lintFile(filePath, source, allRules, getPreset('recommended'))
15
+ }
16
+
17
+ function ids(result: ReturnType<typeof lint>): string[] {
18
+ return result.diagnostics.map((d) => d.ruleId)
19
+ }
20
+
21
+ const RULE = 'pyreon/storage-signal-v-forwarding'
22
+
23
+ describe('pyreon/storage-signal-v-forwarding', () => {
24
+ // ─── FIRES (bug shape) ─────────────────────────────────────────────────────
25
+
26
+ it('FIRES on `wrapper.direct = sig.direct` without _v forwarding', () => {
27
+ const source = `
28
+ function createWrapper(sig) {
29
+ const wrapper = () => sig()
30
+ wrapper.direct = sig.direct
31
+ wrapper.peek = () => sig.peek()
32
+ return wrapper
33
+ }
34
+ `
35
+ expect(ids(lint(source))).toContain(RULE)
36
+ })
37
+
38
+ it('FIRES on `wrapper.direct = (cb) => sig.direct(cb)` arrow delegate without _v', () => {
39
+ const source = `
40
+ function createWrapper(sig) {
41
+ const wrapper = () => sig()
42
+ wrapper.direct = (cb) => sig.direct(cb)
43
+ return wrapper
44
+ }
45
+ `
46
+ expect(ids(lint(source))).toContain(RULE)
47
+ })
48
+
49
+ it('FIRES on arrow with block body `(cb) => { return sig.direct(cb) }`', () => {
50
+ const source = `
51
+ function createWrapper(sig) {
52
+ const wrapper = () => sig()
53
+ wrapper.direct = (cb) => {
54
+ return sig.direct(cb)
55
+ }
56
+ return wrapper
57
+ }
58
+ `
59
+ expect(ids(lint(source))).toContain(RULE)
60
+ })
61
+
62
+ it('FIRES at the module-level (no enclosing function)', () => {
63
+ const source = `
64
+ const sig = signal(0)
65
+ const wrapper = (() => sig())
66
+ wrapper.direct = (cb) => sig.direct(cb)
67
+ `
68
+ expect(ids(lint(source))).toContain(RULE)
69
+ })
70
+
71
+ // ─── DOES NOT FIRE (acceptable patterns) ───────────────────────────────────
72
+
73
+ it('does NOT fire when _v is forwarded via Object.defineProperty', () => {
74
+ const source = `
75
+ function createWrapper(sig) {
76
+ const wrapper = () => sig()
77
+ wrapper.direct = (cb) => sig.direct(cb)
78
+ Object.defineProperty(wrapper, '_v', {
79
+ get: () => sig._v,
80
+ configurable: true,
81
+ })
82
+ return wrapper
83
+ }
84
+ `
85
+ expect(ids(lint(source))).not.toContain(RULE)
86
+ })
87
+
88
+ it('does NOT fire when _v is assigned directly (rare but valid)', () => {
89
+ const source = `
90
+ function createWrapper(sig) {
91
+ const wrapper = () => sig()
92
+ wrapper.direct = (cb) => sig.direct(cb)
93
+ wrapper._v = sig._v
94
+ return wrapper
95
+ }
96
+ `
97
+ expect(ids(lint(source))).not.toContain(RULE)
98
+ })
99
+
100
+ it('does NOT fire when Object.defineProperty appears BEFORE .direct assignment (order-independent)', () => {
101
+ const source = `
102
+ function createWrapper(sig) {
103
+ const wrapper = () => sig()
104
+ Object.defineProperty(wrapper, '_v', { get: () => sig._v })
105
+ wrapper.direct = (cb) => sig.direct(cb)
106
+ return wrapper
107
+ }
108
+ `
109
+ expect(ids(lint(source))).not.toContain(RULE)
110
+ })
111
+
112
+ it('does NOT fire on signal implementations themselves (`.direct = _direct` plain identifier, not a delegate)', () => {
113
+ // The base `signal()` factory assigns a shared `_direct` function
114
+ // to `signalFn.direct`. The RHS is a bare Identifier, NOT a
115
+ // MemberExpression — so the rule correctly skips it.
116
+ const source = `
117
+ function signal(initial) {
118
+ const fn = () => fn._v
119
+ fn._v = initial
120
+ fn.direct = _direct
121
+ return fn
122
+ }
123
+ `
124
+ expect(ids(lint(source))).not.toContain(RULE)
125
+ })
126
+
127
+ it('does NOT fire when only .direct method is read (not assigned)', () => {
128
+ const source = `
129
+ function foo(sig) {
130
+ const fn = () => sig.direct
131
+ return fn()
132
+ }
133
+ `
134
+ expect(ids(lint(source))).not.toContain(RULE)
135
+ })
136
+
137
+ it('does NOT fire on unrelated property assignments', () => {
138
+ const source = `
139
+ function createWrapper(sig) {
140
+ const wrapper = () => sig()
141
+ wrapper.peek = () => sig.peek()
142
+ wrapper.subscribe = (cb) => sig.subscribe(cb)
143
+ return wrapper
144
+ }
145
+ `
146
+ expect(ids(lint(source))).not.toContain(RULE)
147
+ })
148
+
149
+ // ─── SCOPE BEHAVIOR ────────────────────────────────────────────────────────
150
+
151
+ it('FIRES separately for each function scope (per-scope tracking)', () => {
152
+ const source = `
153
+ function makeA(sig) {
154
+ const a = () => sig()
155
+ a.direct = sig.direct
156
+ Object.defineProperty(a, '_v', { get: () => sig._v })
157
+ return a
158
+ }
159
+ function makeB(sig) {
160
+ const b = () => sig()
161
+ b.direct = sig.direct
162
+ return b
163
+ }
164
+ `
165
+ const diags = lint(source).diagnostics.filter((d) => d.ruleId === RULE)
166
+ // Only makeB should fire.
167
+ expect(diags).toHaveLength(1)
168
+ })
169
+
170
+ it('does NOT use _v from an unrelated identifier in the same scope', () => {
171
+ // `other._v = …` doesn't satisfy `wrapper`'s missing forwarding.
172
+ const source = `
173
+ function createWrapper(sig) {
174
+ const wrapper = () => sig()
175
+ const other = {}
176
+ wrapper.direct = sig.direct
177
+ Object.defineProperty(other, '_v', { get: () => 0 })
178
+ return wrapper
179
+ }
180
+ `
181
+ expect(ids(lint(source))).toContain(RULE)
182
+ })
183
+
184
+ // ─── ANCHOR: REAL STORAGE PATTERN ──────────────────────────────────────────
185
+
186
+ it('does NOT fire on the post-fix storage shape (canonical reference)', () => {
187
+ const source = `
188
+ function createStorageSignal(sig, key, defaultValue) {
189
+ const storageSig = (() => sig())
190
+ storageSig.peek = () => sig.peek()
191
+ storageSig.subscribe = (listener) => sig.subscribe(listener)
192
+ storageSig.direct = (updater) => sig.direct(updater)
193
+ storageSig.debug = () => sig.debug()
194
+ Object.defineProperty(storageSig, '_v', {
195
+ get: () => sig._v,
196
+ configurable: true,
197
+ })
198
+ storageSig.set = (value) => {
199
+ sig.set(value)
200
+ }
201
+ return storageSig
202
+ }
203
+ `
204
+ expect(ids(lint(source))).not.toContain(RULE)
205
+ })
206
+
207
+ it('FIRES on the pre-fix storage shape (bisect canary)', () => {
208
+ // Pre-PR-#546: same as above MINUS the Object.defineProperty(_v) block.
209
+ const source = `
210
+ function createStorageSignal(sig, key, defaultValue) {
211
+ const storageSig = (() => sig())
212
+ storageSig.peek = () => sig.peek()
213
+ storageSig.subscribe = (listener) => sig.subscribe(listener)
214
+ storageSig.direct = (updater) => sig.direct(updater)
215
+ storageSig.debug = () => sig.debug()
216
+ storageSig.set = (value) => {
217
+ sig.set(value)
218
+ }
219
+ return storageSig
220
+ }
221
+ `
222
+ expect(ids(lint(source))).toContain(RULE)
223
+ })
224
+ })
package/src/types.ts CHANGED
@@ -41,6 +41,7 @@ export type RuleCategory =
41
41
  | 'hooks'
42
42
  | 'accessibility'
43
43
  | 'router'
44
+ | 'ssg'
44
45
 
45
46
  /**
46
47
  * Declared type of an option slot. Minimal on purpose — sufficient for
@@ -14,7 +14,7 @@
14
14
  * Rules without `meta.schema` accept any options (no validation).
15
15
  */
16
16
 
17
- import type { OptionType, Rule, RuleOptions, RuleOptionsSchema } from '../types'
17
+ import type { OptionType, Rule, RuleOptions } from '../types'
18
18
 
19
19
  export interface ValidationResult {
20
20
  errors: string[]