@pyreon/lint 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.
@@ -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 (12)
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,3 @@
1
+ export { invalidLoaderExport } from './invalid-loader-export'
2
+ export { missingGetStaticPaths } from './missing-get-static-paths'
3
+ export { revalidateNotPureLiteral } from './revalidate-not-pure-literal'
@@ -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 cached = VALIDATION_CACHE.get(cacheKey)
165
- if (!cached) {
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
- cached = { ok: errors.length === 0, diagnostics: configDiags }
175
- VALIDATION_CACHE.set(cacheKey, cached)
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 (cached.diagnostics.length > 0) {
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 cached.diagnostics) {
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 cached.diagnostics) {
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 (!cached.ok) continue
202
+ if (!validation.ok) continue
203
203
 
204
204
  const ctx = createRuleContext(
205
205
  rule,
@@ -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 62 rules', () => {
57
- expect(allRules.length).toBe(62)
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(12)
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(62)
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, ...(loaded?.rules ?? {}) },
3026
+ rules: { ...base.rules, ...loaded?.rules },
3024
3027
  }
3025
3028
 
3026
3029
  // In an exempt path — rule silent.