@pyreon/lint 0.11.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/LICENSE +21 -0
- package/README.md +214 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +2955 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +260 -0
- package/lib/types/index.d.ts.map +1 -0
- package/package.json +56 -0
- package/src/cache.ts +51 -0
- package/src/cli.ts +199 -0
- package/src/config/ignore.ts +159 -0
- package/src/config/loader.ts +72 -0
- package/src/config/presets.ts +62 -0
- package/src/index.ts +40 -0
- package/src/lint.ts +226 -0
- package/src/reporter.ts +85 -0
- package/src/rules/accessibility/dialog-a11y.ts +32 -0
- package/src/rules/accessibility/overlay-a11y.ts +33 -0
- package/src/rules/accessibility/toast-a11y.ts +38 -0
- package/src/rules/architecture/dev-guard-warnings.ts +57 -0
- package/src/rules/architecture/no-circular-import.ts +59 -0
- package/src/rules/architecture/no-cross-layer-import.ts +75 -0
- package/src/rules/architecture/no-deep-import.ts +32 -0
- package/src/rules/architecture/no-error-without-prefix.ts +75 -0
- package/src/rules/form/no-submit-without-validation.ts +45 -0
- package/src/rules/form/no-unregistered-field.ts +45 -0
- package/src/rules/form/prefer-field-array.ts +41 -0
- package/src/rules/hooks/no-raw-addeventlistener.ts +28 -0
- package/src/rules/hooks/no-raw-localstorage.ts +35 -0
- package/src/rules/hooks/no-raw-setinterval.ts +41 -0
- package/src/rules/index.ts +208 -0
- package/src/rules/jsx/no-and-conditional.ts +32 -0
- package/src/rules/jsx/no-children-access.ts +44 -0
- package/src/rules/jsx/no-classname.ts +27 -0
- package/src/rules/jsx/no-htmlfor.ts +27 -0
- package/src/rules/jsx/no-index-as-by.ts +70 -0
- package/src/rules/jsx/no-map-in-jsx.ts +43 -0
- package/src/rules/jsx/no-missing-for-by.ts +27 -0
- package/src/rules/jsx/no-onchange.ts +46 -0
- package/src/rules/jsx/no-props-destructure.ts +64 -0
- package/src/rules/jsx/no-ternary-conditional.ts +32 -0
- package/src/rules/jsx/use-by-not-key.ts +33 -0
- package/src/rules/lifecycle/no-dom-in-setup.ts +53 -0
- package/src/rules/lifecycle/no-effect-in-mount.ts +36 -0
- package/src/rules/lifecycle/no-missing-cleanup.ts +80 -0
- package/src/rules/lifecycle/no-mount-in-effect.ts +35 -0
- package/src/rules/performance/no-eager-import.ts +28 -0
- package/src/rules/performance/no-effect-in-for.ts +41 -0
- package/src/rules/performance/no-large-for-without-by.ts +28 -0
- package/src/rules/performance/prefer-show-over-display.ts +47 -0
- package/src/rules/reactivity/no-bare-signal-in-jsx.ts +56 -0
- package/src/rules/reactivity/no-effect-assignment.ts +65 -0
- package/src/rules/reactivity/no-nested-effect.ts +33 -0
- package/src/rules/reactivity/no-peek-in-tracked.ts +35 -0
- package/src/rules/reactivity/no-signal-in-loop.ts +59 -0
- package/src/rules/reactivity/no-signal-leak.ts +58 -0
- package/src/rules/reactivity/no-unbatched-updates.ts +77 -0
- package/src/rules/reactivity/prefer-computed.ts +56 -0
- package/src/rules/router/index.ts +4 -0
- package/src/rules/router/no-href-navigation.ts +51 -0
- package/src/rules/router/no-imperative-navigate-in-render.ts +83 -0
- package/src/rules/router/no-missing-fallback.ts +87 -0
- package/src/rules/router/prefer-use-is-active.ts +45 -0
- package/src/rules/ssr/no-mismatch-risk.ts +47 -0
- package/src/rules/ssr/no-window-in-ssr.ts +76 -0
- package/src/rules/ssr/prefer-request-context.ts +56 -0
- package/src/rules/store/no-duplicate-store-id.ts +43 -0
- package/src/rules/store/no-mutate-store-state.ts +37 -0
- package/src/rules/store/no-store-outside-provider.ts +59 -0
- package/src/rules/styling/no-dynamic-styled.ts +60 -0
- package/src/rules/styling/no-inline-style-object.ts +30 -0
- package/src/rules/styling/no-theme-outside-provider.ts +45 -0
- package/src/rules/styling/prefer-cx.ts +44 -0
- package/src/runner.ts +170 -0
- package/src/tests/runner.test.ts +1043 -0
- package/src/types.ts +125 -0
- package/src/utils/ast.ts +192 -0
- package/src/utils/imports.ts +122 -0
- package/src/utils/index.ts +39 -0
- package/src/utils/source.ts +36 -0
- package/src/watcher.ts +118 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Rule, VisitorCallbacks } from "../../types"
|
|
2
|
+
import { getSpan, isCallTo, isSetCall } from "../../utils/ast"
|
|
3
|
+
|
|
4
|
+
interface ScopeInfo {
|
|
5
|
+
setCalls: Array<{ span: { start: number; end: number } }>
|
|
6
|
+
hasBatch: boolean
|
|
7
|
+
insideBatch: boolean
|
|
8
|
+
node: any
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const noUnbatchedUpdates: Rule = {
|
|
12
|
+
meta: {
|
|
13
|
+
id: "pyreon/no-unbatched-updates",
|
|
14
|
+
category: "reactivity",
|
|
15
|
+
description: "Warn when 3+ .set() calls occur in the same function without batch().",
|
|
16
|
+
severity: "warn",
|
|
17
|
+
fixable: false,
|
|
18
|
+
},
|
|
19
|
+
create(context) {
|
|
20
|
+
const scopeStack: ScopeInfo[] = []
|
|
21
|
+
let batchDepth = 0
|
|
22
|
+
|
|
23
|
+
function enterScope(node: any) {
|
|
24
|
+
scopeStack.push({ setCalls: [], hasBatch: false, insideBatch: batchDepth > 0, node })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function exitScope() {
|
|
28
|
+
const scope = scopeStack.pop()
|
|
29
|
+
if (!scope) return
|
|
30
|
+
if (!scope.hasBatch && !scope.insideBatch && scope.setCalls.length >= 3) {
|
|
31
|
+
context.report({
|
|
32
|
+
message: `${scope.setCalls.length} signal \`.set()\` calls without \`batch()\` — wrap in \`batch(() => { ... })\` to avoid unnecessary re-renders.`,
|
|
33
|
+
span: getSpan(scope.node),
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const callbacks: VisitorCallbacks = {
|
|
39
|
+
FunctionDeclaration(node: any) {
|
|
40
|
+
enterScope(node)
|
|
41
|
+
},
|
|
42
|
+
"FunctionDeclaration:exit"() {
|
|
43
|
+
exitScope()
|
|
44
|
+
},
|
|
45
|
+
FunctionExpression(node: any) {
|
|
46
|
+
enterScope(node)
|
|
47
|
+
},
|
|
48
|
+
"FunctionExpression:exit"() {
|
|
49
|
+
exitScope()
|
|
50
|
+
},
|
|
51
|
+
ArrowFunctionExpression(node: any) {
|
|
52
|
+
enterScope(node)
|
|
53
|
+
},
|
|
54
|
+
"ArrowFunctionExpression:exit"() {
|
|
55
|
+
exitScope()
|
|
56
|
+
},
|
|
57
|
+
CallExpression(node: any) {
|
|
58
|
+
const currentScope = scopeStack.length > 0 ? scopeStack[scopeStack.length - 1] : undefined
|
|
59
|
+
if (isCallTo(node, "batch")) {
|
|
60
|
+
batchDepth++
|
|
61
|
+
if (currentScope) {
|
|
62
|
+
currentScope.hasBatch = true
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (currentScope && isSetCall(node)) {
|
|
66
|
+
currentScope.setCalls.push({ span: getSpan(node) })
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
"CallExpression:exit"(node: any) {
|
|
70
|
+
if (isCallTo(node, "batch")) {
|
|
71
|
+
batchDepth--
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
return callbacks
|
|
76
|
+
},
|
|
77
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Rule, VisitorCallbacks } from "../../types"
|
|
2
|
+
import { getSpan, isCallTo, isSetCall } from "../../utils/ast"
|
|
3
|
+
|
|
4
|
+
export const preferComputed: Rule = {
|
|
5
|
+
meta: {
|
|
6
|
+
id: "pyreon/prefer-computed",
|
|
7
|
+
category: "reactivity",
|
|
8
|
+
description: "Suggest computed() when an effect only contains a single .set() call.",
|
|
9
|
+
severity: "warn",
|
|
10
|
+
fixable: false,
|
|
11
|
+
},
|
|
12
|
+
create(context) {
|
|
13
|
+
const callbacks: VisitorCallbacks = {
|
|
14
|
+
CallExpression(node: any) {
|
|
15
|
+
if (!isCallTo(node, "effect")) return
|
|
16
|
+
const args = node.arguments
|
|
17
|
+
if (!args || args.length === 0) return
|
|
18
|
+
|
|
19
|
+
const fn = args[0]
|
|
20
|
+
if (!fn) return
|
|
21
|
+
|
|
22
|
+
let body: any = null
|
|
23
|
+
if (fn.type === "ArrowFunctionExpression" || fn.type === "FunctionExpression") {
|
|
24
|
+
body = fn.body
|
|
25
|
+
}
|
|
26
|
+
if (!body) return
|
|
27
|
+
|
|
28
|
+
// Arrow with expression body: effect(() => x.set(y))
|
|
29
|
+
if (body.type === "CallExpression" && isSetCall(body)) {
|
|
30
|
+
context.report({
|
|
31
|
+
message:
|
|
32
|
+
"Effect contains a single `.set()` — consider using `computed()` instead for derived values.",
|
|
33
|
+
span: getSpan(node),
|
|
34
|
+
})
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Block body with single statement: effect(() => { x.set(y) })
|
|
39
|
+
if (body.type === "BlockStatement") {
|
|
40
|
+
const stmts = body.body
|
|
41
|
+
if (stmts && stmts.length === 1) {
|
|
42
|
+
const stmt = stmts[0]
|
|
43
|
+
if (stmt.type === "ExpressionStatement" && isSetCall(stmt.expression)) {
|
|
44
|
+
context.report({
|
|
45
|
+
message:
|
|
46
|
+
"Effect contains a single `.set()` — consider using `computed()` instead for derived values.",
|
|
47
|
+
span: getSpan(node),
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
return callbacks
|
|
55
|
+
},
|
|
56
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Rule, VisitorCallbacks } from "../../types"
|
|
2
|
+
import { getJSXAttribute, getSpan } from "../../utils/ast"
|
|
3
|
+
import { extractImportInfo } from "../../utils/imports"
|
|
4
|
+
|
|
5
|
+
const EXTERNAL_PREFIXES = ["http://", "https://", "mailto:", "tel:"]
|
|
6
|
+
|
|
7
|
+
export const noHrefNavigation: Rule = {
|
|
8
|
+
meta: {
|
|
9
|
+
id: "pyreon/no-href-navigation",
|
|
10
|
+
category: "router",
|
|
11
|
+
description:
|
|
12
|
+
"Warn when `<a href>` is used in files that import @pyreon/router — use `<Link>` instead.",
|
|
13
|
+
severity: "warn",
|
|
14
|
+
fixable: false,
|
|
15
|
+
},
|
|
16
|
+
create(context) {
|
|
17
|
+
let importsRouter = false
|
|
18
|
+
|
|
19
|
+
const callbacks: VisitorCallbacks = {
|
|
20
|
+
ImportDeclaration(node: any) {
|
|
21
|
+
const info = extractImportInfo(node)
|
|
22
|
+
if (info && info.source === "@pyreon/router") {
|
|
23
|
+
importsRouter = true
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
JSXOpeningElement(node: any) {
|
|
27
|
+
if (!importsRouter) return
|
|
28
|
+
const name = node.name
|
|
29
|
+
if (!name || name.type !== "JSXIdentifier" || name.name !== "a") return
|
|
30
|
+
|
|
31
|
+
const hrefAttr = getJSXAttribute(node, "href")
|
|
32
|
+
if (!hrefAttr) return
|
|
33
|
+
|
|
34
|
+
// Get the href value
|
|
35
|
+
const value = hrefAttr.value
|
|
36
|
+
if (value?.type === "Literal" && typeof value.value === "string") {
|
|
37
|
+
const href: string = value.value
|
|
38
|
+
// Skip external URLs and anchor links
|
|
39
|
+
if (href.startsWith("#") || EXTERNAL_PREFIXES.some((p) => href.startsWith(p))) return
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
context.report({
|
|
43
|
+
message:
|
|
44
|
+
"`<a href>` in a router file — use `<Link>` or `<RouterLink>` for client-side navigation.",
|
|
45
|
+
span: getSpan(node),
|
|
46
|
+
})
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
return callbacks
|
|
50
|
+
},
|
|
51
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { Rule, VisitorCallbacks } from "../../types"
|
|
2
|
+
import { getSpan, isCallTo, isMemberCallTo } from "../../utils/ast"
|
|
3
|
+
|
|
4
|
+
export const noImperativeNavigateInRender: Rule = {
|
|
5
|
+
meta: {
|
|
6
|
+
id: "pyreon/no-imperative-navigate-in-render",
|
|
7
|
+
category: "router",
|
|
8
|
+
description:
|
|
9
|
+
"Error when navigate() or router.push() is called at the top level of a component — causes infinite render loops.",
|
|
10
|
+
severity: "error",
|
|
11
|
+
fixable: false,
|
|
12
|
+
},
|
|
13
|
+
create(context) {
|
|
14
|
+
// Track depth of component functions and safe callback wrappers
|
|
15
|
+
// We detect components via VariableDeclarator with PascalCase name + ArrowFunctionExpression init,
|
|
16
|
+
// or FunctionDeclaration with PascalCase name.
|
|
17
|
+
// "Safe" = onMount/effect/onUnmount callbacks or JSX event handlers.
|
|
18
|
+
let componentBodyDepth = 0
|
|
19
|
+
let safeDepth = 0
|
|
20
|
+
|
|
21
|
+
const callbacks: VisitorCallbacks = {
|
|
22
|
+
FunctionDeclaration(node: any) {
|
|
23
|
+
const name: string = node.id?.name ?? ""
|
|
24
|
+
if (/^[A-Z]/.test(name)) {
|
|
25
|
+
componentBodyDepth++
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"FunctionDeclaration:exit"(node: any) {
|
|
29
|
+
const name: string = node.id?.name ?? ""
|
|
30
|
+
if (/^[A-Z]/.test(name)) {
|
|
31
|
+
componentBodyDepth--
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
// For arrow functions, we use VariableDeclarator to detect component assignment
|
|
35
|
+
VariableDeclarator(node: any) {
|
|
36
|
+
const name: string = node.id?.name ?? ""
|
|
37
|
+
if (/^[A-Z]/.test(name) && node.init?.type === "ArrowFunctionExpression") {
|
|
38
|
+
componentBodyDepth++
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"VariableDeclarator:exit"(node: any) {
|
|
42
|
+
const name: string = node.id?.name ?? ""
|
|
43
|
+
if (/^[A-Z]/.test(name) && node.init?.type === "ArrowFunctionExpression") {
|
|
44
|
+
componentBodyDepth--
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
// Track safe callback boundaries: onMount(() => ...), effect(() => ...), etc.
|
|
48
|
+
CallExpression(node: any) {
|
|
49
|
+
if (componentBodyDepth <= 0) return
|
|
50
|
+
|
|
51
|
+
// Check if this is a safe wrapper entering
|
|
52
|
+
if (isSafeWrapperCall(node)) {
|
|
53
|
+
safeDepth++
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Only report if we're in a component body and NOT inside a safe callback
|
|
57
|
+
if (safeDepth > 0) return
|
|
58
|
+
|
|
59
|
+
if (isCallTo(node, "navigate") || isMemberCallTo(node, "router", "push")) {
|
|
60
|
+
context.report({
|
|
61
|
+
message:
|
|
62
|
+
"Imperative navigation at the top level of a component — this runs on every render and causes infinite loops. Move inside `onMount`, `effect`, or an event handler.",
|
|
63
|
+
span: getSpan(node),
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"CallExpression:exit"(node: any) {
|
|
68
|
+
if (componentBodyDepth <= 0) return
|
|
69
|
+
if (isSafeWrapperCall(node)) {
|
|
70
|
+
safeDepth--
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
return callbacks
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isSafeWrapperCall(node: any): boolean {
|
|
79
|
+
const callee = node.callee
|
|
80
|
+
if (!callee || callee.type !== "Identifier") return false
|
|
81
|
+
const name: string = callee.name
|
|
82
|
+
return name === "onMount" || name === "effect" || name === "onUnmount"
|
|
83
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { Rule, VisitorCallbacks } from "../../types"
|
|
2
|
+
import { getSpan } from "../../utils/ast"
|
|
3
|
+
import { extractImportInfo } from "../../utils/imports"
|
|
4
|
+
|
|
5
|
+
function isCatchAllPath(value: string): boolean {
|
|
6
|
+
return value === "*" || value.endsWith("*")
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function getPathValue(prop: any): string | null {
|
|
10
|
+
const key = prop.key
|
|
11
|
+
if (!key) return null
|
|
12
|
+
const keyName = key.type === "Identifier" ? key.name : null
|
|
13
|
+
if (keyName !== "path") return null
|
|
14
|
+
const val = prop.value
|
|
15
|
+
if (val?.type === "Literal" && typeof val.value === "string") {
|
|
16
|
+
return val.value
|
|
17
|
+
}
|
|
18
|
+
return null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function hasPathProperty(obj: any): boolean {
|
|
22
|
+
if (!obj || obj.type !== "ObjectExpression") return false
|
|
23
|
+
for (const prop of obj.properties ?? []) {
|
|
24
|
+
if (prop.type !== "Property") continue
|
|
25
|
+
if (getPathValue(prop) !== null) return true
|
|
26
|
+
}
|
|
27
|
+
return false
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function hasCatchAllRoute(elements: any[]): boolean {
|
|
31
|
+
for (const elem of elements) {
|
|
32
|
+
if (!elem || elem.type !== "ObjectExpression") continue
|
|
33
|
+
for (const prop of elem.properties ?? []) {
|
|
34
|
+
if (prop.type !== "Property") continue
|
|
35
|
+
const pathVal = getPathValue(prop)
|
|
36
|
+
if (pathVal !== null && isCatchAllPath(pathVal)) return true
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const noMissingFallback: Rule = {
|
|
43
|
+
meta: {
|
|
44
|
+
id: "pyreon/no-missing-fallback",
|
|
45
|
+
category: "router",
|
|
46
|
+
description:
|
|
47
|
+
'Warn when route config has no catch-all route (`path: "*"` or `path: "/:rest*"`).',
|
|
48
|
+
severity: "warn",
|
|
49
|
+
fixable: false,
|
|
50
|
+
},
|
|
51
|
+
create(context) {
|
|
52
|
+
let importsRouter = false
|
|
53
|
+
let routeArraySpan: { start: number; end: number } | null = null
|
|
54
|
+
let foundCatchAll = false
|
|
55
|
+
|
|
56
|
+
const callbacks: VisitorCallbacks = {
|
|
57
|
+
ImportDeclaration(node: any) {
|
|
58
|
+
const info = extractImportInfo(node)
|
|
59
|
+
if (info && info.source === "@pyreon/router") {
|
|
60
|
+
importsRouter = true
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
ArrayExpression(node: any) {
|
|
64
|
+
if (!importsRouter) return
|
|
65
|
+
const elements = node.elements ?? []
|
|
66
|
+
const isRouteArray = elements.some((e: any) => hasPathProperty(e))
|
|
67
|
+
if (!isRouteArray) return
|
|
68
|
+
|
|
69
|
+
if (!routeArraySpan) {
|
|
70
|
+
routeArraySpan = getSpan(node)
|
|
71
|
+
}
|
|
72
|
+
if (hasCatchAllRoute(elements)) {
|
|
73
|
+
foundCatchAll = true
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
"Program:exit"() {
|
|
77
|
+
if (!importsRouter || !routeArraySpan || foundCatchAll) return
|
|
78
|
+
context.report({
|
|
79
|
+
message:
|
|
80
|
+
'Route config has no catch-all route — add a `{ path: "*", component: NotFound }` for unmatched URLs.',
|
|
81
|
+
span: routeArraySpan,
|
|
82
|
+
})
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
return callbacks
|
|
86
|
+
},
|
|
87
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Rule, VisitorCallbacks } from "../../types"
|
|
2
|
+
import { getSpan } from "../../utils/ast"
|
|
3
|
+
|
|
4
|
+
export const preferUseIsActive: Rule = {
|
|
5
|
+
meta: {
|
|
6
|
+
id: "pyreon/prefer-use-is-active",
|
|
7
|
+
category: "router",
|
|
8
|
+
description:
|
|
9
|
+
'Suggest useIsActive() instead of `location.pathname === "/foo"` or `route.path === "/foo"` patterns.',
|
|
10
|
+
severity: "info",
|
|
11
|
+
fixable: false,
|
|
12
|
+
},
|
|
13
|
+
create(context) {
|
|
14
|
+
const callbacks: VisitorCallbacks = {
|
|
15
|
+
BinaryExpression(node: any) {
|
|
16
|
+
if (node.operator !== "===" && node.operator !== "==") return
|
|
17
|
+
|
|
18
|
+
// Check both sides for location.pathname or route.path
|
|
19
|
+
if (isPathComparison(node.left) || isPathComparison(node.right)) {
|
|
20
|
+
context.report({
|
|
21
|
+
message:
|
|
22
|
+
"Manual path comparison — use `useIsActive()` for reactive route matching with segment-aware prefix matching.",
|
|
23
|
+
span: getSpan(node),
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
return callbacks
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isPathComparison(node: any): boolean {
|
|
33
|
+
if (!node || node.type !== "MemberExpression") return false
|
|
34
|
+
const obj = node.object
|
|
35
|
+
const prop = node.property
|
|
36
|
+
if (!obj || !prop || prop.type !== "Identifier") return false
|
|
37
|
+
|
|
38
|
+
// location.pathname
|
|
39
|
+
if (obj.type === "Identifier" && obj.name === "location" && prop.name === "pathname") return true
|
|
40
|
+
|
|
41
|
+
// route.path
|
|
42
|
+
if (obj.type === "Identifier" && obj.name === "route" && prop.name === "path") return true
|
|
43
|
+
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Rule, VisitorCallbacks } from "../../types"
|
|
2
|
+
import { getSpan, isMemberCallTo } from "../../utils/ast"
|
|
3
|
+
|
|
4
|
+
export const noMismatchRisk: Rule = {
|
|
5
|
+
meta: {
|
|
6
|
+
id: "pyreon/no-mismatch-risk",
|
|
7
|
+
category: "ssr",
|
|
8
|
+
description:
|
|
9
|
+
"Warn about non-deterministic calls (Date.now, Math.random, crypto.randomUUID) in JSX context that cause hydration mismatches.",
|
|
10
|
+
severity: "warn",
|
|
11
|
+
fixable: false,
|
|
12
|
+
},
|
|
13
|
+
create(context) {
|
|
14
|
+
let jsxDepth = 0
|
|
15
|
+
const callbacks: VisitorCallbacks = {
|
|
16
|
+
JSXElement() {
|
|
17
|
+
jsxDepth++
|
|
18
|
+
},
|
|
19
|
+
"JSXElement:exit"() {
|
|
20
|
+
jsxDepth--
|
|
21
|
+
},
|
|
22
|
+
JSXFragment() {
|
|
23
|
+
jsxDepth++
|
|
24
|
+
},
|
|
25
|
+
"JSXFragment:exit"() {
|
|
26
|
+
jsxDepth--
|
|
27
|
+
},
|
|
28
|
+
CallExpression(node: any) {
|
|
29
|
+
if (jsxDepth === 0) return
|
|
30
|
+
|
|
31
|
+
if (
|
|
32
|
+
isMemberCallTo(node, "Date", "now") ||
|
|
33
|
+
isMemberCallTo(node, "Math", "random") ||
|
|
34
|
+
isMemberCallTo(node, "crypto", "randomUUID")
|
|
35
|
+
) {
|
|
36
|
+
const callee = node.callee
|
|
37
|
+
const name = `${callee.object.name}.${callee.property.name}`
|
|
38
|
+
context.report({
|
|
39
|
+
message: `\`${name}()\` in JSX context — this produces different values on server and client, causing hydration mismatches.`,
|
|
40
|
+
span: getSpan(node),
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
return callbacks
|
|
46
|
+
},
|
|
47
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Rule, VisitorCallbacks } from "../../types"
|
|
2
|
+
import { getSpan, isCallTo } from "../../utils/ast"
|
|
3
|
+
import { BROWSER_GLOBALS } from "../../utils/imports"
|
|
4
|
+
|
|
5
|
+
export const noWindowInSsr: Rule = {
|
|
6
|
+
meta: {
|
|
7
|
+
id: "pyreon/no-window-in-ssr",
|
|
8
|
+
category: "ssr",
|
|
9
|
+
description: "Disallow browser globals outside onMount/effect/typeof guards — they break SSR.",
|
|
10
|
+
severity: "error",
|
|
11
|
+
fixable: false,
|
|
12
|
+
},
|
|
13
|
+
create(context) {
|
|
14
|
+
let safeDepth = 0
|
|
15
|
+
let typeofGuardDepth = 0
|
|
16
|
+
|
|
17
|
+
const callbacks: VisitorCallbacks = {
|
|
18
|
+
CallExpression(node: any) {
|
|
19
|
+
if (isCallTo(node, "onMount") || isCallTo(node, "effect")) {
|
|
20
|
+
safeDepth++
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"CallExpression:exit"(node: any) {
|
|
24
|
+
if (isCallTo(node, "onMount") || isCallTo(node, "effect")) {
|
|
25
|
+
safeDepth--
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
IfStatement(node: any) {
|
|
29
|
+
// typeof window !== "undefined"
|
|
30
|
+
const test = node.test
|
|
31
|
+
if (
|
|
32
|
+
test?.type === "BinaryExpression" &&
|
|
33
|
+
test.left?.type === "UnaryExpression" &&
|
|
34
|
+
test.left.operator === "typeof"
|
|
35
|
+
) {
|
|
36
|
+
typeofGuardDepth++
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"IfStatement:exit"(node: any) {
|
|
40
|
+
const test = node.test
|
|
41
|
+
if (
|
|
42
|
+
test?.type === "BinaryExpression" &&
|
|
43
|
+
test.left?.type === "UnaryExpression" &&
|
|
44
|
+
test.left.operator === "typeof"
|
|
45
|
+
) {
|
|
46
|
+
typeofGuardDepth--
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
Identifier(node: any, parent: any) {
|
|
50
|
+
if (safeDepth > 0 || typeofGuardDepth > 0) return
|
|
51
|
+
if (!BROWSER_GLOBALS.has(node.name)) return
|
|
52
|
+
|
|
53
|
+
// Skip typeof expressions: typeof window
|
|
54
|
+
if (parent?.type === "UnaryExpression" && parent.operator === "typeof") return
|
|
55
|
+
|
|
56
|
+
// Skip import specifiers
|
|
57
|
+
if (
|
|
58
|
+
parent?.type === "ImportSpecifier" ||
|
|
59
|
+
parent?.type === "ImportDefaultSpecifier" ||
|
|
60
|
+
parent?.type === "ImportNamespaceSpecifier"
|
|
61
|
+
)
|
|
62
|
+
return
|
|
63
|
+
|
|
64
|
+
// Skip property access on member expressions (only flag when used as the object)
|
|
65
|
+
if (parent?.type === "MemberExpression" && parent.property === node && !parent.computed)
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
context.report({
|
|
69
|
+
message: `Browser global \`${node.name}\` used outside \`onMount\`/\`effect\`/typeof guard — this will fail during SSR. Wrap in \`onMount(() => { ... })\`.`,
|
|
70
|
+
span: getSpan(node),
|
|
71
|
+
})
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
return callbacks
|
|
75
|
+
},
|
|
76
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Rule, VisitorCallbacks } from "../../types"
|
|
2
|
+
import { getSpan, isCallTo } from "../../utils/ast"
|
|
3
|
+
|
|
4
|
+
export const preferRequestContext: Rule = {
|
|
5
|
+
meta: {
|
|
6
|
+
id: "pyreon/prefer-request-context",
|
|
7
|
+
category: "ssr",
|
|
8
|
+
description:
|
|
9
|
+
"Warn about module-level signal()/createStore() in server files — use request context instead.",
|
|
10
|
+
severity: "warn",
|
|
11
|
+
fixable: false,
|
|
12
|
+
},
|
|
13
|
+
create(context) {
|
|
14
|
+
const filePath = context.getFilePath()
|
|
15
|
+
const isServerFile =
|
|
16
|
+
filePath.includes("server") ||
|
|
17
|
+
filePath.includes(".server.") ||
|
|
18
|
+
filePath.endsWith("server.ts") ||
|
|
19
|
+
filePath.endsWith("server.tsx")
|
|
20
|
+
|
|
21
|
+
if (!isServerFile) return {}
|
|
22
|
+
|
|
23
|
+
let functionDepth = 0
|
|
24
|
+
const callbacks: VisitorCallbacks = {
|
|
25
|
+
FunctionDeclaration() {
|
|
26
|
+
functionDepth++
|
|
27
|
+
},
|
|
28
|
+
"FunctionDeclaration:exit"() {
|
|
29
|
+
functionDepth--
|
|
30
|
+
},
|
|
31
|
+
FunctionExpression() {
|
|
32
|
+
functionDepth++
|
|
33
|
+
},
|
|
34
|
+
"FunctionExpression:exit"() {
|
|
35
|
+
functionDepth--
|
|
36
|
+
},
|
|
37
|
+
ArrowFunctionExpression() {
|
|
38
|
+
functionDepth++
|
|
39
|
+
},
|
|
40
|
+
"ArrowFunctionExpression:exit"() {
|
|
41
|
+
functionDepth--
|
|
42
|
+
},
|
|
43
|
+
CallExpression(node: any) {
|
|
44
|
+
if (functionDepth > 0) return // only flag module-level calls
|
|
45
|
+
if (isCallTo(node, "signal") || isCallTo(node, "createStore")) {
|
|
46
|
+
const name = node.callee.name
|
|
47
|
+
context.report({
|
|
48
|
+
message: `Module-level \`${name}()\` in a server file — this state is shared across all requests. Use \`runWithRequestContext()\` for per-request isolation.`,
|
|
49
|
+
span: getSpan(node),
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
return callbacks
|
|
55
|
+
},
|
|
56
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Rule, VisitorCallbacks } from "../../types"
|
|
2
|
+
import { getSpan, isCallTo } from "../../utils/ast"
|
|
3
|
+
|
|
4
|
+
export const noDuplicateStoreId: Rule = {
|
|
5
|
+
meta: {
|
|
6
|
+
id: "pyreon/no-duplicate-store-id",
|
|
7
|
+
category: "store",
|
|
8
|
+
description: "Disallow duplicate defineStore() IDs in the same file.",
|
|
9
|
+
severity: "error",
|
|
10
|
+
fixable: false,
|
|
11
|
+
},
|
|
12
|
+
create(context) {
|
|
13
|
+
const storeIds = new Map<string, { start: number; end: number }>()
|
|
14
|
+
|
|
15
|
+
const callbacks: VisitorCallbacks = {
|
|
16
|
+
CallExpression(node: any) {
|
|
17
|
+
if (!isCallTo(node, "defineStore")) return
|
|
18
|
+
const args = node.arguments
|
|
19
|
+
if (!args || args.length === 0) return
|
|
20
|
+
|
|
21
|
+
const firstArg = args[0]
|
|
22
|
+
if (!firstArg) return
|
|
23
|
+
|
|
24
|
+
let id: string | null = null
|
|
25
|
+
if (firstArg.type === "Literal" || firstArg.type === "StringLiteral") {
|
|
26
|
+
id = firstArg.value as string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (typeof id !== "string") return
|
|
30
|
+
|
|
31
|
+
if (storeIds.has(id)) {
|
|
32
|
+
context.report({
|
|
33
|
+
message: `Duplicate store ID \`"${id}"\` — each \`defineStore()\` must have a unique ID.`,
|
|
34
|
+
span: getSpan(node),
|
|
35
|
+
})
|
|
36
|
+
} else {
|
|
37
|
+
storeIds.set(id, getSpan(node))
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
return callbacks
|
|
42
|
+
},
|
|
43
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Rule, VisitorCallbacks } from "../../types"
|
|
2
|
+
import { getSpan } from "../../utils/ast"
|
|
3
|
+
|
|
4
|
+
export const noMutateStoreState: Rule = {
|
|
5
|
+
meta: {
|
|
6
|
+
id: "pyreon/no-mutate-store-state",
|
|
7
|
+
category: "store",
|
|
8
|
+
description: "Warn when directly calling .set() on store signals — use store actions instead.",
|
|
9
|
+
severity: "warn",
|
|
10
|
+
fixable: false,
|
|
11
|
+
},
|
|
12
|
+
create(context) {
|
|
13
|
+
const callbacks: VisitorCallbacks = {
|
|
14
|
+
CallExpression(node: any) {
|
|
15
|
+
const callee = node.callee
|
|
16
|
+
if (!callee || callee.type !== "MemberExpression") return
|
|
17
|
+
if (callee.property?.type !== "Identifier" || callee.property.name !== "set") return
|
|
18
|
+
|
|
19
|
+
// Check for store.signal.set() pattern — member.member.set()
|
|
20
|
+
const obj = callee.object
|
|
21
|
+
if (!obj || obj.type !== "MemberExpression") return
|
|
22
|
+
const outerObj = obj.object
|
|
23
|
+
if (!outerObj || outerObj.type !== "Identifier") return
|
|
24
|
+
|
|
25
|
+
const name: string = outerObj.name
|
|
26
|
+
// Heuristic: if the outer object name contains "store" (case-insensitive)
|
|
27
|
+
if (name.toLowerCase().includes("store")) {
|
|
28
|
+
context.report({
|
|
29
|
+
message: `Direct \`.set()\` on store state \`${name}\` — use store actions to mutate state for better traceability.`,
|
|
30
|
+
span: getSpan(node),
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
return callbacks
|
|
36
|
+
},
|
|
37
|
+
}
|