@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,33 @@
1
+ import type { Rule, VisitorCallbacks } from "../../types"
2
+ import { getSpan, hasJSXAttribute } from "../../utils/ast"
3
+
4
+ export const overlayA11y: Rule = {
5
+ meta: {
6
+ id: "pyreon/overlay-a11y",
7
+ category: "accessibility",
8
+ description: "Warn when <Overlay> is missing role, aria-label, or aria-labelledby.",
9
+ severity: "warn",
10
+ fixable: false,
11
+ },
12
+ create(context) {
13
+ const callbacks: VisitorCallbacks = {
14
+ JSXOpeningElement(node: any) {
15
+ const name = node.name
16
+ if (!name || name.type !== "JSXIdentifier" || name.name !== "Overlay") return
17
+
18
+ const hasRole = hasJSXAttribute(node, "role")
19
+ const hasLabel = hasJSXAttribute(node, "aria-label")
20
+ const hasLabelledBy = hasJSXAttribute(node, "aria-labelledby")
21
+
22
+ if (!hasRole && !hasLabel && !hasLabelledBy) {
23
+ context.report({
24
+ message:
25
+ "`<Overlay>` missing `role`, `aria-label`, or `aria-labelledby` — provide accessibility attributes for screen readers.",
26
+ span: getSpan(node),
27
+ })
28
+ }
29
+ },
30
+ }
31
+ return callbacks
32
+ },
33
+ }
@@ -0,0 +1,38 @@
1
+ import type { Rule, VisitorCallbacks } from "../../types"
2
+ import { getSpan, hasJSXAttribute } from "../../utils/ast"
3
+
4
+ export const toastA11y: Rule = {
5
+ meta: {
6
+ id: "pyreon/toast-a11y",
7
+ category: "accessibility",
8
+ description: "Warn when toast-like components are missing role or aria-live attributes.",
9
+ severity: "warn",
10
+ fixable: false,
11
+ },
12
+ create(context) {
13
+ const callbacks: VisitorCallbacks = {
14
+ JSXOpeningElement(node: any) {
15
+ const name = node.name
16
+ if (!name || name.type !== "JSXIdentifier") return
17
+
18
+ const tagName: string = name.name
19
+ // Skip non-PascalCase and the Toaster container itself
20
+ if (tagName === "Toaster") return
21
+ const firstChar = tagName[0]
22
+ if (!firstChar || firstChar !== firstChar.toUpperCase()) return
23
+ if (!tagName.toLowerCase().includes("toast")) return
24
+
25
+ const hasRole = hasJSXAttribute(node, "role")
26
+ const hasAriaLive = hasJSXAttribute(node, "aria-live")
27
+
28
+ if (!hasRole && !hasAriaLive) {
29
+ context.report({
30
+ message: `Toast component \`<${tagName}>\` missing \`role\` or \`aria-live\` — add \`role="alert"\` and \`aria-live="polite"\` for screen reader accessibility.`,
31
+ span: getSpan(node),
32
+ })
33
+ }
34
+ },
35
+ }
36
+ return callbacks
37
+ },
38
+ }
@@ -0,0 +1,57 @@
1
+ import type { Rule, VisitorCallbacks } from "../../types"
2
+ import { getSpan } from "../../utils/ast"
3
+
4
+ export const devGuardWarnings: Rule = {
5
+ meta: {
6
+ id: "pyreon/dev-guard-warnings",
7
+ category: "architecture",
8
+ description: "Require console.warn/error calls to be wrapped in `if (__DEV__)` guards.",
9
+ severity: "error",
10
+ fixable: false,
11
+ },
12
+ create(context) {
13
+ const filePath = context.getFilePath()
14
+ // Skip test and example files
15
+ if (
16
+ filePath.includes("/tests/") ||
17
+ filePath.includes("/test/") ||
18
+ filePath.includes("/examples/") ||
19
+ filePath.includes(".test.") ||
20
+ filePath.includes(".spec.")
21
+ ) {
22
+ return {}
23
+ }
24
+
25
+ let devGuardDepth = 0
26
+ const callbacks: VisitorCallbacks = {
27
+ IfStatement(node: any) {
28
+ if (node.test?.type === "Identifier" && node.test.name === "__DEV__") {
29
+ devGuardDepth++
30
+ }
31
+ },
32
+ "IfStatement:exit"(node: any) {
33
+ if (node.test?.type === "Identifier" && node.test.name === "__DEV__") {
34
+ devGuardDepth--
35
+ }
36
+ },
37
+ CallExpression(node: any) {
38
+ if (devGuardDepth > 0) return
39
+
40
+ const callee = node.callee
41
+ if (
42
+ callee?.type === "MemberExpression" &&
43
+ callee.object?.type === "Identifier" &&
44
+ callee.object.name === "console" &&
45
+ callee.property?.type === "Identifier" &&
46
+ (callee.property.name === "warn" || callee.property.name === "error")
47
+ ) {
48
+ context.report({
49
+ message: `\`console.${callee.property.name}()\` without \`__DEV__\` guard — dev warnings must be tree-shakeable in production. Wrap in \`if (__DEV__) { ... }\`.`,
50
+ span: getSpan(node),
51
+ })
52
+ }
53
+ },
54
+ }
55
+ return callbacks
56
+ },
57
+ }
@@ -0,0 +1,59 @@
1
+ import type { Rule, VisitorCallbacks } from "../../types"
2
+ import { getSpan } from "../../utils/ast"
3
+ import { isPyreonImport } from "../../utils/imports"
4
+
5
+ const LAYER_ORDER: Record<string, number> = {
6
+ "@pyreon/reactivity": 0,
7
+ "@pyreon/core": 1,
8
+ "@pyreon/compiler": 1,
9
+ "@pyreon/runtime-dom": 2,
10
+ "@pyreon/runtime-server": 2,
11
+ "@pyreon/router": 3,
12
+ "@pyreon/head": 4,
13
+ "@pyreon/server": 5,
14
+ }
15
+
16
+ function getLayer(source: string): number | null {
17
+ return LAYER_ORDER[source] ?? null
18
+ }
19
+
20
+ function getFileLayer(filePath: string): number | null {
21
+ for (const [pkg, layer] of Object.entries(LAYER_ORDER)) {
22
+ const pkgName = pkg.replace("@pyreon/", "")
23
+ if (filePath.includes(`/packages/core/${pkgName}/`)) return layer
24
+ }
25
+ return null
26
+ }
27
+
28
+ export const noCircularImport: Rule = {
29
+ meta: {
30
+ id: "pyreon/no-circular-import",
31
+ category: "architecture",
32
+ description: "Enforce package layer order to prevent circular imports between core packages.",
33
+ severity: "error",
34
+ fixable: false,
35
+ },
36
+ create(context) {
37
+ const filePath = context.getFilePath()
38
+ const fileLayer = getFileLayer(filePath)
39
+ if (fileLayer === null) return {}
40
+
41
+ const callbacks: VisitorCallbacks = {
42
+ ImportDeclaration(node: any) {
43
+ const source = node.source?.value as string
44
+ if (!source || !isPyreonImport(source)) return
45
+
46
+ const importLayer = getLayer(source)
47
+ if (importLayer === null) return
48
+
49
+ if (importLayer >= fileLayer) {
50
+ context.report({
51
+ message: `Importing \`${source}\` (layer ${importLayer}) from layer ${fileLayer} — this violates the package layer order and may cause circular imports.`,
52
+ span: getSpan(node),
53
+ })
54
+ }
55
+ },
56
+ }
57
+ return callbacks
58
+ },
59
+ }
@@ -0,0 +1,75 @@
1
+ import type { Rule, VisitorCallbacks } from "../../types"
2
+ import { getSpan } from "../../utils/ast"
3
+ import { isPyreonImport } from "../../utils/imports"
4
+
5
+ type PackageCategory = "core" | "fundamentals" | "tools" | "ui-system"
6
+
7
+ const CORE_PACKAGES = new Set([
8
+ "@pyreon/reactivity",
9
+ "@pyreon/core",
10
+ "@pyreon/compiler",
11
+ "@pyreon/runtime-dom",
12
+ "@pyreon/runtime-server",
13
+ "@pyreon/router",
14
+ "@pyreon/head",
15
+ "@pyreon/server",
16
+ ])
17
+
18
+ const UI_PACKAGES = new Set([
19
+ "@pyreon/ui-core",
20
+ "@pyreon/styler",
21
+ "@pyreon/unistyle",
22
+ "@pyreon/elements",
23
+ "@pyreon/attrs",
24
+ "@pyreon/rocketstyle",
25
+ "@pyreon/coolgrid",
26
+ "@pyreon/kinetic",
27
+ "@pyreon/kinetic-presets",
28
+ "@pyreon/connector-document",
29
+ "@pyreon/document-primitives",
30
+ ])
31
+
32
+ function getImportCategory(source: string): PackageCategory | null {
33
+ if (CORE_PACKAGES.has(source)) return "core"
34
+ if (UI_PACKAGES.has(source)) return "ui-system"
35
+ return null
36
+ }
37
+
38
+ function getFileCategory(filePath: string): PackageCategory | null {
39
+ if (filePath.includes("/packages/core/")) return "core"
40
+ if (filePath.includes("/packages/ui-system/")) return "ui-system"
41
+ if (filePath.includes("/packages/fundamentals/")) return "fundamentals"
42
+ if (filePath.includes("/packages/tools/")) return "tools"
43
+ return null
44
+ }
45
+
46
+ export const noCrossLayerImport: Rule = {
47
+ meta: {
48
+ id: "pyreon/no-cross-layer-import",
49
+ category: "architecture",
50
+ description: "Prevent core packages from importing ui-system packages.",
51
+ severity: "error",
52
+ fixable: false,
53
+ },
54
+ create(context) {
55
+ const filePath = context.getFilePath()
56
+ const fileCategory = getFileCategory(filePath)
57
+ if (fileCategory !== "core") return {}
58
+
59
+ const callbacks: VisitorCallbacks = {
60
+ ImportDeclaration(node: any) {
61
+ const source = node.source?.value as string
62
+ if (!source || !isPyreonImport(source)) return
63
+
64
+ const importCategory = getImportCategory(source)
65
+ if (importCategory === "ui-system") {
66
+ context.report({
67
+ message: `Core package importing ui-system package \`${source}\` — core packages must not depend on ui-system.`,
68
+ span: getSpan(node),
69
+ })
70
+ }
71
+ },
72
+ }
73
+ return callbacks
74
+ },
75
+ }
@@ -0,0 +1,32 @@
1
+ import type { Rule, VisitorCallbacks } from "../../types"
2
+ import { getSpan } from "../../utils/ast"
3
+ import { isPyreonImport } from "../../utils/imports"
4
+
5
+ const DEEP_IMPORT_PATTERN = /@pyreon\/[^/]+\/(src|dist|lib)\//
6
+
7
+ export const noDeepImport: Rule = {
8
+ meta: {
9
+ id: "pyreon/no-deep-import",
10
+ category: "architecture",
11
+ description:
12
+ "Disallow importing from @pyreon/*/src/, /dist/, or /lib/ — use public exports instead.",
13
+ severity: "warn",
14
+ fixable: false,
15
+ },
16
+ create(context) {
17
+ const callbacks: VisitorCallbacks = {
18
+ ImportDeclaration(node: any) {
19
+ const source = node.source?.value as string
20
+ if (!source || !isPyreonImport(source)) return
21
+
22
+ if (DEEP_IMPORT_PATTERN.test(source)) {
23
+ context.report({
24
+ message: `Deep import \`${source}\` — import from the package's public exports instead.`,
25
+ span: getSpan(node),
26
+ })
27
+ }
28
+ },
29
+ }
30
+ return callbacks
31
+ },
32
+ }
@@ -0,0 +1,75 @@
1
+ import type { Rule, VisitorCallbacks } from "../../types"
2
+ import { getSpan } from "../../utils/ast"
3
+
4
+ export const noErrorWithoutPrefix: Rule = {
5
+ meta: {
6
+ id: "pyreon/no-error-without-prefix",
7
+ category: "architecture",
8
+ description: "Require error messages to be prefixed with [Pyreon].",
9
+ severity: "warn",
10
+ fixable: true,
11
+ },
12
+ create(context) {
13
+ const filePath = context.getFilePath()
14
+ // Skip test files
15
+ if (
16
+ filePath.includes("/tests/") ||
17
+ filePath.includes("/test/") ||
18
+ filePath.includes(".test.") ||
19
+ filePath.includes(".spec.")
20
+ ) {
21
+ return {}
22
+ }
23
+
24
+ const callbacks: VisitorCallbacks = {
25
+ ThrowStatement(node: any) {
26
+ const arg = node.argument
27
+ if (!arg || arg.type !== "NewExpression") return
28
+ const callee = arg.callee
29
+ if (!callee || callee.type !== "Identifier" || callee.name !== "Error") return
30
+
31
+ const args = arg.arguments
32
+ if (!args || args.length === 0) return
33
+
34
+ const firstArg = args[0]
35
+ if (!firstArg) return
36
+
37
+ if (firstArg.type === "Literal" || firstArg.type === "StringLiteral") {
38
+ const value = firstArg.value as string
39
+ if (typeof value === "string" && !value.startsWith("[Pyreon]")) {
40
+ const argSpan = getSpan(firstArg)
41
+ // Fix: add [Pyreon] prefix
42
+ const quote = context.getSourceText()[argSpan.start]
43
+ const fixedValue = `${quote}[Pyreon] ${value}${quote}`
44
+ context.report({
45
+ message:
46
+ "Error message missing `[Pyreon]` prefix — all framework errors should be prefixed for identification.",
47
+ span: getSpan(node),
48
+ fix: { span: argSpan, replacement: fixedValue },
49
+ })
50
+ }
51
+ }
52
+
53
+ if (firstArg.type === "TemplateLiteral") {
54
+ const quasis = firstArg.quasis
55
+ if (quasis && quasis.length > 0) {
56
+ const first = quasis[0]
57
+ const raw = first.value?.raw ?? first.value?.cooked ?? ""
58
+ if (!raw.startsWith("[Pyreon]")) {
59
+ const argSpan = getSpan(firstArg)
60
+ const source = context.getSourceText().slice(argSpan.start, argSpan.end)
61
+ const fixed = source.replace(/^`/, "`[Pyreon] ")
62
+ context.report({
63
+ message:
64
+ "Error message missing `[Pyreon]` prefix — all framework errors should be prefixed for identification.",
65
+ span: getSpan(node),
66
+ fix: { span: argSpan, replacement: fixed },
67
+ })
68
+ }
69
+ }
70
+ }
71
+ },
72
+ }
73
+ return callbacks
74
+ },
75
+ }
@@ -0,0 +1,45 @@
1
+ import type { Rule, VisitorCallbacks } from "../../types"
2
+ import { getSpan, isCallTo } from "../../utils/ast"
3
+
4
+ export const noSubmitWithoutValidation: Rule = {
5
+ meta: {
6
+ id: "pyreon/no-submit-without-validation",
7
+ category: "form",
8
+ description: "Warn when useForm() has onSubmit but no validators or schema.",
9
+ severity: "warn",
10
+ fixable: false,
11
+ },
12
+ create(context) {
13
+ const callbacks: VisitorCallbacks = {
14
+ CallExpression(node: any) {
15
+ if (!isCallTo(node, "useForm")) return
16
+ const args = node.arguments
17
+ if (!args || args.length === 0) return
18
+
19
+ const options = args[0]
20
+ if (!options || options.type !== "ObjectExpression") return
21
+
22
+ let hasOnSubmit = false
23
+ let hasValidation = false
24
+
25
+ for (const prop of options.properties ?? []) {
26
+ if (prop.type !== "Property") continue
27
+ const key = prop.key
28
+ if (!key) continue
29
+ const name = key.type === "Identifier" ? key.name : null
30
+ if (name === "onSubmit") hasOnSubmit = true
31
+ if (name === "validators" || name === "schema") hasValidation = true
32
+ }
33
+
34
+ if (hasOnSubmit && !hasValidation) {
35
+ context.report({
36
+ message:
37
+ "`useForm()` has `onSubmit` without `validators` or `schema` — consider adding validation for data integrity.",
38
+ span: getSpan(node),
39
+ })
40
+ }
41
+ },
42
+ }
43
+ return callbacks
44
+ },
45
+ }
@@ -0,0 +1,45 @@
1
+ import type { Rule, VisitorCallbacks } from "../../types"
2
+ import { getSpan, isCallTo } from "../../utils/ast"
3
+
4
+ export const noUnregisteredField: Rule = {
5
+ meta: {
6
+ id: "pyreon/no-unregistered-field",
7
+ category: "form",
8
+ description: "Warn when useField() is called without a corresponding register() call.",
9
+ severity: "warn",
10
+ fixable: false,
11
+ },
12
+ create(context) {
13
+ const fieldDecls = new Map<string, { span: { start: number; end: number } }>()
14
+ const registeredNames = new Set<string>()
15
+
16
+ const callbacks: VisitorCallbacks = {
17
+ VariableDeclarator(node: any) {
18
+ const init = node.init
19
+ if (!init || !isCallTo(init, "useField")) return
20
+ const id = node.id
21
+ if (!id || id.type !== "Identifier") return
22
+ fieldDecls.set(id.name, { span: getSpan(node) })
23
+ },
24
+ CallExpression(node: any) {
25
+ const callee = node.callee
26
+ if (!callee || callee.type !== "MemberExpression") return
27
+ if (callee.property?.type !== "Identifier" || callee.property.name !== "register") return
28
+ if (callee.object?.type === "Identifier") {
29
+ registeredNames.add(callee.object.name)
30
+ }
31
+ },
32
+ "Program:exit"() {
33
+ for (const [name, { span }] of fieldDecls) {
34
+ if (!registeredNames.has(name)) {
35
+ context.report({
36
+ message: `\`useField()\` result \`${name}\` is never registered — call \`${name}.register()\` to connect it to the form.`,
37
+ span,
38
+ })
39
+ }
40
+ }
41
+ },
42
+ }
43
+ return callbacks
44
+ },
45
+ }
@@ -0,0 +1,41 @@
1
+ import type { Rule, VisitorCallbacks } from "../../types"
2
+ import { getSpan, isCallTo } from "../../utils/ast"
3
+ import { extractImportInfo } from "../../utils/imports"
4
+
5
+ export const preferFieldArray: Rule = {
6
+ meta: {
7
+ id: "pyreon/prefer-field-array",
8
+ category: "form",
9
+ description: "Suggest useFieldArray() instead of signal([]) in files that import @pyreon/form.",
10
+ severity: "info",
11
+ fixable: false,
12
+ },
13
+ create(context) {
14
+ let importsForm = false
15
+
16
+ const callbacks: VisitorCallbacks = {
17
+ ImportDeclaration(node: any) {
18
+ const info = extractImportInfo(node)
19
+ if (info && info.source === "@pyreon/form") {
20
+ importsForm = true
21
+ }
22
+ },
23
+ CallExpression(node: any) {
24
+ if (!importsForm) return
25
+ if (!isCallTo(node, "signal")) return
26
+
27
+ const args = node.arguments
28
+ if (!args || args.length === 0) return
29
+ const firstArg = args[0]
30
+ if (firstArg?.type === "ArrayExpression") {
31
+ context.report({
32
+ message:
33
+ "`signal([])` in a form file — consider using `useFieldArray()` for dynamic array fields with stable keys.",
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 } from "../../utils/ast"
3
+
4
+ export const noRawAddEventListener: Rule = {
5
+ meta: {
6
+ id: "pyreon/no-raw-addeventlistener",
7
+ category: "hooks",
8
+ description: "Suggest useEventListener() instead of raw .addEventListener() calls.",
9
+ severity: "info",
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 !== "addEventListener")
18
+ return
19
+ context.report({
20
+ message:
21
+ "Raw `.addEventListener()` — consider using `useEventListener()` from `@pyreon/hooks` for auto-cleanup on unmount.",
22
+ span: getSpan(node),
23
+ })
24
+ },
25
+ }
26
+ return callbacks
27
+ },
28
+ }
@@ -0,0 +1,35 @@
1
+ import type { Rule, VisitorCallbacks } from "../../types"
2
+ import { getSpan } from "../../utils/ast"
3
+
4
+ const STORAGE_OBJECTS = new Set(["localStorage", "sessionStorage"])
5
+ const STORAGE_METHODS = new Set(["getItem", "setItem", "removeItem"])
6
+
7
+ export const noRawLocalStorage: Rule = {
8
+ meta: {
9
+ id: "pyreon/no-raw-localstorage",
10
+ category: "hooks",
11
+ description: "Suggest useStorage() instead of raw localStorage/sessionStorage access.",
12
+ severity: "info",
13
+ fixable: false,
14
+ },
15
+ create(context) {
16
+ const callbacks: VisitorCallbacks = {
17
+ CallExpression(node: any) {
18
+ const callee = node.callee
19
+ if (!callee || callee.type !== "MemberExpression") return
20
+ if (
21
+ callee.object?.type === "Identifier" &&
22
+ STORAGE_OBJECTS.has(callee.object.name) &&
23
+ callee.property?.type === "Identifier" &&
24
+ STORAGE_METHODS.has(callee.property.name)
25
+ ) {
26
+ context.report({
27
+ message: `Raw \`${callee.object.name}.${callee.property.name}()\` — consider using \`useStorage()\` from \`@pyreon/storage\` for reactive, cross-tab synced storage.`,
28
+ span: getSpan(node),
29
+ })
30
+ }
31
+ },
32
+ }
33
+ return callbacks
34
+ },
35
+ }
@@ -0,0 +1,41 @@
1
+ import type { Rule, VisitorCallbacks } from "../../types"
2
+ import { getSpan, isCallTo } from "../../utils/ast"
3
+
4
+ const TIMER_FNS = new Set(["setInterval", "setTimeout"])
5
+
6
+ export const noRawSetInterval: Rule = {
7
+ meta: {
8
+ id: "pyreon/no-raw-setinterval",
9
+ category: "hooks",
10
+ description: "Suggest wrapping setInterval/setTimeout in onMount for automatic cleanup.",
11
+ severity: "info",
12
+ fixable: false,
13
+ },
14
+ create(context) {
15
+ let mountDepth = 0
16
+ const callbacks: VisitorCallbacks = {
17
+ CallExpression(node: any) {
18
+ if (isCallTo(node, "onMount")) {
19
+ mountDepth++
20
+ }
21
+
22
+ if (mountDepth > 0) return
23
+
24
+ const callee = node.callee
25
+ if (!callee || callee.type !== "Identifier") return
26
+ if (TIMER_FNS.has(callee.name)) {
27
+ context.report({
28
+ message: `\`${callee.name}()\` outside \`onMount\` — wrap in \`onMount(() => { ... return () => clear... })\` for automatic cleanup.`,
29
+ span: getSpan(node),
30
+ })
31
+ }
32
+ },
33
+ "CallExpression:exit"(node: any) {
34
+ if (isCallTo(node, "onMount")) {
35
+ mountDepth--
36
+ }
37
+ },
38
+ }
39
+ return callbacks
40
+ },
41
+ }