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