@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,53 @@
|
|
|
1
|
+
import type { Rule, VisitorCallbacks } from "../../types"
|
|
2
|
+
import { getSpan, isCallTo } from "../../utils/ast"
|
|
3
|
+
|
|
4
|
+
const DOM_METHODS = new Set([
|
|
5
|
+
"querySelector",
|
|
6
|
+
"querySelectorAll",
|
|
7
|
+
"getElementById",
|
|
8
|
+
"getElementsByClassName",
|
|
9
|
+
"getElementsByTagName",
|
|
10
|
+
])
|
|
11
|
+
|
|
12
|
+
export const noDomInSetup: Rule = {
|
|
13
|
+
meta: {
|
|
14
|
+
id: "pyreon/no-dom-in-setup",
|
|
15
|
+
category: "lifecycle",
|
|
16
|
+
description: "Warn when DOM query methods are used outside onMount or effect.",
|
|
17
|
+
severity: "warn",
|
|
18
|
+
fixable: false,
|
|
19
|
+
},
|
|
20
|
+
create(context) {
|
|
21
|
+
let safeDepth = 0 // inside onMount or effect
|
|
22
|
+
const callbacks: VisitorCallbacks = {
|
|
23
|
+
CallExpression(node: any) {
|
|
24
|
+
if (isCallTo(node, "onMount") || isCallTo(node, "effect")) {
|
|
25
|
+
safeDepth++
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (safeDepth > 0) return
|
|
29
|
+
|
|
30
|
+
// Check for document.querySelector() etc.
|
|
31
|
+
const callee = node.callee
|
|
32
|
+
if (
|
|
33
|
+
callee?.type === "MemberExpression" &&
|
|
34
|
+
callee.object?.type === "Identifier" &&
|
|
35
|
+
callee.object.name === "document" &&
|
|
36
|
+
callee.property?.type === "Identifier" &&
|
|
37
|
+
DOM_METHODS.has(callee.property.name)
|
|
38
|
+
) {
|
|
39
|
+
context.report({
|
|
40
|
+
message: `\`document.${callee.property.name}()\` outside \`onMount\`/\`effect\` — DOM is not available during SSR or setup phase.`,
|
|
41
|
+
span: getSpan(node),
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"CallExpression:exit"(node: any) {
|
|
46
|
+
if (isCallTo(node, "onMount") || isCallTo(node, "effect")) {
|
|
47
|
+
safeDepth--
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
return callbacks
|
|
52
|
+
},
|
|
53
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Rule, VisitorCallbacks } from "../../types"
|
|
2
|
+
import { getSpan, isCallTo } from "../../utils/ast"
|
|
3
|
+
|
|
4
|
+
export const noEffectInMount: Rule = {
|
|
5
|
+
meta: {
|
|
6
|
+
id: "pyreon/no-effect-in-mount",
|
|
7
|
+
category: "lifecycle",
|
|
8
|
+
description:
|
|
9
|
+
"Inform when effect() is created inside onMount — effects are typically created at setup time.",
|
|
10
|
+
severity: "info",
|
|
11
|
+
fixable: false,
|
|
12
|
+
},
|
|
13
|
+
create(context) {
|
|
14
|
+
let mountDepth = 0
|
|
15
|
+
const callbacks: VisitorCallbacks = {
|
|
16
|
+
CallExpression(node: any) {
|
|
17
|
+
if (isCallTo(node, "onMount")) {
|
|
18
|
+
mountDepth++
|
|
19
|
+
}
|
|
20
|
+
if (mountDepth > 0 && isCallTo(node, "effect")) {
|
|
21
|
+
context.report({
|
|
22
|
+
message:
|
|
23
|
+
"`effect()` inside `onMount` — effects are typically created at component setup time, not inside lifecycle hooks.",
|
|
24
|
+
span: getSpan(node),
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"CallExpression:exit"(node: any) {
|
|
29
|
+
if (isCallTo(node, "onMount")) {
|
|
30
|
+
mountDepth--
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
return callbacks
|
|
35
|
+
},
|
|
36
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Rule, VisitorCallbacks } from "../../types"
|
|
2
|
+
import { getSpan, isCallTo } from "../../utils/ast"
|
|
3
|
+
|
|
4
|
+
const NEEDS_CLEANUP = new Set(["setInterval", "addEventListener"])
|
|
5
|
+
|
|
6
|
+
export const noMissingCleanup: Rule = {
|
|
7
|
+
meta: {
|
|
8
|
+
id: "pyreon/no-missing-cleanup",
|
|
9
|
+
category: "lifecycle",
|
|
10
|
+
description:
|
|
11
|
+
"Warn when onMount uses setInterval/addEventListener without returning a cleanup function.",
|
|
12
|
+
severity: "warn",
|
|
13
|
+
fixable: false,
|
|
14
|
+
},
|
|
15
|
+
create(context) {
|
|
16
|
+
const callbacks: VisitorCallbacks = {
|
|
17
|
+
CallExpression(node: any) {
|
|
18
|
+
if (!isCallTo(node, "onMount")) return
|
|
19
|
+
const args = node.arguments
|
|
20
|
+
if (!args || args.length === 0) return
|
|
21
|
+
|
|
22
|
+
const fn = args[0]
|
|
23
|
+
if (!fn) return
|
|
24
|
+
if (fn.type !== "ArrowFunctionExpression" && fn.type !== "FunctionExpression") return
|
|
25
|
+
|
|
26
|
+
const body = fn.body
|
|
27
|
+
if (!body) return
|
|
28
|
+
|
|
29
|
+
// Only check block bodies
|
|
30
|
+
if (body.type !== "BlockStatement") return
|
|
31
|
+
|
|
32
|
+
let hasCleanupTarget = false
|
|
33
|
+
let hasReturn = false
|
|
34
|
+
|
|
35
|
+
function walk(n: any) {
|
|
36
|
+
if (!n) return
|
|
37
|
+
if (n.type === "CallExpression") {
|
|
38
|
+
const callee = n.callee
|
|
39
|
+
if (callee?.type === "Identifier" && NEEDS_CLEANUP.has(callee.name)) {
|
|
40
|
+
hasCleanupTarget = true
|
|
41
|
+
}
|
|
42
|
+
if (
|
|
43
|
+
callee?.type === "MemberExpression" &&
|
|
44
|
+
callee.property?.type === "Identifier" &&
|
|
45
|
+
NEEDS_CLEANUP.has(callee.property.name)
|
|
46
|
+
) {
|
|
47
|
+
hasCleanupTarget = true
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (n.type === "ReturnStatement" && n.argument) {
|
|
51
|
+
hasReturn = true
|
|
52
|
+
}
|
|
53
|
+
for (const key of Object.keys(n)) {
|
|
54
|
+
const child = n[key]
|
|
55
|
+
if (child && typeof child === "object") {
|
|
56
|
+
if (Array.isArray(child)) {
|
|
57
|
+
for (const item of child) {
|
|
58
|
+
if (item && typeof item.type === "string") walk(item)
|
|
59
|
+
}
|
|
60
|
+
} else if (typeof child.type === "string") {
|
|
61
|
+
walk(child)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
walk(body)
|
|
68
|
+
|
|
69
|
+
if (hasCleanupTarget && !hasReturn) {
|
|
70
|
+
context.report({
|
|
71
|
+
message:
|
|
72
|
+
"`onMount` uses `setInterval`/`addEventListener` without returning a cleanup function — this will cause a memory leak.",
|
|
73
|
+
span: getSpan(node),
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
return callbacks
|
|
79
|
+
},
|
|
80
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Rule, VisitorCallbacks } from "../../types"
|
|
2
|
+
import { getSpan, isCallTo } from "../../utils/ast"
|
|
3
|
+
|
|
4
|
+
export const noMountInEffect: Rule = {
|
|
5
|
+
meta: {
|
|
6
|
+
id: "pyreon/no-mount-in-effect",
|
|
7
|
+
category: "lifecycle",
|
|
8
|
+
description: "Warn when onMount is called inside effect().",
|
|
9
|
+
severity: "warn",
|
|
10
|
+
fixable: false,
|
|
11
|
+
},
|
|
12
|
+
create(context) {
|
|
13
|
+
let effectDepth = 0
|
|
14
|
+
const callbacks: VisitorCallbacks = {
|
|
15
|
+
CallExpression(node: any) {
|
|
16
|
+
if (isCallTo(node, "effect")) {
|
|
17
|
+
effectDepth++
|
|
18
|
+
}
|
|
19
|
+
if (effectDepth > 0 && isCallTo(node, "onMount")) {
|
|
20
|
+
context.report({
|
|
21
|
+
message:
|
|
22
|
+
"`onMount` inside `effect()` — `onMount` runs once on mount, not on every effect re-run.",
|
|
23
|
+
span: getSpan(node),
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"CallExpression:exit"(node: any) {
|
|
28
|
+
if (isCallTo(node, "effect")) {
|
|
29
|
+
effectDepth--
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
return callbacks
|
|
34
|
+
},
|
|
35
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Rule, VisitorCallbacks } from "../../types"
|
|
2
|
+
import { getSpan } from "../../utils/ast"
|
|
3
|
+
import { HEAVY_PACKAGES } from "../../utils/imports"
|
|
4
|
+
|
|
5
|
+
export const noEagerImport: Rule = {
|
|
6
|
+
meta: {
|
|
7
|
+
id: "pyreon/no-eager-import",
|
|
8
|
+
category: "performance",
|
|
9
|
+
description: "Suggest lazy-loading heavy Pyreon packages (charts, code, document, flow).",
|
|
10
|
+
severity: "info",
|
|
11
|
+
fixable: false,
|
|
12
|
+
},
|
|
13
|
+
create(context) {
|
|
14
|
+
const callbacks: VisitorCallbacks = {
|
|
15
|
+
ImportDeclaration(node: any) {
|
|
16
|
+
const source = node.source?.value as string
|
|
17
|
+
if (!source) return
|
|
18
|
+
if (HEAVY_PACKAGES.has(source)) {
|
|
19
|
+
context.report({
|
|
20
|
+
message: `Static import of \`${source}\` — consider using \`lazy()\` or dynamic \`import()\` to reduce initial bundle size.`,
|
|
21
|
+
span: getSpan(node),
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
return callbacks
|
|
27
|
+
},
|
|
28
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Rule, VisitorCallbacks } from "../../types"
|
|
2
|
+
import { getSpan, isCallTo } from "../../utils/ast"
|
|
3
|
+
|
|
4
|
+
export const noEffectInFor: Rule = {
|
|
5
|
+
meta: {
|
|
6
|
+
id: "pyreon/no-effect-in-for",
|
|
7
|
+
category: "performance",
|
|
8
|
+
description:
|
|
9
|
+
"Warn when effect() is created inside <For> — creates effects per item on every reconciliation.",
|
|
10
|
+
severity: "warn",
|
|
11
|
+
fixable: false,
|
|
12
|
+
},
|
|
13
|
+
create(context) {
|
|
14
|
+
let forJsxDepth = 0
|
|
15
|
+
const callbacks: VisitorCallbacks = {
|
|
16
|
+
JSXOpeningElement(node: any) {
|
|
17
|
+
const name = node.name
|
|
18
|
+
if (name?.type === "JSXIdentifier" && name.name === "For") {
|
|
19
|
+
forJsxDepth++
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
JSXClosingElement(node: any) {
|
|
23
|
+
const name = node.name
|
|
24
|
+
if (name?.type === "JSXIdentifier" && name.name === "For") {
|
|
25
|
+
forJsxDepth--
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
CallExpression(node: any) {
|
|
29
|
+
if (forJsxDepth === 0) return
|
|
30
|
+
if (isCallTo(node, "effect")) {
|
|
31
|
+
context.report({
|
|
32
|
+
message:
|
|
33
|
+
"`effect()` inside `<For>` — this creates a new effect for every item on each reconciliation. Lift the effect outside.",
|
|
34
|
+
span: getSpan(node),
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
return callbacks
|
|
40
|
+
},
|
|
41
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Rule, VisitorCallbacks } from "../../types"
|
|
2
|
+
import { getSpan, hasJSXAttribute } from "../../utils/ast"
|
|
3
|
+
|
|
4
|
+
export const noLargeForWithoutBy: Rule = {
|
|
5
|
+
meta: {
|
|
6
|
+
id: "pyreon/no-large-for-without-by",
|
|
7
|
+
category: "performance",
|
|
8
|
+
description:
|
|
9
|
+
"Error when <For> is used without a `by` prop — critical for reconciliation performance.",
|
|
10
|
+
severity: "error",
|
|
11
|
+
fixable: false,
|
|
12
|
+
},
|
|
13
|
+
create(context) {
|
|
14
|
+
const callbacks: VisitorCallbacks = {
|
|
15
|
+
JSXOpeningElement(node: any) {
|
|
16
|
+
const name = node.name
|
|
17
|
+
if (!name || name.type !== "JSXIdentifier" || name.name !== "For") return
|
|
18
|
+
if (hasJSXAttribute(node, "by")) return
|
|
19
|
+
context.report({
|
|
20
|
+
message:
|
|
21
|
+
"`<For>` without `by` prop — provide a key function for efficient reconciliation.",
|
|
22
|
+
span: getSpan(node),
|
|
23
|
+
})
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
return callbacks
|
|
27
|
+
},
|
|
28
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Rule, VisitorCallbacks } from "../../types"
|
|
2
|
+
import { getSpan } from "../../utils/ast"
|
|
3
|
+
|
|
4
|
+
export const preferShowOverDisplay: Rule = {
|
|
5
|
+
meta: {
|
|
6
|
+
id: "pyreon/prefer-show-over-display",
|
|
7
|
+
category: "performance",
|
|
8
|
+
description: "Suggest <Show> over conditional `display` style property in JSX.",
|
|
9
|
+
severity: "info",
|
|
10
|
+
fixable: false,
|
|
11
|
+
},
|
|
12
|
+
create(context) {
|
|
13
|
+
const callbacks: VisitorCallbacks = {
|
|
14
|
+
JSXAttribute(node: any) {
|
|
15
|
+
if (node.name?.type !== "JSXIdentifier" || node.name.name !== "style") return
|
|
16
|
+
const value = node.value
|
|
17
|
+
if (!value || value.type !== "JSXExpressionContainer") return
|
|
18
|
+
const expr = value.expression
|
|
19
|
+
if (!expr || expr.type !== "ObjectExpression") return
|
|
20
|
+
|
|
21
|
+
for (const prop of expr.properties ?? []) {
|
|
22
|
+
if (prop.type !== "Property") continue
|
|
23
|
+
const key = prop.key
|
|
24
|
+
if (!key) continue
|
|
25
|
+
const propName =
|
|
26
|
+
key.type === "Identifier" ? key.name : key.type === "Literal" ? key.value : null
|
|
27
|
+
if (propName === "display") {
|
|
28
|
+
// Check if the value is conditional
|
|
29
|
+
const val = prop.value
|
|
30
|
+
if (
|
|
31
|
+
val?.type === "ConditionalExpression" ||
|
|
32
|
+
val?.type === "LogicalExpression" ||
|
|
33
|
+
val?.type === "CallExpression"
|
|
34
|
+
) {
|
|
35
|
+
context.report({
|
|
36
|
+
message:
|
|
37
|
+
"Conditional `display` style — consider using `<Show>` for conditional rendering instead of toggling CSS display.",
|
|
38
|
+
span: getSpan(prop),
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
return callbacks
|
|
46
|
+
},
|
|
47
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Rule, VisitorCallbacks } from "../../types"
|
|
2
|
+
import { getSpan } from "../../utils/ast"
|
|
3
|
+
|
|
4
|
+
const SKIP_PREFIXES = /^(use|get|is|has|[A-Z])/
|
|
5
|
+
|
|
6
|
+
export const noBareSignalInJsx: Rule = {
|
|
7
|
+
meta: {
|
|
8
|
+
id: "pyreon/no-bare-signal-in-jsx",
|
|
9
|
+
category: "reactivity",
|
|
10
|
+
description:
|
|
11
|
+
"Disallow bare signal calls in JSX text positions. Wrap in `() =>` for reactivity.",
|
|
12
|
+
severity: "error",
|
|
13
|
+
fixable: true,
|
|
14
|
+
},
|
|
15
|
+
create(context) {
|
|
16
|
+
let jsxDepth = 0
|
|
17
|
+
const callbacks: VisitorCallbacks = {
|
|
18
|
+
JSXElement() {
|
|
19
|
+
jsxDepth++
|
|
20
|
+
},
|
|
21
|
+
"JSXElement:exit"() {
|
|
22
|
+
jsxDepth--
|
|
23
|
+
},
|
|
24
|
+
JSXFragment() {
|
|
25
|
+
jsxDepth++
|
|
26
|
+
},
|
|
27
|
+
"JSXFragment:exit"() {
|
|
28
|
+
jsxDepth--
|
|
29
|
+
},
|
|
30
|
+
JSXExpressionContainer(node: any) {
|
|
31
|
+
if (jsxDepth === 0) return
|
|
32
|
+
const expr = node.expression
|
|
33
|
+
if (!expr || expr.type !== "CallExpression") return
|
|
34
|
+
const callee = expr.callee
|
|
35
|
+
if (!callee || callee.type !== "Identifier") return
|
|
36
|
+
|
|
37
|
+
const name: string = callee.name
|
|
38
|
+
if (SKIP_PREFIXES.test(name)) return
|
|
39
|
+
|
|
40
|
+
const span = getSpan(node)
|
|
41
|
+
const source = context.getSourceText()
|
|
42
|
+
const original = source.slice(span.start, span.end)
|
|
43
|
+
// {count()} → {() => count()}
|
|
44
|
+
const inner = original.slice(1, -1) // strip { }
|
|
45
|
+
const fixed = `{() => ${inner}}`
|
|
46
|
+
|
|
47
|
+
context.report({
|
|
48
|
+
message: `Bare signal call \`${name}()\` in JSX text — wrap in \`() => ${name}()\` for fine-grained reactivity.`,
|
|
49
|
+
span,
|
|
50
|
+
fix: { span, replacement: fixed },
|
|
51
|
+
})
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
return callbacks
|
|
55
|
+
},
|
|
56
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Rule, VisitorCallbacks } from "../../types"
|
|
2
|
+
import { getSpan, isCallTo } from "../../utils/ast"
|
|
3
|
+
|
|
4
|
+
function isUpdateCall(node: any): boolean {
|
|
5
|
+
return (
|
|
6
|
+
node.type === "CallExpression" &&
|
|
7
|
+
node.callee?.type === "MemberExpression" &&
|
|
8
|
+
node.callee.property?.type === "Identifier" &&
|
|
9
|
+
node.callee.property.name === "update"
|
|
10
|
+
)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const noEffectAssignment: Rule = {
|
|
14
|
+
meta: {
|
|
15
|
+
id: "pyreon/no-effect-assignment",
|
|
16
|
+
category: "reactivity",
|
|
17
|
+
description: "Warn when an effect only contains a single .update() call.",
|
|
18
|
+
severity: "warn",
|
|
19
|
+
fixable: false,
|
|
20
|
+
},
|
|
21
|
+
create(context) {
|
|
22
|
+
const callbacks: VisitorCallbacks = {
|
|
23
|
+
CallExpression(node: any) {
|
|
24
|
+
if (!isCallTo(node, "effect")) return
|
|
25
|
+
const args = node.arguments
|
|
26
|
+
if (!args || args.length === 0) return
|
|
27
|
+
|
|
28
|
+
const fn = args[0]
|
|
29
|
+
if (!fn) return
|
|
30
|
+
|
|
31
|
+
let body: any = null
|
|
32
|
+
if (fn.type === "ArrowFunctionExpression" || fn.type === "FunctionExpression") {
|
|
33
|
+
body = fn.body
|
|
34
|
+
}
|
|
35
|
+
if (!body) return
|
|
36
|
+
|
|
37
|
+
// Arrow with expression body
|
|
38
|
+
if (isUpdateCall(body)) {
|
|
39
|
+
context.report({
|
|
40
|
+
message:
|
|
41
|
+
"Effect contains a single `.update()` — consider using `computed()` for derived values.",
|
|
42
|
+
span: getSpan(node),
|
|
43
|
+
})
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Block body with single statement
|
|
48
|
+
if (body.type === "BlockStatement") {
|
|
49
|
+
const stmts = body.body
|
|
50
|
+
if (stmts && stmts.length === 1) {
|
|
51
|
+
const stmt = stmts[0]
|
|
52
|
+
if (stmt.type === "ExpressionStatement" && isUpdateCall(stmt.expression)) {
|
|
53
|
+
context.report({
|
|
54
|
+
message:
|
|
55
|
+
"Effect contains a single `.update()` — consider using `computed()` for derived values.",
|
|
56
|
+
span: getSpan(node),
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
return callbacks
|
|
64
|
+
},
|
|
65
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Rule, VisitorCallbacks } from "../../types"
|
|
2
|
+
import { getSpan, isCallTo } from "../../utils/ast"
|
|
3
|
+
|
|
4
|
+
export const noNestedEffect: Rule = {
|
|
5
|
+
meta: {
|
|
6
|
+
id: "pyreon/no-nested-effect",
|
|
7
|
+
category: "reactivity",
|
|
8
|
+
description: "Warn against nesting effect() inside another effect().",
|
|
9
|
+
severity: "warn",
|
|
10
|
+
fixable: false,
|
|
11
|
+
},
|
|
12
|
+
create(context) {
|
|
13
|
+
let effectDepth = 0
|
|
14
|
+
const callbacks: VisitorCallbacks = {
|
|
15
|
+
CallExpression(node: any) {
|
|
16
|
+
if (!isCallTo(node, "effect")) return
|
|
17
|
+
if (effectDepth > 0) {
|
|
18
|
+
context.report({
|
|
19
|
+
message: "Nested `effect()` — consider using `computed()` for derived values instead.",
|
|
20
|
+
span: getSpan(node),
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
effectDepth++
|
|
24
|
+
},
|
|
25
|
+
"CallExpression:exit"(node: any) {
|
|
26
|
+
if (isCallTo(node, "effect")) {
|
|
27
|
+
effectDepth--
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
return callbacks
|
|
32
|
+
},
|
|
33
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Rule, VisitorCallbacks } from "../../types"
|
|
2
|
+
import { getSpan, isCallTo, isPeekCall } from "../../utils/ast"
|
|
3
|
+
|
|
4
|
+
export const noPeekInTracked: Rule = {
|
|
5
|
+
meta: {
|
|
6
|
+
id: "pyreon/no-peek-in-tracked",
|
|
7
|
+
category: "reactivity",
|
|
8
|
+
description: "Disallow .peek() inside effect() or computed() — it bypasses tracking.",
|
|
9
|
+
severity: "error",
|
|
10
|
+
fixable: false,
|
|
11
|
+
},
|
|
12
|
+
create(context) {
|
|
13
|
+
let trackedDepth = 0
|
|
14
|
+
const callbacks: VisitorCallbacks = {
|
|
15
|
+
CallExpression(node: any) {
|
|
16
|
+
if (isCallTo(node, "effect") || isCallTo(node, "computed")) {
|
|
17
|
+
trackedDepth++
|
|
18
|
+
}
|
|
19
|
+
if (trackedDepth > 0 && isPeekCall(node)) {
|
|
20
|
+
context.report({
|
|
21
|
+
message:
|
|
22
|
+
"`.peek()` inside a tracked scope (effect/computed) bypasses dependency tracking — use a normal signal read instead.",
|
|
23
|
+
span: getSpan(node),
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"CallExpression:exit"(node: any) {
|
|
28
|
+
if (isCallTo(node, "effect") || isCallTo(node, "computed")) {
|
|
29
|
+
trackedDepth--
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
return callbacks
|
|
34
|
+
},
|
|
35
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Rule, VisitorCallbacks } from "../../types"
|
|
2
|
+
import { getSpan } from "../../utils/ast"
|
|
3
|
+
|
|
4
|
+
export const noSignalInLoop: Rule = {
|
|
5
|
+
meta: {
|
|
6
|
+
id: "pyreon/no-signal-in-loop",
|
|
7
|
+
category: "reactivity",
|
|
8
|
+
description: "Disallow creating signals or computeds inside loops.",
|
|
9
|
+
severity: "error",
|
|
10
|
+
fixable: false,
|
|
11
|
+
},
|
|
12
|
+
create(context) {
|
|
13
|
+
let loopDepth = 0
|
|
14
|
+
const callbacks: VisitorCallbacks = {
|
|
15
|
+
ForStatement() {
|
|
16
|
+
loopDepth++
|
|
17
|
+
},
|
|
18
|
+
"ForStatement:exit"() {
|
|
19
|
+
loopDepth--
|
|
20
|
+
},
|
|
21
|
+
ForInStatement() {
|
|
22
|
+
loopDepth++
|
|
23
|
+
},
|
|
24
|
+
"ForInStatement:exit"() {
|
|
25
|
+
loopDepth--
|
|
26
|
+
},
|
|
27
|
+
ForOfStatement() {
|
|
28
|
+
loopDepth++
|
|
29
|
+
},
|
|
30
|
+
"ForOfStatement:exit"() {
|
|
31
|
+
loopDepth--
|
|
32
|
+
},
|
|
33
|
+
WhileStatement() {
|
|
34
|
+
loopDepth++
|
|
35
|
+
},
|
|
36
|
+
"WhileStatement:exit"() {
|
|
37
|
+
loopDepth--
|
|
38
|
+
},
|
|
39
|
+
DoWhileStatement() {
|
|
40
|
+
loopDepth++
|
|
41
|
+
},
|
|
42
|
+
"DoWhileStatement:exit"() {
|
|
43
|
+
loopDepth--
|
|
44
|
+
},
|
|
45
|
+
CallExpression(node: any) {
|
|
46
|
+
if (loopDepth === 0) return
|
|
47
|
+
const callee = node.callee
|
|
48
|
+
if (!callee || callee.type !== "Identifier") return
|
|
49
|
+
if (callee.name === "signal" || callee.name === "computed") {
|
|
50
|
+
context.report({
|
|
51
|
+
message: `\`${callee.name}()\` inside a loop — signals should be created once at component setup, not on every iteration.`,
|
|
52
|
+
span: getSpan(node),
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
return callbacks
|
|
58
|
+
},
|
|
59
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Rule, VisitorCallbacks } from "../../types"
|
|
2
|
+
import { getSpan, isCallTo } from "../../utils/ast"
|
|
3
|
+
|
|
4
|
+
export const noSignalLeak: Rule = {
|
|
5
|
+
meta: {
|
|
6
|
+
id: "pyreon/no-signal-leak",
|
|
7
|
+
category: "reactivity",
|
|
8
|
+
description: "Warn about unused signal declarations (potential leaks).",
|
|
9
|
+
severity: "warn",
|
|
10
|
+
fixable: false,
|
|
11
|
+
},
|
|
12
|
+
create(context) {
|
|
13
|
+
const signalDecls = new Map<
|
|
14
|
+
string,
|
|
15
|
+
{ span: { start: number; end: number }; declStart: number; declEnd: number }
|
|
16
|
+
>()
|
|
17
|
+
const identifierOccurrences = new Map<string, Array<{ start: number; end: number }>>()
|
|
18
|
+
|
|
19
|
+
const callbacks: VisitorCallbacks = {
|
|
20
|
+
VariableDeclarator(node: any) {
|
|
21
|
+
const init = node.init
|
|
22
|
+
if (!init || !isCallTo(init, "signal")) return
|
|
23
|
+
const id = node.id
|
|
24
|
+
if (!id || id.type !== "Identifier") return
|
|
25
|
+
signalDecls.set(id.name, {
|
|
26
|
+
span: getSpan(node),
|
|
27
|
+
declStart: id.start as number,
|
|
28
|
+
declEnd: id.end as number,
|
|
29
|
+
})
|
|
30
|
+
},
|
|
31
|
+
Identifier(node: any) {
|
|
32
|
+
const name: string = node.name
|
|
33
|
+
const existing = identifierOccurrences.get(name)
|
|
34
|
+
if (existing) {
|
|
35
|
+
existing.push({ start: node.start as number, end: node.end as number })
|
|
36
|
+
} else {
|
|
37
|
+
identifierOccurrences.set(name, [
|
|
38
|
+
{ start: node.start as number, end: node.end as number },
|
|
39
|
+
])
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"Program:exit"() {
|
|
43
|
+
for (const [name, { span, declStart, declEnd }] of signalDecls) {
|
|
44
|
+
const occurrences = identifierOccurrences.get(name) ?? []
|
|
45
|
+
// Filter out the declaration identifier itself
|
|
46
|
+
const usages = occurrences.filter((o) => o.start !== declStart || o.end !== declEnd)
|
|
47
|
+
if (usages.length === 0) {
|
|
48
|
+
context.report({
|
|
49
|
+
message: `Signal \`${name}\` is declared but never used — this may be a signal leak.`,
|
|
50
|
+
span,
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
return callbacks
|
|
57
|
+
},
|
|
58
|
+
}
|