@pyreon/lint 0.11.5 → 0.11.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -91
- package/lib/analysis/cli.js.html +1 -1
- package/lib/analysis/index.js.html +1 -1
- package/lib/cli.js +214 -1
- package/lib/cli.js.map +1 -1
- package/lib/index.js +207 -1
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +30 -5
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +15 -15
- package/src/cache.ts +1 -1
- package/src/cli.ts +38 -28
- package/src/config/ignore.ts +23 -23
- package/src/config/loader.ts +8 -8
- package/src/config/presets.ts +11 -11
- package/src/index.ts +14 -12
- package/src/lint.ts +19 -19
- package/src/lsp/index.ts +225 -0
- package/src/reporter.ts +17 -17
- package/src/rules/accessibility/dialog-a11y.ts +10 -10
- package/src/rules/accessibility/overlay-a11y.ts +11 -11
- package/src/rules/accessibility/toast-a11y.ts +11 -11
- package/src/rules/architecture/dev-guard-warnings.ts +19 -19
- package/src/rules/architecture/no-circular-import.ts +16 -16
- package/src/rules/architecture/no-cross-layer-import.ts +35 -35
- package/src/rules/architecture/no-deep-import.ts +7 -7
- package/src/rules/architecture/no-error-without-prefix.ts +20 -20
- package/src/rules/form/no-submit-without-validation.ts +13 -13
- package/src/rules/form/no-unregistered-field.ts +12 -12
- package/src/rules/form/prefer-field-array.ts +11 -11
- package/src/rules/hooks/no-raw-addeventlistener.ts +9 -9
- package/src/rules/hooks/no-raw-localstorage.ts +11 -11
- package/src/rules/hooks/no-raw-setinterval.ts +11 -11
- package/src/rules/index.ts +60 -57
- package/src/rules/jsx/no-and-conditional.ts +8 -8
- package/src/rules/jsx/no-children-access.ts +12 -12
- package/src/rules/jsx/no-classname.ts +10 -10
- package/src/rules/jsx/no-htmlfor.ts +10 -10
- package/src/rules/jsx/no-index-as-by.ts +17 -17
- package/src/rules/jsx/no-map-in-jsx.ts +9 -9
- package/src/rules/jsx/no-missing-for-by.ts +9 -9
- package/src/rules/jsx/no-onchange.ts +12 -12
- package/src/rules/jsx/no-props-destructure.ts +11 -11
- package/src/rules/jsx/no-ternary-conditional.ts +8 -8
- package/src/rules/jsx/use-by-not-key.ts +12 -12
- package/src/rules/lifecycle/no-dom-in-setup.ts +18 -18
- package/src/rules/lifecycle/no-effect-in-mount.ts +11 -11
- package/src/rules/lifecycle/no-missing-cleanup.ts +19 -19
- package/src/rules/lifecycle/no-mount-in-effect.ts +11 -11
- package/src/rules/performance/no-eager-import.ts +7 -7
- package/src/rules/performance/no-effect-in-for.ts +10 -10
- package/src/rules/performance/no-large-for-without-by.ts +9 -9
- package/src/rules/performance/prefer-show-over-display.ts +16 -16
- package/src/rules/reactivity/no-bare-signal-in-jsx.ts +10 -10
- package/src/rules/reactivity/no-context-destructure.ts +45 -0
- package/src/rules/reactivity/no-effect-assignment.ts +16 -16
- package/src/rules/reactivity/no-nested-effect.ts +10 -10
- package/src/rules/reactivity/no-peek-in-tracked.ts +10 -10
- package/src/rules/reactivity/no-signal-in-loop.ts +13 -13
- package/src/rules/reactivity/no-signal-leak.ts +9 -9
- package/src/rules/reactivity/no-unbatched-updates.ts +12 -12
- package/src/rules/reactivity/prefer-computed.ts +13 -13
- package/src/rules/router/index.ts +4 -4
- package/src/rules/router/no-href-navigation.ts +14 -14
- package/src/rules/router/no-imperative-navigate-in-render.ts +19 -19
- package/src/rules/router/no-missing-fallback.ts +16 -16
- package/src/rules/router/prefer-use-is-active.ts +11 -11
- package/src/rules/ssr/no-mismatch-risk.ts +11 -11
- package/src/rules/ssr/no-window-in-ssr.ts +22 -22
- package/src/rules/ssr/prefer-request-context.ts +14 -14
- package/src/rules/store/no-duplicate-store-id.ts +9 -9
- package/src/rules/store/no-mutate-store-state.ts +11 -11
- package/src/rules/store/no-store-outside-provider.ts +15 -15
- package/src/rules/styling/no-dynamic-styled.ts +13 -13
- package/src/rules/styling/no-inline-style-object.ts +10 -10
- package/src/rules/styling/no-theme-outside-provider.ts +11 -11
- package/src/rules/styling/prefer-cx.ts +12 -12
- package/src/runner.ts +13 -13
- package/src/tests/lsp.test.ts +88 -0
- package/src/tests/runner.test.ts +325 -325
- package/src/types.ts +15 -15
- package/src/utils/ast.ts +50 -50
- package/src/utils/imports.ts +53 -53
- package/src/utils/index.ts +5 -5
- package/src/utils/source.ts +2 -2
- package/src/watcher.ts +19 -19
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
import type { Rule, VisitorCallbacks } from
|
|
2
|
-
import { getSpan, isCallTo, isSetCall } from
|
|
1
|
+
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
|
+
import { getSpan, isCallTo, isSetCall } from '../../utils/ast'
|
|
3
3
|
|
|
4
4
|
export const preferComputed: Rule = {
|
|
5
5
|
meta: {
|
|
6
|
-
id:
|
|
7
|
-
category:
|
|
8
|
-
description:
|
|
9
|
-
severity:
|
|
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
10
|
fixable: false,
|
|
11
11
|
},
|
|
12
12
|
create(context) {
|
|
13
13
|
const callbacks: VisitorCallbacks = {
|
|
14
14
|
CallExpression(node: any) {
|
|
15
|
-
if (!isCallTo(node,
|
|
15
|
+
if (!isCallTo(node, 'effect')) return
|
|
16
16
|
const args = node.arguments
|
|
17
17
|
if (!args || args.length === 0) return
|
|
18
18
|
|
|
@@ -20,30 +20,30 @@ export const preferComputed: Rule = {
|
|
|
20
20
|
if (!fn) return
|
|
21
21
|
|
|
22
22
|
let body: any = null
|
|
23
|
-
if (fn.type ===
|
|
23
|
+
if (fn.type === 'ArrowFunctionExpression' || fn.type === 'FunctionExpression') {
|
|
24
24
|
body = fn.body
|
|
25
25
|
}
|
|
26
26
|
if (!body) return
|
|
27
27
|
|
|
28
28
|
// Arrow with expression body: effect(() => x.set(y))
|
|
29
|
-
if (body.type ===
|
|
29
|
+
if (body.type === 'CallExpression' && isSetCall(body)) {
|
|
30
30
|
context.report({
|
|
31
31
|
message:
|
|
32
|
-
|
|
32
|
+
'Effect contains a single `.set()` — consider using `computed()` instead for derived values.',
|
|
33
33
|
span: getSpan(node),
|
|
34
34
|
})
|
|
35
35
|
return
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
// Block body with single statement: effect(() => { x.set(y) })
|
|
39
|
-
if (body.type ===
|
|
39
|
+
if (body.type === 'BlockStatement') {
|
|
40
40
|
const stmts = body.body
|
|
41
41
|
if (stmts && stmts.length === 1) {
|
|
42
42
|
const stmt = stmts[0]
|
|
43
|
-
if (stmt.type ===
|
|
43
|
+
if (stmt.type === 'ExpressionStatement' && isSetCall(stmt.expression)) {
|
|
44
44
|
context.report({
|
|
45
45
|
message:
|
|
46
|
-
|
|
46
|
+
'Effect contains a single `.set()` — consider using `computed()` instead for derived values.',
|
|
47
47
|
span: getSpan(node),
|
|
48
48
|
})
|
|
49
49
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { noHrefNavigation } from
|
|
2
|
-
export { noImperativeNavigateInRender } from
|
|
3
|
-
export { noMissingFallback } from
|
|
4
|
-
export { preferUseIsActive } from
|
|
1
|
+
export { noHrefNavigation } from './no-href-navigation'
|
|
2
|
+
export { noImperativeNavigateInRender } from './no-imperative-navigate-in-render'
|
|
3
|
+
export { noMissingFallback } from './no-missing-fallback'
|
|
4
|
+
export { preferUseIsActive } from './prefer-use-is-active'
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import type { Rule, VisitorCallbacks } from
|
|
2
|
-
import { getJSXAttribute, getSpan } from
|
|
3
|
-
import { extractImportInfo } from
|
|
1
|
+
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
|
+
import { getJSXAttribute, getSpan } from '../../utils/ast'
|
|
3
|
+
import { extractImportInfo } from '../../utils/imports'
|
|
4
4
|
|
|
5
|
-
const EXTERNAL_PREFIXES = [
|
|
5
|
+
const EXTERNAL_PREFIXES = ['http://', 'https://', 'mailto:', 'tel:']
|
|
6
6
|
|
|
7
7
|
export const noHrefNavigation: Rule = {
|
|
8
8
|
meta: {
|
|
9
|
-
id:
|
|
10
|
-
category:
|
|
9
|
+
id: 'pyreon/no-href-navigation',
|
|
10
|
+
category: 'router',
|
|
11
11
|
description:
|
|
12
|
-
|
|
13
|
-
severity:
|
|
12
|
+
'Warn when `<a href>` is used in files that import @pyreon/router — use `<Link>` instead.',
|
|
13
|
+
severity: 'warn',
|
|
14
14
|
fixable: false,
|
|
15
15
|
},
|
|
16
16
|
create(context) {
|
|
@@ -19,29 +19,29 @@ export const noHrefNavigation: Rule = {
|
|
|
19
19
|
const callbacks: VisitorCallbacks = {
|
|
20
20
|
ImportDeclaration(node: any) {
|
|
21
21
|
const info = extractImportInfo(node)
|
|
22
|
-
if (info && info.source ===
|
|
22
|
+
if (info && info.source === '@pyreon/router') {
|
|
23
23
|
importsRouter = true
|
|
24
24
|
}
|
|
25
25
|
},
|
|
26
26
|
JSXOpeningElement(node: any) {
|
|
27
27
|
if (!importsRouter) return
|
|
28
28
|
const name = node.name
|
|
29
|
-
if (!name || name.type !==
|
|
29
|
+
if (!name || name.type !== 'JSXIdentifier' || name.name !== 'a') return
|
|
30
30
|
|
|
31
|
-
const hrefAttr = getJSXAttribute(node,
|
|
31
|
+
const hrefAttr = getJSXAttribute(node, 'href')
|
|
32
32
|
if (!hrefAttr) return
|
|
33
33
|
|
|
34
34
|
// Get the href value
|
|
35
35
|
const value = hrefAttr.value
|
|
36
|
-
if (value?.type ===
|
|
36
|
+
if (value?.type === 'Literal' && typeof value.value === 'string') {
|
|
37
37
|
const href: string = value.value
|
|
38
38
|
// Skip external URLs and anchor links
|
|
39
|
-
if (href.startsWith(
|
|
39
|
+
if (href.startsWith('#') || EXTERNAL_PREFIXES.some((p) => href.startsWith(p))) return
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
context.report({
|
|
43
43
|
message:
|
|
44
|
-
|
|
44
|
+
'`<a href>` in a router file — use `<Link>` or `<RouterLink>` for client-side navigation.',
|
|
45
45
|
span: getSpan(node),
|
|
46
46
|
})
|
|
47
47
|
},
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import type { Rule, VisitorCallbacks } from
|
|
2
|
-
import { getSpan, isCallTo, isMemberCallTo } from
|
|
1
|
+
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
|
+
import { getSpan, isCallTo, isMemberCallTo } from '../../utils/ast'
|
|
3
3
|
|
|
4
4
|
export const noImperativeNavigateInRender: Rule = {
|
|
5
5
|
meta: {
|
|
6
|
-
id:
|
|
7
|
-
category:
|
|
6
|
+
id: 'pyreon/no-imperative-navigate-in-render',
|
|
7
|
+
category: 'router',
|
|
8
8
|
description:
|
|
9
|
-
|
|
10
|
-
severity:
|
|
9
|
+
'Error when navigate() or router.push() is called at the top level of a component — causes infinite render loops.',
|
|
10
|
+
severity: 'error',
|
|
11
11
|
fixable: false,
|
|
12
12
|
},
|
|
13
13
|
create(context) {
|
|
@@ -20,27 +20,27 @@ export const noImperativeNavigateInRender: Rule = {
|
|
|
20
20
|
|
|
21
21
|
const callbacks: VisitorCallbacks = {
|
|
22
22
|
FunctionDeclaration(node: any) {
|
|
23
|
-
const name: string = node.id?.name ??
|
|
23
|
+
const name: string = node.id?.name ?? ''
|
|
24
24
|
if (/^[A-Z]/.test(name)) {
|
|
25
25
|
componentBodyDepth++
|
|
26
26
|
}
|
|
27
27
|
},
|
|
28
|
-
|
|
29
|
-
const name: string = node.id?.name ??
|
|
28
|
+
'FunctionDeclaration:exit'(node: any) {
|
|
29
|
+
const name: string = node.id?.name ?? ''
|
|
30
30
|
if (/^[A-Z]/.test(name)) {
|
|
31
31
|
componentBodyDepth--
|
|
32
32
|
}
|
|
33
33
|
},
|
|
34
34
|
// For arrow functions, we use VariableDeclarator to detect component assignment
|
|
35
35
|
VariableDeclarator(node: any) {
|
|
36
|
-
const name: string = node.id?.name ??
|
|
37
|
-
if (/^[A-Z]/.test(name) && node.init?.type ===
|
|
36
|
+
const name: string = node.id?.name ?? ''
|
|
37
|
+
if (/^[A-Z]/.test(name) && node.init?.type === 'ArrowFunctionExpression') {
|
|
38
38
|
componentBodyDepth++
|
|
39
39
|
}
|
|
40
40
|
},
|
|
41
|
-
|
|
42
|
-
const name: string = node.id?.name ??
|
|
43
|
-
if (/^[A-Z]/.test(name) && node.init?.type ===
|
|
41
|
+
'VariableDeclarator:exit'(node: any) {
|
|
42
|
+
const name: string = node.id?.name ?? ''
|
|
43
|
+
if (/^[A-Z]/.test(name) && node.init?.type === 'ArrowFunctionExpression') {
|
|
44
44
|
componentBodyDepth--
|
|
45
45
|
}
|
|
46
46
|
},
|
|
@@ -56,15 +56,15 @@ export const noImperativeNavigateInRender: Rule = {
|
|
|
56
56
|
// Only report if we're in a component body and NOT inside a safe callback
|
|
57
57
|
if (safeDepth > 0) return
|
|
58
58
|
|
|
59
|
-
if (isCallTo(node,
|
|
59
|
+
if (isCallTo(node, 'navigate') || isMemberCallTo(node, 'router', 'push')) {
|
|
60
60
|
context.report({
|
|
61
61
|
message:
|
|
62
|
-
|
|
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
63
|
span: getSpan(node),
|
|
64
64
|
})
|
|
65
65
|
}
|
|
66
66
|
},
|
|
67
|
-
|
|
67
|
+
'CallExpression:exit'(node: any) {
|
|
68
68
|
if (componentBodyDepth <= 0) return
|
|
69
69
|
if (isSafeWrapperCall(node)) {
|
|
70
70
|
safeDepth--
|
|
@@ -77,7 +77,7 @@ export const noImperativeNavigateInRender: Rule = {
|
|
|
77
77
|
|
|
78
78
|
function isSafeWrapperCall(node: any): boolean {
|
|
79
79
|
const callee = node.callee
|
|
80
|
-
if (!callee || callee.type !==
|
|
80
|
+
if (!callee || callee.type !== 'Identifier') return false
|
|
81
81
|
const name: string = callee.name
|
|
82
|
-
return name ===
|
|
82
|
+
return name === 'onMount' || name === 'effect' || name === 'onUnmount'
|
|
83
83
|
}
|
|
@@ -1,27 +1,27 @@
|
|
|
1
|
-
import type { Rule, VisitorCallbacks } from
|
|
2
|
-
import { getSpan } from
|
|
3
|
-
import { extractImportInfo } from
|
|
1
|
+
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
|
+
import { getSpan } from '../../utils/ast'
|
|
3
|
+
import { extractImportInfo } from '../../utils/imports'
|
|
4
4
|
|
|
5
5
|
function isCatchAllPath(value: string): boolean {
|
|
6
|
-
return value ===
|
|
6
|
+
return value === '*' || value.endsWith('*')
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
function getPathValue(prop: any): string | null {
|
|
10
10
|
const key = prop.key
|
|
11
11
|
if (!key) return null
|
|
12
|
-
const keyName = key.type ===
|
|
13
|
-
if (keyName !==
|
|
12
|
+
const keyName = key.type === 'Identifier' ? key.name : null
|
|
13
|
+
if (keyName !== 'path') return null
|
|
14
14
|
const val = prop.value
|
|
15
|
-
if (val?.type ===
|
|
15
|
+
if (val?.type === 'Literal' && typeof val.value === 'string') {
|
|
16
16
|
return val.value
|
|
17
17
|
}
|
|
18
18
|
return null
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
function hasPathProperty(obj: any): boolean {
|
|
22
|
-
if (!obj || obj.type !==
|
|
22
|
+
if (!obj || obj.type !== 'ObjectExpression') return false
|
|
23
23
|
for (const prop of obj.properties ?? []) {
|
|
24
|
-
if (prop.type !==
|
|
24
|
+
if (prop.type !== 'Property') continue
|
|
25
25
|
if (getPathValue(prop) !== null) return true
|
|
26
26
|
}
|
|
27
27
|
return false
|
|
@@ -29,9 +29,9 @@ function hasPathProperty(obj: any): boolean {
|
|
|
29
29
|
|
|
30
30
|
function hasCatchAllRoute(elements: any[]): boolean {
|
|
31
31
|
for (const elem of elements) {
|
|
32
|
-
if (!elem || elem.type !==
|
|
32
|
+
if (!elem || elem.type !== 'ObjectExpression') continue
|
|
33
33
|
for (const prop of elem.properties ?? []) {
|
|
34
|
-
if (prop.type !==
|
|
34
|
+
if (prop.type !== 'Property') continue
|
|
35
35
|
const pathVal = getPathValue(prop)
|
|
36
36
|
if (pathVal !== null && isCatchAllPath(pathVal)) return true
|
|
37
37
|
}
|
|
@@ -41,11 +41,11 @@ function hasCatchAllRoute(elements: any[]): boolean {
|
|
|
41
41
|
|
|
42
42
|
export const noMissingFallback: Rule = {
|
|
43
43
|
meta: {
|
|
44
|
-
id:
|
|
45
|
-
category:
|
|
44
|
+
id: 'pyreon/no-missing-fallback',
|
|
45
|
+
category: 'router',
|
|
46
46
|
description:
|
|
47
47
|
'Warn when route config has no catch-all route (`path: "*"` or `path: "/:rest*"`).',
|
|
48
|
-
severity:
|
|
48
|
+
severity: 'warn',
|
|
49
49
|
fixable: false,
|
|
50
50
|
},
|
|
51
51
|
create(context) {
|
|
@@ -56,7 +56,7 @@ export const noMissingFallback: Rule = {
|
|
|
56
56
|
const callbacks: VisitorCallbacks = {
|
|
57
57
|
ImportDeclaration(node: any) {
|
|
58
58
|
const info = extractImportInfo(node)
|
|
59
|
-
if (info && info.source ===
|
|
59
|
+
if (info && info.source === '@pyreon/router') {
|
|
60
60
|
importsRouter = true
|
|
61
61
|
}
|
|
62
62
|
},
|
|
@@ -73,7 +73,7 @@ export const noMissingFallback: Rule = {
|
|
|
73
73
|
foundCatchAll = true
|
|
74
74
|
}
|
|
75
75
|
},
|
|
76
|
-
|
|
76
|
+
'Program:exit'() {
|
|
77
77
|
if (!importsRouter || !routeArraySpan || foundCatchAll) return
|
|
78
78
|
context.report({
|
|
79
79
|
message:
|
|
@@ -1,25 +1,25 @@
|
|
|
1
|
-
import type { Rule, VisitorCallbacks } from
|
|
2
|
-
import { getSpan } from
|
|
1
|
+
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
|
+
import { getSpan } from '../../utils/ast'
|
|
3
3
|
|
|
4
4
|
export const preferUseIsActive: Rule = {
|
|
5
5
|
meta: {
|
|
6
|
-
id:
|
|
7
|
-
category:
|
|
6
|
+
id: 'pyreon/prefer-use-is-active',
|
|
7
|
+
category: 'router',
|
|
8
8
|
description:
|
|
9
9
|
'Suggest useIsActive() instead of `location.pathname === "/foo"` or `route.path === "/foo"` patterns.',
|
|
10
|
-
severity:
|
|
10
|
+
severity: 'info',
|
|
11
11
|
fixable: false,
|
|
12
12
|
},
|
|
13
13
|
create(context) {
|
|
14
14
|
const callbacks: VisitorCallbacks = {
|
|
15
15
|
BinaryExpression(node: any) {
|
|
16
|
-
if (node.operator !==
|
|
16
|
+
if (node.operator !== '===' && node.operator !== '==') return
|
|
17
17
|
|
|
18
18
|
// Check both sides for location.pathname or route.path
|
|
19
19
|
if (isPathComparison(node.left) || isPathComparison(node.right)) {
|
|
20
20
|
context.report({
|
|
21
21
|
message:
|
|
22
|
-
|
|
22
|
+
'Manual path comparison — use `useIsActive()` for reactive route matching with segment-aware prefix matching.',
|
|
23
23
|
span: getSpan(node),
|
|
24
24
|
})
|
|
25
25
|
}
|
|
@@ -30,16 +30,16 @@ export const preferUseIsActive: Rule = {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
function isPathComparison(node: any): boolean {
|
|
33
|
-
if (!node || node.type !==
|
|
33
|
+
if (!node || node.type !== 'MemberExpression') return false
|
|
34
34
|
const obj = node.object
|
|
35
35
|
const prop = node.property
|
|
36
|
-
if (!obj || !prop || prop.type !==
|
|
36
|
+
if (!obj || !prop || prop.type !== 'Identifier') return false
|
|
37
37
|
|
|
38
38
|
// location.pathname
|
|
39
|
-
if (obj.type ===
|
|
39
|
+
if (obj.type === 'Identifier' && obj.name === 'location' && prop.name === 'pathname') return true
|
|
40
40
|
|
|
41
41
|
// route.path
|
|
42
|
-
if (obj.type ===
|
|
42
|
+
if (obj.type === 'Identifier' && obj.name === 'route' && prop.name === 'path') return true
|
|
43
43
|
|
|
44
44
|
return false
|
|
45
45
|
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import type { Rule, VisitorCallbacks } from
|
|
2
|
-
import { getSpan, isMemberCallTo } from
|
|
1
|
+
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
|
+
import { getSpan, isMemberCallTo } from '../../utils/ast'
|
|
3
3
|
|
|
4
4
|
export const noMismatchRisk: Rule = {
|
|
5
5
|
meta: {
|
|
6
|
-
id:
|
|
7
|
-
category:
|
|
6
|
+
id: 'pyreon/no-mismatch-risk',
|
|
7
|
+
category: 'ssr',
|
|
8
8
|
description:
|
|
9
|
-
|
|
10
|
-
severity:
|
|
9
|
+
'Warn about non-deterministic calls (Date.now, Math.random, crypto.randomUUID) in JSX context that cause hydration mismatches.',
|
|
10
|
+
severity: 'warn',
|
|
11
11
|
fixable: false,
|
|
12
12
|
},
|
|
13
13
|
create(context) {
|
|
@@ -16,22 +16,22 @@ export const noMismatchRisk: Rule = {
|
|
|
16
16
|
JSXElement() {
|
|
17
17
|
jsxDepth++
|
|
18
18
|
},
|
|
19
|
-
|
|
19
|
+
'JSXElement:exit'() {
|
|
20
20
|
jsxDepth--
|
|
21
21
|
},
|
|
22
22
|
JSXFragment() {
|
|
23
23
|
jsxDepth++
|
|
24
24
|
},
|
|
25
|
-
|
|
25
|
+
'JSXFragment:exit'() {
|
|
26
26
|
jsxDepth--
|
|
27
27
|
},
|
|
28
28
|
CallExpression(node: any) {
|
|
29
29
|
if (jsxDepth === 0) return
|
|
30
30
|
|
|
31
31
|
if (
|
|
32
|
-
isMemberCallTo(node,
|
|
33
|
-
isMemberCallTo(node,
|
|
34
|
-
isMemberCallTo(node,
|
|
32
|
+
isMemberCallTo(node, 'Date', 'now') ||
|
|
33
|
+
isMemberCallTo(node, 'Math', 'random') ||
|
|
34
|
+
isMemberCallTo(node, 'crypto', 'randomUUID')
|
|
35
35
|
) {
|
|
36
36
|
const callee = node.callee
|
|
37
37
|
const name = `${callee.object.name}.${callee.property.name}`
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import type { Rule, VisitorCallbacks } from
|
|
2
|
-
import { getSpan, isCallTo } from
|
|
3
|
-
import { BROWSER_GLOBALS } from
|
|
1
|
+
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
|
+
import { getSpan, isCallTo } from '../../utils/ast'
|
|
3
|
+
import { BROWSER_GLOBALS } from '../../utils/imports'
|
|
4
4
|
|
|
5
5
|
export const noWindowInSsr: Rule = {
|
|
6
6
|
meta: {
|
|
7
|
-
id:
|
|
8
|
-
category:
|
|
9
|
-
description:
|
|
10
|
-
severity:
|
|
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
11
|
fixable: false,
|
|
12
12
|
},
|
|
13
13
|
create(context) {
|
|
@@ -16,12 +16,12 @@ export const noWindowInSsr: Rule = {
|
|
|
16
16
|
|
|
17
17
|
const callbacks: VisitorCallbacks = {
|
|
18
18
|
CallExpression(node: any) {
|
|
19
|
-
if (isCallTo(node,
|
|
19
|
+
if (isCallTo(node, 'onMount') || isCallTo(node, 'effect')) {
|
|
20
20
|
safeDepth++
|
|
21
21
|
}
|
|
22
22
|
},
|
|
23
|
-
|
|
24
|
-
if (isCallTo(node,
|
|
23
|
+
'CallExpression:exit'(node: any) {
|
|
24
|
+
if (isCallTo(node, 'onMount') || isCallTo(node, 'effect')) {
|
|
25
25
|
safeDepth--
|
|
26
26
|
}
|
|
27
27
|
},
|
|
@@ -29,19 +29,19 @@ export const noWindowInSsr: Rule = {
|
|
|
29
29
|
// typeof window !== "undefined"
|
|
30
30
|
const test = node.test
|
|
31
31
|
if (
|
|
32
|
-
test?.type ===
|
|
33
|
-
test.left?.type ===
|
|
34
|
-
test.left.operator ===
|
|
32
|
+
test?.type === 'BinaryExpression' &&
|
|
33
|
+
test.left?.type === 'UnaryExpression' &&
|
|
34
|
+
test.left.operator === 'typeof'
|
|
35
35
|
) {
|
|
36
36
|
typeofGuardDepth++
|
|
37
37
|
}
|
|
38
38
|
},
|
|
39
|
-
|
|
39
|
+
'IfStatement:exit'(node: any) {
|
|
40
40
|
const test = node.test
|
|
41
41
|
if (
|
|
42
|
-
test?.type ===
|
|
43
|
-
test.left?.type ===
|
|
44
|
-
test.left.operator ===
|
|
42
|
+
test?.type === 'BinaryExpression' &&
|
|
43
|
+
test.left?.type === 'UnaryExpression' &&
|
|
44
|
+
test.left.operator === 'typeof'
|
|
45
45
|
) {
|
|
46
46
|
typeofGuardDepth--
|
|
47
47
|
}
|
|
@@ -51,18 +51,18 @@ export const noWindowInSsr: Rule = {
|
|
|
51
51
|
if (!BROWSER_GLOBALS.has(node.name)) return
|
|
52
52
|
|
|
53
53
|
// Skip typeof expressions: typeof window
|
|
54
|
-
if (parent?.type ===
|
|
54
|
+
if (parent?.type === 'UnaryExpression' && parent.operator === 'typeof') return
|
|
55
55
|
|
|
56
56
|
// Skip import specifiers
|
|
57
57
|
if (
|
|
58
|
-
parent?.type ===
|
|
59
|
-
parent?.type ===
|
|
60
|
-
parent?.type ===
|
|
58
|
+
parent?.type === 'ImportSpecifier' ||
|
|
59
|
+
parent?.type === 'ImportDefaultSpecifier' ||
|
|
60
|
+
parent?.type === 'ImportNamespaceSpecifier'
|
|
61
61
|
)
|
|
62
62
|
return
|
|
63
63
|
|
|
64
64
|
// Skip property access on member expressions (only flag when used as the object)
|
|
65
|
-
if (parent?.type ===
|
|
65
|
+
if (parent?.type === 'MemberExpression' && parent.property === node && !parent.computed)
|
|
66
66
|
return
|
|
67
67
|
|
|
68
68
|
context.report({
|
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
import type { Rule, VisitorCallbacks } from
|
|
2
|
-
import { getSpan, isCallTo } from
|
|
1
|
+
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
|
+
import { getSpan, isCallTo } from '../../utils/ast'
|
|
3
3
|
|
|
4
4
|
export const preferRequestContext: Rule = {
|
|
5
5
|
meta: {
|
|
6
|
-
id:
|
|
7
|
-
category:
|
|
6
|
+
id: 'pyreon/prefer-request-context',
|
|
7
|
+
category: 'ssr',
|
|
8
8
|
description:
|
|
9
|
-
|
|
10
|
-
severity:
|
|
9
|
+
'Warn about module-level signal()/createStore() in server files — use request context instead.',
|
|
10
|
+
severity: 'warn',
|
|
11
11
|
fixable: false,
|
|
12
12
|
},
|
|
13
13
|
create(context) {
|
|
14
14
|
const filePath = context.getFilePath()
|
|
15
15
|
const isServerFile =
|
|
16
|
-
filePath.includes(
|
|
17
|
-
filePath.includes(
|
|
18
|
-
filePath.endsWith(
|
|
19
|
-
filePath.endsWith(
|
|
16
|
+
filePath.includes('server') ||
|
|
17
|
+
filePath.includes('.server.') ||
|
|
18
|
+
filePath.endsWith('server.ts') ||
|
|
19
|
+
filePath.endsWith('server.tsx')
|
|
20
20
|
|
|
21
21
|
if (!isServerFile) return {}
|
|
22
22
|
|
|
@@ -25,24 +25,24 @@ export const preferRequestContext: Rule = {
|
|
|
25
25
|
FunctionDeclaration() {
|
|
26
26
|
functionDepth++
|
|
27
27
|
},
|
|
28
|
-
|
|
28
|
+
'FunctionDeclaration:exit'() {
|
|
29
29
|
functionDepth--
|
|
30
30
|
},
|
|
31
31
|
FunctionExpression() {
|
|
32
32
|
functionDepth++
|
|
33
33
|
},
|
|
34
|
-
|
|
34
|
+
'FunctionExpression:exit'() {
|
|
35
35
|
functionDepth--
|
|
36
36
|
},
|
|
37
37
|
ArrowFunctionExpression() {
|
|
38
38
|
functionDepth++
|
|
39
39
|
},
|
|
40
|
-
|
|
40
|
+
'ArrowFunctionExpression:exit'() {
|
|
41
41
|
functionDepth--
|
|
42
42
|
},
|
|
43
43
|
CallExpression(node: any) {
|
|
44
44
|
if (functionDepth > 0) return // only flag module-level calls
|
|
45
|
-
if (isCallTo(node,
|
|
45
|
+
if (isCallTo(node, 'signal') || isCallTo(node, 'createStore')) {
|
|
46
46
|
const name = node.callee.name
|
|
47
47
|
context.report({
|
|
48
48
|
message: `Module-level \`${name}()\` in a server file — this state is shared across all requests. Use \`runWithRequestContext()\` for per-request isolation.`,
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import type { Rule, VisitorCallbacks } from
|
|
2
|
-
import { getSpan, isCallTo } from
|
|
1
|
+
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
|
+
import { getSpan, isCallTo } from '../../utils/ast'
|
|
3
3
|
|
|
4
4
|
export const noDuplicateStoreId: Rule = {
|
|
5
5
|
meta: {
|
|
6
|
-
id:
|
|
7
|
-
category:
|
|
8
|
-
description:
|
|
9
|
-
severity:
|
|
6
|
+
id: 'pyreon/no-duplicate-store-id',
|
|
7
|
+
category: 'store',
|
|
8
|
+
description: 'Disallow duplicate defineStore() IDs in the same file.',
|
|
9
|
+
severity: 'error',
|
|
10
10
|
fixable: false,
|
|
11
11
|
},
|
|
12
12
|
create(context) {
|
|
@@ -14,7 +14,7 @@ export const noDuplicateStoreId: Rule = {
|
|
|
14
14
|
|
|
15
15
|
const callbacks: VisitorCallbacks = {
|
|
16
16
|
CallExpression(node: any) {
|
|
17
|
-
if (!isCallTo(node,
|
|
17
|
+
if (!isCallTo(node, 'defineStore')) return
|
|
18
18
|
const args = node.arguments
|
|
19
19
|
if (!args || args.length === 0) return
|
|
20
20
|
|
|
@@ -22,11 +22,11 @@ export const noDuplicateStoreId: Rule = {
|
|
|
22
22
|
if (!firstArg) return
|
|
23
23
|
|
|
24
24
|
let id: string | null = null
|
|
25
|
-
if (firstArg.type ===
|
|
25
|
+
if (firstArg.type === 'Literal' || firstArg.type === 'StringLiteral') {
|
|
26
26
|
id = firstArg.value as string
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
if (typeof id !==
|
|
29
|
+
if (typeof id !== 'string') return
|
|
30
30
|
|
|
31
31
|
if (storeIds.has(id)) {
|
|
32
32
|
context.report({
|
|
@@ -1,30 +1,30 @@
|
|
|
1
|
-
import type { Rule, VisitorCallbacks } from
|
|
2
|
-
import { getSpan } from
|
|
1
|
+
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
|
+
import { getSpan } from '../../utils/ast'
|
|
3
3
|
|
|
4
4
|
export const noMutateStoreState: Rule = {
|
|
5
5
|
meta: {
|
|
6
|
-
id:
|
|
7
|
-
category:
|
|
8
|
-
description:
|
|
9
|
-
severity:
|
|
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
10
|
fixable: false,
|
|
11
11
|
},
|
|
12
12
|
create(context) {
|
|
13
13
|
const callbacks: VisitorCallbacks = {
|
|
14
14
|
CallExpression(node: any) {
|
|
15
15
|
const callee = node.callee
|
|
16
|
-
if (!callee || callee.type !==
|
|
17
|
-
if (callee.property?.type !==
|
|
16
|
+
if (!callee || callee.type !== 'MemberExpression') return
|
|
17
|
+
if (callee.property?.type !== 'Identifier' || callee.property.name !== 'set') return
|
|
18
18
|
|
|
19
19
|
// Check for store.signal.set() pattern — member.member.set()
|
|
20
20
|
const obj = callee.object
|
|
21
|
-
if (!obj || obj.type !==
|
|
21
|
+
if (!obj || obj.type !== 'MemberExpression') return
|
|
22
22
|
const outerObj = obj.object
|
|
23
|
-
if (!outerObj || outerObj.type !==
|
|
23
|
+
if (!outerObj || outerObj.type !== 'Identifier') return
|
|
24
24
|
|
|
25
25
|
const name: string = outerObj.name
|
|
26
26
|
// Heuristic: if the outer object name contains "store" (case-insensitive)
|
|
27
|
-
if (name.toLowerCase().includes(
|
|
27
|
+
if (name.toLowerCase().includes('store')) {
|
|
28
28
|
context.report({
|
|
29
29
|
message: `Direct \`.set()\` on store state \`${name}\` — use store actions to mutate state for better traceability.`,
|
|
30
30
|
span: getSpan(node),
|