@pyreon/lint 0.11.5 → 0.11.7

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 (86) hide show
  1. package/README.md +91 -91
  2. package/lib/analysis/cli.js.html +1 -1
  3. package/lib/analysis/index.js.html +1 -1
  4. package/lib/cli.js +214 -1
  5. package/lib/cli.js.map +1 -1
  6. package/lib/index.js +207 -1
  7. package/lib/index.js.map +1 -1
  8. package/lib/types/index.d.ts +30 -5
  9. package/lib/types/index.d.ts.map +1 -1
  10. package/package.json +15 -15
  11. package/src/cache.ts +1 -1
  12. package/src/cli.ts +38 -28
  13. package/src/config/ignore.ts +23 -23
  14. package/src/config/loader.ts +8 -8
  15. package/src/config/presets.ts +11 -11
  16. package/src/index.ts +14 -12
  17. package/src/lint.ts +19 -19
  18. package/src/lsp/index.ts +225 -0
  19. package/src/reporter.ts +17 -17
  20. package/src/rules/accessibility/dialog-a11y.ts +10 -10
  21. package/src/rules/accessibility/overlay-a11y.ts +11 -11
  22. package/src/rules/accessibility/toast-a11y.ts +11 -11
  23. package/src/rules/architecture/dev-guard-warnings.ts +19 -19
  24. package/src/rules/architecture/no-circular-import.ts +16 -16
  25. package/src/rules/architecture/no-cross-layer-import.ts +35 -35
  26. package/src/rules/architecture/no-deep-import.ts +7 -7
  27. package/src/rules/architecture/no-error-without-prefix.ts +20 -20
  28. package/src/rules/form/no-submit-without-validation.ts +13 -13
  29. package/src/rules/form/no-unregistered-field.ts +12 -12
  30. package/src/rules/form/prefer-field-array.ts +11 -11
  31. package/src/rules/hooks/no-raw-addeventlistener.ts +9 -9
  32. package/src/rules/hooks/no-raw-localstorage.ts +11 -11
  33. package/src/rules/hooks/no-raw-setinterval.ts +11 -11
  34. package/src/rules/index.ts +60 -57
  35. package/src/rules/jsx/no-and-conditional.ts +8 -8
  36. package/src/rules/jsx/no-children-access.ts +12 -12
  37. package/src/rules/jsx/no-classname.ts +10 -10
  38. package/src/rules/jsx/no-htmlfor.ts +10 -10
  39. package/src/rules/jsx/no-index-as-by.ts +17 -17
  40. package/src/rules/jsx/no-map-in-jsx.ts +9 -9
  41. package/src/rules/jsx/no-missing-for-by.ts +9 -9
  42. package/src/rules/jsx/no-onchange.ts +12 -12
  43. package/src/rules/jsx/no-props-destructure.ts +11 -11
  44. package/src/rules/jsx/no-ternary-conditional.ts +8 -8
  45. package/src/rules/jsx/use-by-not-key.ts +12 -12
  46. package/src/rules/lifecycle/no-dom-in-setup.ts +18 -18
  47. package/src/rules/lifecycle/no-effect-in-mount.ts +11 -11
  48. package/src/rules/lifecycle/no-missing-cleanup.ts +19 -19
  49. package/src/rules/lifecycle/no-mount-in-effect.ts +11 -11
  50. package/src/rules/performance/no-eager-import.ts +7 -7
  51. package/src/rules/performance/no-effect-in-for.ts +10 -10
  52. package/src/rules/performance/no-large-for-without-by.ts +9 -9
  53. package/src/rules/performance/prefer-show-over-display.ts +16 -16
  54. package/src/rules/reactivity/no-bare-signal-in-jsx.ts +10 -10
  55. package/src/rules/reactivity/no-context-destructure.ts +45 -0
  56. package/src/rules/reactivity/no-effect-assignment.ts +16 -16
  57. package/src/rules/reactivity/no-nested-effect.ts +10 -10
  58. package/src/rules/reactivity/no-peek-in-tracked.ts +10 -10
  59. package/src/rules/reactivity/no-signal-in-loop.ts +13 -13
  60. package/src/rules/reactivity/no-signal-leak.ts +9 -9
  61. package/src/rules/reactivity/no-unbatched-updates.ts +12 -12
  62. package/src/rules/reactivity/prefer-computed.ts +13 -13
  63. package/src/rules/router/index.ts +4 -4
  64. package/src/rules/router/no-href-navigation.ts +14 -14
  65. package/src/rules/router/no-imperative-navigate-in-render.ts +19 -19
  66. package/src/rules/router/no-missing-fallback.ts +16 -16
  67. package/src/rules/router/prefer-use-is-active.ts +11 -11
  68. package/src/rules/ssr/no-mismatch-risk.ts +11 -11
  69. package/src/rules/ssr/no-window-in-ssr.ts +22 -22
  70. package/src/rules/ssr/prefer-request-context.ts +14 -14
  71. package/src/rules/store/no-duplicate-store-id.ts +9 -9
  72. package/src/rules/store/no-mutate-store-state.ts +11 -11
  73. package/src/rules/store/no-store-outside-provider.ts +15 -15
  74. package/src/rules/styling/no-dynamic-styled.ts +13 -13
  75. package/src/rules/styling/no-inline-style-object.ts +10 -10
  76. package/src/rules/styling/no-theme-outside-provider.ts +11 -11
  77. package/src/rules/styling/prefer-cx.ts +12 -12
  78. package/src/runner.ts +13 -13
  79. package/src/tests/lsp.test.ts +88 -0
  80. package/src/tests/runner.test.ts +325 -325
  81. package/src/types.ts +15 -15
  82. package/src/utils/ast.ts +50 -50
  83. package/src/utils/imports.ts +53 -53
  84. package/src/utils/index.ts +5 -5
  85. package/src/utils/source.ts +2 -2
  86. package/src/watcher.ts +19 -19
