@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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +214 -0
  3. package/lib/analysis/index.js.html +5406 -0
  4. package/lib/index.js +2955 -0
  5. package/lib/index.js.map +1 -0
  6. package/lib/types/index.d.ts +260 -0
  7. package/lib/types/index.d.ts.map +1 -0
  8. package/package.json +56 -0
  9. package/src/cache.ts +51 -0
  10. package/src/cli.ts +199 -0
  11. package/src/config/ignore.ts +159 -0
  12. package/src/config/loader.ts +72 -0
  13. package/src/config/presets.ts +62 -0
  14. package/src/index.ts +40 -0
  15. package/src/lint.ts +226 -0
  16. package/src/reporter.ts +85 -0
  17. package/src/rules/accessibility/dialog-a11y.ts +32 -0
  18. package/src/rules/accessibility/overlay-a11y.ts +33 -0
  19. package/src/rules/accessibility/toast-a11y.ts +38 -0
  20. package/src/rules/architecture/dev-guard-warnings.ts +57 -0
  21. package/src/rules/architecture/no-circular-import.ts +59 -0
  22. package/src/rules/architecture/no-cross-layer-import.ts +75 -0
  23. package/src/rules/architecture/no-deep-import.ts +32 -0
  24. package/src/rules/architecture/no-error-without-prefix.ts +75 -0
  25. package/src/rules/form/no-submit-without-validation.ts +45 -0
  26. package/src/rules/form/no-unregistered-field.ts +45 -0
  27. package/src/rules/form/prefer-field-array.ts +41 -0
  28. package/src/rules/hooks/no-raw-addeventlistener.ts +28 -0
  29. package/src/rules/hooks/no-raw-localstorage.ts +35 -0
  30. package/src/rules/hooks/no-raw-setinterval.ts +41 -0
  31. package/src/rules/index.ts +208 -0
  32. package/src/rules/jsx/no-and-conditional.ts +32 -0
  33. package/src/rules/jsx/no-children-access.ts +44 -0
  34. package/src/rules/jsx/no-classname.ts +27 -0
  35. package/src/rules/jsx/no-htmlfor.ts +27 -0
  36. package/src/rules/jsx/no-index-as-by.ts +70 -0
  37. package/src/rules/jsx/no-map-in-jsx.ts +43 -0
  38. package/src/rules/jsx/no-missing-for-by.ts +27 -0
  39. package/src/rules/jsx/no-onchange.ts +46 -0
  40. package/src/rules/jsx/no-props-destructure.ts +64 -0
  41. package/src/rules/jsx/no-ternary-conditional.ts +32 -0
  42. package/src/rules/jsx/use-by-not-key.ts +33 -0
  43. package/src/rules/lifecycle/no-dom-in-setup.ts +53 -0
  44. package/src/rules/lifecycle/no-effect-in-mount.ts +36 -0
  45. package/src/rules/lifecycle/no-missing-cleanup.ts +80 -0
  46. package/src/rules/lifecycle/no-mount-in-effect.ts +35 -0
  47. package/src/rules/performance/no-eager-import.ts +28 -0
  48. package/src/rules/performance/no-effect-in-for.ts +41 -0
  49. package/src/rules/performance/no-large-for-without-by.ts +28 -0
  50. package/src/rules/performance/prefer-show-over-display.ts +47 -0
  51. package/src/rules/reactivity/no-bare-signal-in-jsx.ts +56 -0
  52. package/src/rules/reactivity/no-effect-assignment.ts +65 -0
  53. package/src/rules/reactivity/no-nested-effect.ts +33 -0
  54. package/src/rules/reactivity/no-peek-in-tracked.ts +35 -0
  55. package/src/rules/reactivity/no-signal-in-loop.ts +59 -0
  56. package/src/rules/reactivity/no-signal-leak.ts +58 -0
  57. package/src/rules/reactivity/no-unbatched-updates.ts +77 -0
  58. package/src/rules/reactivity/prefer-computed.ts +56 -0
  59. package/src/rules/router/index.ts +4 -0
  60. package/src/rules/router/no-href-navigation.ts +51 -0
  61. package/src/rules/router/no-imperative-navigate-in-render.ts +83 -0
  62. package/src/rules/router/no-missing-fallback.ts +87 -0
  63. package/src/rules/router/prefer-use-is-active.ts +45 -0
  64. package/src/rules/ssr/no-mismatch-risk.ts +47 -0
  65. package/src/rules/ssr/no-window-in-ssr.ts +76 -0
  66. package/src/rules/ssr/prefer-request-context.ts +56 -0
  67. package/src/rules/store/no-duplicate-store-id.ts +43 -0
  68. package/src/rules/store/no-mutate-store-state.ts +37 -0
  69. package/src/rules/store/no-store-outside-provider.ts +59 -0
  70. package/src/rules/styling/no-dynamic-styled.ts +60 -0
  71. package/src/rules/styling/no-inline-style-object.ts +30 -0
  72. package/src/rules/styling/no-theme-outside-provider.ts +45 -0
  73. package/src/rules/styling/prefer-cx.ts +44 -0
  74. package/src/runner.ts +170 -0
  75. package/src/tests/runner.test.ts +1043 -0
  76. package/src/types.ts +125 -0
  77. package/src/utils/ast.ts +192 -0
  78. package/src/utils/imports.ts +122 -0
  79. package/src/utils/index.ts +39 -0
  80. package/src/utils/source.ts +36 -0
  81. 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
+ }