@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,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,4 @@
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"
@@ -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
+ }