@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,59 @@
1
+ import type { Rule, VisitorCallbacks } from "../../types"
2
+ import { getSpan } from "../../utils/ast"
3
+ import { extractImportInfo } from "../../utils/imports"
4
+
5
+ export const noStoreOutsideProvider: Rule = {
6
+ meta: {
7
+ id: "pyreon/no-store-outside-provider",
8
+ category: "store",
9
+ description: "Warn when store hooks are used in SSR files without a provider import.",
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 hasProviderImport = false
24
+ const storeHookCalls: Array<{ name: string; span: { start: number; end: number } }> = []
25
+
26
+ const callbacks: VisitorCallbacks = {
27
+ ImportDeclaration(node: any) {
28
+ const info = extractImportInfo(node)
29
+ if (!info) return
30
+ if (
31
+ info.specifiers.some(
32
+ (s) =>
33
+ s.imported === "setStoreRegistryProvider" || s.imported === "runWithRequestContext",
34
+ )
35
+ ) {
36
+ hasProviderImport = true
37
+ }
38
+ },
39
+ CallExpression(node: any) {
40
+ const callee = node.callee
41
+ if (!callee || callee.type !== "Identifier") return
42
+ const name: string = callee.name
43
+ if (name.endsWith("Store") && name.startsWith("use")) {
44
+ storeHookCalls.push({ name, span: getSpan(node) })
45
+ }
46
+ },
47
+ "Program:exit"() {
48
+ if (hasProviderImport) return
49
+ for (const call of storeHookCalls) {
50
+ context.report({
51
+ message: `\`${call.name}()\` in a server file without a store registry provider — use \`runWithRequestContext()\` or \`setStoreRegistryProvider()\` for SSR isolation.`,
52
+ span: call.span,
53
+ })
54
+ }
55
+ },
56
+ }
57
+ return callbacks
58
+ },
59
+ }
@@ -0,0 +1,60 @@
1
+ import type { Rule, VisitorCallbacks } from "../../types"
2
+ import { getSpan, isCallTo } from "../../utils/ast"
3
+
4
+ export const noDynamicStyled: Rule = {
5
+ meta: {
6
+ id: "pyreon/no-dynamic-styled",
7
+ category: "styling",
8
+ description:
9
+ "Warn when styled() is called inside a function — it creates new CSS on every render.",
10
+ severity: "warn",
11
+ fixable: false,
12
+ },
13
+ create(context) {
14
+ let functionDepth = 0
15
+ const callbacks: VisitorCallbacks = {
16
+ FunctionDeclaration() {
17
+ functionDepth++
18
+ },
19
+ "FunctionDeclaration:exit"() {
20
+ functionDepth--
21
+ },
22
+ FunctionExpression() {
23
+ functionDepth++
24
+ },
25
+ "FunctionExpression:exit"() {
26
+ functionDepth--
27
+ },
28
+ ArrowFunctionExpression() {
29
+ functionDepth++
30
+ },
31
+ "ArrowFunctionExpression:exit"() {
32
+ functionDepth--
33
+ },
34
+ CallExpression(node: any) {
35
+ if (functionDepth === 0) return
36
+ if (isCallTo(node, "styled")) {
37
+ context.report({
38
+ message:
39
+ "`styled()` inside a function — this creates new CSS rules on every render. Move `styled()` to module scope.",
40
+ span: getSpan(node),
41
+ })
42
+ }
43
+ },
44
+ TaggedTemplateExpression(node: any) {
45
+ if (functionDepth === 0) return
46
+ const tag = node.tag
47
+ if (!tag) return
48
+ // styled('div')`...` — tag is a CallExpression of styled
49
+ if (tag.type === "CallExpression" && isCallTo(tag, "styled")) {
50
+ context.report({
51
+ message:
52
+ "`styled()` tagged template inside a function — this creates new CSS rules on every render. Move to module scope.",
53
+ span: getSpan(node),
54
+ })
55
+ }
56
+ },
57
+ }
58
+ return callbacks
59
+ },
60
+ }
@@ -0,0 +1,30 @@
1
+ import type { Rule, VisitorCallbacks } from "../../types"
2
+ import { getSpan } from "../../utils/ast"
3
+
4
+ export const noInlineStyleObject: Rule = {
5
+ meta: {
6
+ id: "pyreon/no-inline-style-object",
7
+ category: "styling",
8
+ description: "Warn against inline style objects in JSX — prefer styled() or css``.",
9
+ severity: "warn",
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?.type === "ObjectExpression") {
20
+ context.report({
21
+ message:
22
+ "Inline style object in JSX — consider using `styled()` or `css\\`...\\`` for better performance and caching.",
23
+ span: getSpan(node),
24
+ })
25
+ }
26
+ },
27
+ }
28
+ return callbacks
29
+ },
30
+ }
@@ -0,0 +1,45 @@
1
+ import type { Rule, VisitorCallbacks } from "../../types"
2
+ import { getSpan, isCallTo } from "../../utils/ast"
3
+ import { extractImportInfo } from "../../utils/imports"
4
+
5
+ export const noThemeOutsideProvider: Rule = {
6
+ meta: {
7
+ id: "pyreon/no-theme-outside-provider",
8
+ category: "styling",
9
+ description: "Warn when useTheme() is used without PyreonUI or ThemeProvider in the same file.",
10
+ severity: "warn",
11
+ fixable: false,
12
+ },
13
+ create(context) {
14
+ let hasProviderImport = false
15
+ const themeCalls: Array<{ span: { start: number; end: number } }> = []
16
+
17
+ const callbacks: VisitorCallbacks = {
18
+ ImportDeclaration(node: any) {
19
+ const info = extractImportInfo(node)
20
+ if (!info) return
21
+ if (
22
+ info.specifiers.some((s) => s.imported === "PyreonUI" || s.imported === "ThemeProvider")
23
+ ) {
24
+ hasProviderImport = true
25
+ }
26
+ },
27
+ CallExpression(node: any) {
28
+ if (isCallTo(node, "useTheme")) {
29
+ themeCalls.push({ span: getSpan(node) })
30
+ }
31
+ },
32
+ "Program:exit"() {
33
+ if (hasProviderImport) return
34
+ for (const call of themeCalls) {
35
+ context.report({
36
+ message:
37
+ "`useTheme()` without a `PyreonUI` or `ThemeProvider` import — the theme context may not be available.",
38
+ span: call.span,
39
+ })
40
+ }
41
+ },
42
+ }
43
+ return callbacks
44
+ },
45
+ }
@@ -0,0 +1,44 @@
1
+ import type { Rule, VisitorCallbacks } from "../../types"
2
+ import { getSpan } from "../../utils/ast"
3
+
4
+ export const preferCx: Rule = {
5
+ meta: {
6
+ id: "pyreon/prefer-cx",
7
+ category: "styling",
8
+ description:
9
+ "Suggest cx() for class composition instead of string concatenation or template literals.",
10
+ severity: "info",
11
+ fixable: false,
12
+ },
13
+ create(context) {
14
+ const callbacks: VisitorCallbacks = {
15
+ JSXAttribute(node: any) {
16
+ if (node.name?.type !== "JSXIdentifier" || node.name.name !== "class") return
17
+ const value = node.value
18
+ if (!value || value.type !== "JSXExpressionContainer") return
19
+ const expr = value.expression
20
+ if (!expr) return
21
+
22
+ // String concatenation: "foo " + bar
23
+ if (expr.type === "BinaryExpression" && expr.operator === "+") {
24
+ context.report({
25
+ message:
26
+ "String concatenation in `class` attribute — use `cx()` for cleaner class composition.",
27
+ span: getSpan(expr),
28
+ })
29
+ return
30
+ }
31
+
32
+ // Template literal: `foo ${bar}`
33
+ if (expr.type === "TemplateLiteral" && expr.expressions?.length > 0) {
34
+ context.report({
35
+ message:
36
+ "Template literal in `class` attribute — use `cx()` for cleaner class composition.",
37
+ span: getSpan(expr),
38
+ })
39
+ }
40
+ },
41
+ }
42
+ return callbacks
43
+ },
44
+ }
package/src/runner.ts ADDED
@@ -0,0 +1,170 @@
1
+ import { parseSync, Visitor } from "oxc-parser"
2
+ import type { AstCache } from "./cache"
3
+ import type {
4
+ Diagnostic,
5
+ LintConfig,
6
+ LintFileResult,
7
+ Rule,
8
+ RuleContext,
9
+ Severity,
10
+ VisitorCallbacks,
11
+ } from "./types"
12
+ import { LineIndex } from "./utils/source"
13
+
14
+ const JS_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mts", ".mjs"])
15
+
16
+ function getExtension(filePath: string): string {
17
+ const lastDot = filePath.lastIndexOf(".")
18
+ return lastDot === -1 ? "" : filePath.slice(lastDot)
19
+ }
20
+
21
+ type OxcLang = "jsx" | "tsx" | "ts" | "js" | "dts"
22
+
23
+ function getLang(ext: string): OxcLang {
24
+ if (ext === ".tsx" || ext === ".jsx") return "tsx"
25
+ if (ext === ".ts" || ext === ".mts") return "ts"
26
+ return "js"
27
+ }
28
+
29
+ function createRuleContext(
30
+ rule: Rule,
31
+ severity: Severity,
32
+ diagnostics: Diagnostic[],
33
+ lineIndex: LineIndex,
34
+ sourceText: string,
35
+ filePath: string,
36
+ ): RuleContext {
37
+ return {
38
+ report(partial) {
39
+ diagnostics.push({
40
+ ruleId: rule.meta.id,
41
+ severity,
42
+ message: partial.message,
43
+ span: partial.span,
44
+ loc: lineIndex.locate(partial.span.start),
45
+ fix: partial.fix,
46
+ })
47
+ },
48
+ getSourceText() {
49
+ return sourceText
50
+ },
51
+ getFilePath() {
52
+ return filePath
53
+ },
54
+ }
55
+ }
56
+
57
+ function mergeCallbacks(allCallbacks: VisitorCallbacks[]): Record<string, (node: any) => void> {
58
+ const callbacksByKey: Record<string, Array<(node: any) => void>> = {}
59
+
60
+ for (const callbacks of allCallbacks) {
61
+ for (const [key, fn] of Object.entries(callbacks)) {
62
+ const existing = callbacksByKey[key]
63
+ if (existing) {
64
+ existing.push(fn as (node: any) => void)
65
+ } else {
66
+ callbacksByKey[key] = [fn as (node: any) => void]
67
+ }
68
+ }
69
+ }
70
+
71
+ const merged: Record<string, (node: any) => void> = {}
72
+ for (const [key, fns] of Object.entries(callbacksByKey)) {
73
+ const first = fns[0]
74
+ if (fns.length === 1 && first) {
75
+ merged[key] = first
76
+ } else {
77
+ merged[key] = (node: any) => {
78
+ for (const fn of fns) fn(node)
79
+ }
80
+ }
81
+ }
82
+ return merged
83
+ }
84
+
85
+ /**
86
+ * Lint a single file and return diagnostics.
87
+ *
88
+ * @example
89
+ * ```ts
90
+ * const result = lintFile("app.tsx", source, allRules, getPreset("recommended"))
91
+ * for (const d of result.diagnostics) console.log(d.message)
92
+ * ```
93
+ */
94
+ export function lintFile(
95
+ filePath: string,
96
+ sourceText: string,
97
+ rules: Rule[],
98
+ config: LintConfig,
99
+ cache?: AstCache | undefined,
100
+ ): LintFileResult {
101
+ const ext = getExtension(filePath)
102
+ if (!JS_EXTENSIONS.has(ext)) {
103
+ return { filePath, diagnostics: [] }
104
+ }
105
+
106
+ // Try cache first
107
+ let lineIndex: LineIndex
108
+ let program: any
109
+ const cached = cache?.get(sourceText)
110
+ if (cached) {
111
+ lineIndex = cached.lineIndex
112
+ program = cached.program
113
+ } else {
114
+ lineIndex = new LineIndex(sourceText)
115
+ try {
116
+ const result = parseSync(filePath, sourceText, {
117
+ sourceType: "module",
118
+ lang: getLang(ext),
119
+ })
120
+ program = result.program
121
+ } catch {
122
+ return { filePath, diagnostics: [] }
123
+ }
124
+ cache?.set(sourceText, { program, lineIndex })
125
+ }
126
+
127
+ const diagnostics: Diagnostic[] = []
128
+
129
+ // Filter to enabled rules and create visitor callbacks
130
+ const allCallbacks: VisitorCallbacks[] = []
131
+ for (const rule of rules) {
132
+ const severity = config.rules[rule.meta.id]
133
+ if (severity === undefined || severity === "off") continue
134
+ const ctx = createRuleContext(rule, severity, diagnostics, lineIndex, sourceText, filePath)
135
+ allCallbacks.push(rule.create(ctx))
136
+ }
137
+
138
+ // Walk the AST
139
+ const visitor = new Visitor(mergeCallbacks(allCallbacks))
140
+ visitor.visit(program)
141
+
142
+ diagnostics.sort((a, b) => a.span.start - b.span.start)
143
+ return { filePath, diagnostics }
144
+ }
145
+
146
+ /**
147
+ * Apply all auto-fixes to a source text.
148
+ * Fixes are applied in reverse order to maintain correct offsets.
149
+ */
150
+ export function applyFixes(sourceText: string, diagnostics: Diagnostic[]): string {
151
+ const fixable = diagnostics.filter((d) => d.fix !== undefined)
152
+ if (fixable.length === 0) return sourceText
153
+
154
+ // Sort by start position descending (apply from end to start)
155
+ const sorted = [...fixable].sort((a, b) => {
156
+ const aFix = a.fix
157
+ const bFix = b.fix
158
+ if (!aFix || !bFix) return 0
159
+ return bFix.span.start - aFix.span.start
160
+ })
161
+
162
+ let result = sourceText
163
+ for (const diag of sorted) {
164
+ const fix = diag.fix
165
+ if (!fix) continue
166
+ result = result.slice(0, fix.span.start) + fix.replacement + result.slice(fix.span.end)
167
+ }
168
+
169
+ return result
170
+ }