@@ -1,22 +1,22 @@
1
- import type { Rule, VisitorCallbacks } from "../../types"
2
- import { getSpan } from "../../utils/ast"
3
- import { extractImportInfo } from "../../utils/imports"
1
+ import type { Rule, VisitorCallbacks } from '../../types'
2
+ import { getSpan } from '../../utils/ast'
3
+ import { extractImportInfo } from '../../utils/imports'
4
4
 
5
5
  export const noStoreOutsideProvider: Rule = {
6
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",
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
11
  fixable: false,
12
12
  },
13
13
  create(context) {
14
14
  const filePath = context.getFilePath()
15
15
  const isServerFile =
16
- filePath.includes("server") ||
17
- filePath.includes(".server.") ||
18
- filePath.endsWith("server.ts") ||
19
- filePath.endsWith("server.tsx")
16
+ filePath.includes('server') ||
17
+ filePath.includes('.server.') ||
18
+ filePath.endsWith('server.ts') ||
19
+ filePath.endsWith('server.tsx')
20
20
 
21
21
  if (!isServerFile) return {}
22
22
 
@@ -30,7 +30,7 @@ export const noStoreOutsideProvider: Rule = {
30
30
  if (
31
31
  info.specifiers.some(
32
32
  (s) =>
33
- s.imported === "setStoreRegistryProvider" || s.imported === "runWithRequestContext",
33
+ s.imported === 'setStoreRegistryProvider' || s.imported === 'runWithRequestContext',
34
34
  )
35
35
  ) {
36
36
  hasProviderImport = true
@@ -38,13 +38,13 @@ export const noStoreOutsideProvider: Rule = {
38
38
  },
39
39
  CallExpression(node: any) {
40
40
  const callee = node.callee
41
- if (!callee || callee.type !== "Identifier") return
41
+ if (!callee || callee.type !== 'Identifier') return
42
42
  const name: string = callee.name
43
- if (name.endsWith("Store") && name.startsWith("use")) {
43
+ if (name.endsWith('Store') && name.startsWith('use')) {
44
44
  storeHookCalls.push({ name, span: getSpan(node) })
45
45
  }
46
46
  },
47
- "Program:exit"() {
47
+ 'Program:exit'() {
48
48
  if (hasProviderImport) return
49
49
  for (const call of storeHookCalls) {
50
50
  context.report({
@@ -1,13 +1,13 @@
1
- import type { Rule, VisitorCallbacks } from "../../types"
2
- import { getSpan, isCallTo } from "../../utils/ast"
1
+ import type { Rule, VisitorCallbacks } from '../../types'
2
+ import { getSpan, isCallTo } from '../../utils/ast'
3
3
 
4
4
  export const noDynamicStyled: Rule = {
5
5
  meta: {
6
- id: "pyreon/no-dynamic-styled",
7
- category: "styling",
6
+ id: 'pyreon/no-dynamic-styled',
7
+ category: 'styling',
8
8
  description:
9
- "Warn when styled() is called inside a function — it creates new CSS on every render.",
10
- severity: "warn",
9
+ 'Warn when styled() is called inside a function — it creates new CSS on every render.',
10
+ severity: 'warn',
11
11
  fixable: false,
12
12
  },
13
13
  create(context) {
@@ -16,27 +16,27 @@ export const noDynamicStyled: Rule = {
16
16
  FunctionDeclaration() {
17
17
  functionDepth++
18
18
  },
19
- "FunctionDeclaration:exit"() {
19
+ 'FunctionDeclaration:exit'() {
20
20
  functionDepth--
21
21
  },
22
22
  FunctionExpression() {
23
23
  functionDepth++
24
24
  },
25
- "FunctionExpression:exit"() {
25
+ 'FunctionExpression:exit'() {
26
26
  functionDepth--
27
27
  },
28
28
  ArrowFunctionExpression() {
29
29
  functionDepth++
30
30
  },
31
- "ArrowFunctionExpression:exit"() {
31
+ 'ArrowFunctionExpression:exit'() {
32
32
  functionDepth--
33
33
  },
34
34
  CallExpression(node: any) {
35
35
  if (functionDepth === 0) return
36
- if (isCallTo(node, "styled")) {
36
+ if (isCallTo(node, 'styled')) {
37
37
  context.report({
38
38
  message:
39
- "`styled()` inside a function — this creates new CSS rules on every render. Move `styled()` to module scope.",
39
+ '`styled()` inside a function — this creates new CSS rules on every render. Move `styled()` to module scope.',
40
40
  span: getSpan(node),
41
41
  })
42
42
  }
@@ -46,10 +46,10 @@ export const noDynamicStyled: Rule = {
46
46
  const tag = node.tag
47
47
  if (!tag) return
48
48
  // styled('div')`...` — tag is a CallExpression of styled
49
- if (tag.type === "CallExpression" && isCallTo(tag, "styled")) {
49
+ if (tag.type === 'CallExpression' && isCallTo(tag, 'styled')) {
50
50
  context.report({
51
51
  message:
52
- "`styled()` tagged template inside a function — this creates new CSS rules on every render. Move to module scope.",
52
+ '`styled()` tagged template inside a function — this creates new CSS rules on every render. Move to module scope.',
53
53
  span: getSpan(node),
54
54
  })
55
55
  }
@@ -1,25 +1,25 @@
1
- import type { Rule, VisitorCallbacks } from "../../types"
2
- import { getSpan } from "../../utils/ast"
1
+ import type { Rule, VisitorCallbacks } from '../../types'
2
+ import { getSpan } from '../../utils/ast'
3
3
 
4
4
  export const noInlineStyleObject: Rule = {
5
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",
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
10
  fixable: false,
11
11
  },
12
12
  create(context) {
13
13
  const callbacks: VisitorCallbacks = {
14
14
  JSXAttribute(node: any) {
15
- if (node.name?.type !== "JSXIdentifier" || node.name.name !== "style") return
15
+ if (node.name?.type !== 'JSXIdentifier' || node.name.name !== 'style') return
16
16
  const value = node.value
17
- if (!value || value.type !== "JSXExpressionContainer") return
17
+ if (!value || value.type !== 'JSXExpressionContainer') return
18
18
  const expr = value.expression
19
- if (expr?.type === "ObjectExpression") {
19
+ if (expr?.type === 'ObjectExpression') {
20
20
  context.report({
21
21
  message:
22
- "Inline style object in JSX — consider using `styled()` or `css\\`...\\`` for better performance and caching.",
22
+ 'Inline style object in JSX — consider using `styled()` or `css\\`...\\`` for better performance and caching.',
23
23
  span: getSpan(node),
24
24
  })
25
25
  }
@@ -1,13 +1,13 @@
1
- import type { Rule, VisitorCallbacks } from "../../types"
2
- import { getSpan, isCallTo } from "../../utils/ast"
3
- import { extractImportInfo } from "../../utils/imports"
1
+ import type { Rule, VisitorCallbacks } from '../../types'
2
+ import { getSpan, isCallTo } from '../../utils/ast'
3
+ import { extractImportInfo } from '../../utils/imports'
4
4
 
5
5
  export const noThemeOutsideProvider: Rule = {
6
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",
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
11
  fixable: false,
12
12
  },
13
13
  create(context) {
@@ -19,22 +19,22 @@ export const noThemeOutsideProvider: Rule = {
19
19
  const info = extractImportInfo(node)
20
20
  if (!info) return
21
21
  if (
22
- info.specifiers.some((s) => s.imported === "PyreonUI" || s.imported === "ThemeProvider")
22
+ info.specifiers.some((s) => s.imported === 'PyreonUI' || s.imported === 'ThemeProvider')
23
23
  ) {
24
24
  hasProviderImport = true
25
25
  }
26
26
  },
27
27
  CallExpression(node: any) {
28
- if (isCallTo(node, "useTheme")) {
28
+ if (isCallTo(node, 'useTheme')) {
29
29
  themeCalls.push({ span: getSpan(node) })
30
30
  }
31
31
  },
32
- "Program:exit"() {
32
+ 'Program:exit'() {
33
33
  if (hasProviderImport) return
34
34
  for (const call of themeCalls) {
35
35
  context.report({
36
36
  message:
37
- "`useTheme()` without a `PyreonUI` or `ThemeProvider` import — the theme context may not be available.",
37
+ '`useTheme()` without a `PyreonUI` or `ThemeProvider` import — the theme context may not be available.',
38
38
  span: call.span,
39
39
  })
40
40
  }
@@ -1,39 +1,39 @@
1
- import type { Rule, VisitorCallbacks } from "../../types"
2
- import { getSpan } from "../../utils/ast"
1
+ import type { Rule, VisitorCallbacks } from '../../types'
2
+ import { getSpan } from '../../utils/ast'
3
3
 
4
4
  export const preferCx: Rule = {
5
5
  meta: {
6
- id: "pyreon/prefer-cx",
7
- category: "styling",
6
+ id: 'pyreon/prefer-cx',
7
+ category: 'styling',
8
8
  description:
9
- "Suggest cx() for class composition instead of string concatenation or template literals.",
10
- severity: "info",
9
+ 'Suggest cx() for class composition instead of string concatenation or template literals.',
10
+ severity: 'info',
11
11
  fixable: false,
12
12
  },
13
13
  create(context) {
14
14
  const callbacks: VisitorCallbacks = {
15
15
  JSXAttribute(node: any) {
16
- if (node.name?.type !== "JSXIdentifier" || node.name.name !== "class") return
16
+ if (node.name?.type !== 'JSXIdentifier' || node.name.name !== 'class') return
17
17
  const value = node.value
18
- if (!value || value.type !== "JSXExpressionContainer") return
18
+ if (!value || value.type !== 'JSXExpressionContainer') return
19
19
  const expr = value.expression
20
20
  if (!expr) return
21
21
 
22
22
  // String concatenation: "foo " + bar
23
- if (expr.type === "BinaryExpression" && expr.operator === "+") {
23
+ if (expr.type === 'BinaryExpression' && expr.operator === '+') {
24
24
  context.report({
25
25
  message:
26
- "String concatenation in `class` attribute — use `cx()` for cleaner class composition.",
26
+ 'String concatenation in `class` attribute — use `cx()` for cleaner class composition.',
27
27
  span: getSpan(expr),
28
28
  })
29
29
  return
30
30
  }
31
31
 
32
32
  // Template literal: `foo ${bar}`
33
- if (expr.type === "TemplateLiteral" && expr.expressions?.length > 0) {
33
+ if (expr.type === 'TemplateLiteral' && expr.expressions?.length > 0) {
34
34
  context.report({
35
35
  message:
36
- "Template literal in `class` attribute — use `cx()` for cleaner class composition.",
36
+ 'Template literal in `class` attribute — use `cx()` for cleaner class composition.',
37
37
  span: getSpan(expr),
38
38
  })
39
39
  }
package/src/runner.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { parseSync, Visitor } from "oxc-parser"
2
- import type { AstCache } from "./cache"
1
+ import { parseSync, Visitor } from 'oxc-parser'
2
+ import type { AstCache } from './cache'
3
3
  import type {
4
4
  Diagnostic,
5
5
  LintConfig,
@@ -8,21 +8,21 @@ import type {
8
8
  RuleContext,
9
9
  Severity,
10
10
  VisitorCallbacks,
11
- } from "./types"
12
- import { JS_EXTENSIONS } from "./utils/index"
13
- import { LineIndex } from "./utils/source"
11
+ } from './types'
12
+ import { JS_EXTENSIONS } from './utils/index'
13
+ import { LineIndex } from './utils/source'
14
14
 
15
15
  function getExtension(filePath: string): string {
16
- const lastDot = filePath.lastIndexOf(".")
17
- return lastDot === -1 ? "" : filePath.slice(lastDot)
16
+ const lastDot = filePath.lastIndexOf('.')
17
+ return lastDot === -1 ? '' : filePath.slice(lastDot)
18
18
  }
19
19
 
20
- type OxcLang = "jsx" | "tsx" | "ts" | "js" | "dts"
20
+ type OxcLang = 'jsx' | 'tsx' | 'ts' | 'js' | 'dts'
21
21
 
22
22
  function getLang(ext: string): OxcLang {
23
- if (ext === ".tsx" || ext === ".jsx") return "tsx"
24
- if (ext === ".ts" || ext === ".mts") return "ts"
25
- return "js"
23
+ if (ext === '.tsx' || ext === '.jsx') return 'tsx'
24
+ if (ext === '.ts' || ext === '.mts') return 'ts'
25
+ return 'js'
26
26
  }
27
27
 
28
28
  function createRuleContext(
@@ -113,7 +113,7 @@ export function lintFile(
113
113
  lineIndex = new LineIndex(sourceText)
114
114
  try {
115
115
  const result = parseSync(filePath, sourceText, {
116
- sourceType: "module",
116
+ sourceType: 'module',
117
117
  lang: getLang(ext),
118
118
  })
119
119
  program = result.program
@@ -129,7 +129,7 @@ export function lintFile(
129
129
  const allCallbacks: VisitorCallbacks[] = []
130
130
  for (const rule of rules) {
131
131
  const severity = config.rules[rule.meta.id]
132
- if (severity === undefined || severity === "off") continue
132
+ if (severity === undefined || severity === 'off') continue
133
133
  const ctx = createRuleContext(rule, severity, diagnostics, lineIndex, sourceText, filePath)
134
134
  allCallbacks.push(rule.create(ctx))
135
135
  }
@@ -0,0 +1,88 @@
1
+ import { getPreset } from '../config/presets'
2
+ import { allRules } from '../rules/index'
3
+ import { lintFile } from '../runner'
4
+
5
+ describe('LSP diagnostic conversion', () => {
6
+ const config = getPreset('recommended')
7
+
8
+ it('converts lint diagnostics to LSP format', () => {
9
+ const source = `
10
+ import { useContext } from '@pyreon/core'
11
+ const { mode } = useContext(ThemeCtx)
12
+ `
13
+ const result = lintFile('test.tsx', source, allRules, config)
14
+ const diag = result.diagnostics.find(
15
+ (d) => d.ruleId === 'pyreon/no-context-destructure',
16
+ )
17
+ expect(diag).toBeDefined()
18
+ expect(diag!.loc.line).toBeGreaterThan(0)
19
+ expect(diag!.loc.column).toBeGreaterThan(0)
20
+ expect(diag!.span.start).toBeLessThan(diag!.span.end)
21
+ })
22
+
23
+ it('LSP severity mapping — error=1, warn=2, info=3', () => {
24
+ const severityMap: Record<string, number> = {
25
+ error: 1,
26
+ warn: 2,
27
+ info: 3,
28
+ }
29
+ expect(severityMap.error).toBe(1)
30
+ expect(severityMap.warn).toBe(2)
31
+ expect(severityMap.info).toBe(3)
32
+ })
33
+
34
+ it('source diagnostics use 1-based lines (LSP layer converts to 0-based)', () => {
35
+ const source = `import { signal } from '@pyreon/reactivity'\nconst x = signal(0)\n{x}\n`
36
+ const result = lintFile('test.tsx', source, allRules, config)
37
+
38
+ for (const d of result.diagnostics) {
39
+ expect(d.loc.line).toBeGreaterThanOrEqual(1)
40
+ expect(d.loc.column).toBeGreaterThanOrEqual(1)
41
+ }
42
+ })
43
+
44
+ it('handles empty source without crashing', () => {
45
+ const result = lintFile('test.tsx', '', allRules, config)
46
+ expect(result.diagnostics).toEqual([])
47
+ })
48
+
49
+ it('handles non-JS file gracefully', () => {
50
+ const result = lintFile('test.css', 'body { color: red }', allRules, config)
51
+ expect(result.diagnostics).toEqual([])
52
+ })
53
+
54
+ it('handles syntax errors gracefully', () => {
55
+ const result = lintFile('test.tsx', 'const x = {{{', allRules, config)
56
+ // Should not throw — returns empty or partial diagnostics
57
+ expect(result.diagnostics).toBeDefined()
58
+ })
59
+
60
+ it('multiple diagnostics are sorted by position', () => {
61
+ const source = `
62
+ import { useContext } from '@pyreon/core'
63
+ const { a } = useContext(Ctx1)
64
+ const { b } = useContext(Ctx2)
65
+ `
66
+ const result = lintFile('test.tsx', source, allRules, config)
67
+ const ctxDiags = result.diagnostics.filter(
68
+ (d) => d.ruleId === 'pyreon/no-context-destructure',
69
+ )
70
+ expect(ctxDiags.length).toBe(2)
71
+ expect(ctxDiags[0]!.span.start).toBeLessThan(ctxDiags[1]!.span.start)
72
+ })
73
+
74
+ it('diagnostic span covers the destructured pattern', () => {
75
+ const source = `
76
+ import { useContext } from '@pyreon/core'
77
+ const { mode, theme } = useContext(ThemeCtx)
78
+ `
79
+ const result = lintFile('test.tsx', source, allRules, config)
80
+ const diag = result.diagnostics.find(
81
+ (d) => d.ruleId === 'pyreon/no-context-destructure',
82
+ )
83
+ expect(diag).toBeDefined()
84
+ const spanned = source.slice(diag!.span.start, diag!.span.end)
85
+ expect(spanned).toContain('mode')
86
+ expect(spanned).toContain('theme')
87
+ })
88
+ })