@pyreon/lint 0.11.4 → 0.11.6
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/README.md +91 -91
- package/lib/analysis/cli.js.html +5406 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/cli.js +3290 -0
- package/lib/cli.js.map +1 -0
- package/lib/index.js +220 -29
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +30 -5
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +19 -19
- package/src/cache.ts +1 -1
- package/src/cli.ts +39 -28
- package/src/config/ignore.ts +23 -23
- package/src/config/loader.ts +8 -8
- package/src/config/presets.ts +11 -11
- package/src/index.ts +14 -12
- package/src/lint.ts +19 -25
- package/src/lsp/index.ts +225 -0
- package/src/reporter.ts +17 -17
- package/src/rules/accessibility/dialog-a11y.ts +10 -10
- package/src/rules/accessibility/overlay-a11y.ts +11 -11
- package/src/rules/accessibility/toast-a11y.ts +11 -11
- package/src/rules/architecture/dev-guard-warnings.ts +19 -19
- package/src/rules/architecture/no-circular-import.ts +16 -16
- package/src/rules/architecture/no-cross-layer-import.ts +35 -35
- package/src/rules/architecture/no-deep-import.ts +7 -7
- package/src/rules/architecture/no-error-without-prefix.ts +20 -20
- package/src/rules/form/no-submit-without-validation.ts +13 -13
- package/src/rules/form/no-unregistered-field.ts +12 -12
- package/src/rules/form/prefer-field-array.ts +11 -11
- package/src/rules/hooks/no-raw-addeventlistener.ts +9 -9
- package/src/rules/hooks/no-raw-localstorage.ts +11 -11
- package/src/rules/hooks/no-raw-setinterval.ts +11 -11
- package/src/rules/index.ts +60 -57
- package/src/rules/jsx/no-and-conditional.ts +8 -8
- package/src/rules/jsx/no-children-access.ts +12 -12
- package/src/rules/jsx/no-classname.ts +10 -10
- package/src/rules/jsx/no-htmlfor.ts +10 -10
- package/src/rules/jsx/no-index-as-by.ts +17 -17
- package/src/rules/jsx/no-map-in-jsx.ts +9 -9
- package/src/rules/jsx/no-missing-for-by.ts +9 -9
- package/src/rules/jsx/no-onchange.ts +12 -12
- package/src/rules/jsx/no-props-destructure.ts +11 -11
- package/src/rules/jsx/no-ternary-conditional.ts +8 -8
- package/src/rules/jsx/use-by-not-key.ts +12 -12
- package/src/rules/lifecycle/no-dom-in-setup.ts +18 -18
- package/src/rules/lifecycle/no-effect-in-mount.ts +11 -11
- package/src/rules/lifecycle/no-missing-cleanup.ts +19 -19
- package/src/rules/lifecycle/no-mount-in-effect.ts +11 -11
- package/src/rules/performance/no-eager-import.ts +7 -7
- package/src/rules/performance/no-effect-in-for.ts +10 -10
- package/src/rules/performance/no-large-for-without-by.ts +9 -9
- package/src/rules/performance/prefer-show-over-display.ts +16 -16
- package/src/rules/reactivity/no-bare-signal-in-jsx.ts +10 -10
- package/src/rules/reactivity/no-context-destructure.ts +45 -0
- package/src/rules/reactivity/no-effect-assignment.ts +16 -16
- package/src/rules/reactivity/no-nested-effect.ts +10 -10
- package/src/rules/reactivity/no-peek-in-tracked.ts +10 -10
- package/src/rules/reactivity/no-signal-in-loop.ts +13 -13
- package/src/rules/reactivity/no-signal-leak.ts +9 -9
- package/src/rules/reactivity/no-unbatched-updates.ts +12 -12
- package/src/rules/reactivity/prefer-computed.ts +13 -13
- package/src/rules/router/index.ts +4 -4
- package/src/rules/router/no-href-navigation.ts +14 -14
- package/src/rules/router/no-imperative-navigate-in-render.ts +19 -19
- package/src/rules/router/no-missing-fallback.ts +16 -16
- package/src/rules/router/prefer-use-is-active.ts +11 -11
- package/src/rules/ssr/no-mismatch-risk.ts +11 -11
- package/src/rules/ssr/no-window-in-ssr.ts +22 -22
- package/src/rules/ssr/prefer-request-context.ts +14 -14
- package/src/rules/store/no-duplicate-store-id.ts +9 -9
- package/src/rules/store/no-mutate-store-state.ts +11 -11
- package/src/rules/store/no-store-outside-provider.ts +15 -15
- package/src/rules/styling/no-dynamic-styled.ts +13 -13
- package/src/rules/styling/no-inline-style-object.ts +10 -10
- package/src/rules/styling/no-theme-outside-provider.ts +11 -11
- package/src/rules/styling/prefer-cx.ts +12 -12
- package/src/runner.ts +13 -14
- package/src/tests/lsp.test.ts +88 -0
- package/src/tests/runner.test.ts +325 -325
- package/src/types.ts +15 -15
- package/src/utils/ast.ts +50 -50
- package/src/utils/imports.ts +53 -53
- package/src/utils/index.ts +12 -3
- package/src/utils/source.ts +2 -2
- package/src/watcher.ts +19 -25
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import type { Rule, VisitorCallbacks } from
|
|
2
|
-
import { getSpan, isMemberCallTo } from
|
|
1
|
+
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
|
+
import { getSpan, isMemberCallTo } from '../../utils/ast'
|
|
3
3
|
|
|
4
4
|
export const noMismatchRisk: Rule = {
|
|
5
5
|
meta: {
|
|
6
|
-
id:
|
|
7
|
-
category:
|
|
6
|
+
id: 'pyreon/no-mismatch-risk',
|
|
7
|
+
category: 'ssr',
|
|
8
8
|
description:
|
|
9
|
-
|
|
10
|
-
severity:
|
|
9
|
+
'Warn about non-deterministic calls (Date.now, Math.random, crypto.randomUUID) in JSX context that cause hydration mismatches.',
|
|
10
|
+
severity: 'warn',
|
|
11
11
|
fixable: false,
|
|
12
12
|
},
|
|
13
13
|
create(context) {
|
|
@@ -16,22 +16,22 @@ export const noMismatchRisk: Rule = {
|
|
|
16
16
|
JSXElement() {
|
|
17
17
|
jsxDepth++
|
|
18
18
|
},
|
|
19
|
-
|
|
19
|
+
'JSXElement:exit'() {
|
|
20
20
|
jsxDepth--
|
|
21
21
|
},
|
|
22
22
|
JSXFragment() {
|
|
23
23
|
jsxDepth++
|
|
24
24
|
},
|
|
25
|
-
|
|
25
|
+
'JSXFragment:exit'() {
|
|
26
26
|
jsxDepth--
|
|
27
27
|
},
|
|
28
28
|
CallExpression(node: any) {
|
|
29
29
|
if (jsxDepth === 0) return
|
|
30
30
|
|
|
31
31
|
if (
|
|
32
|
-
isMemberCallTo(node,
|
|
33
|
-
isMemberCallTo(node,
|
|
34
|
-
isMemberCallTo(node,
|
|
32
|
+
isMemberCallTo(node, 'Date', 'now') ||
|
|
33
|
+
isMemberCallTo(node, 'Math', 'random') ||
|
|
34
|
+
isMemberCallTo(node, 'crypto', 'randomUUID')
|
|
35
35
|
) {
|
|
36
36
|
const callee = node.callee
|
|
37
37
|
const name = `${callee.object.name}.${callee.property.name}`
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import type { Rule, VisitorCallbacks } from
|
|
2
|
-
import { getSpan, isCallTo } from
|
|
3
|
-
import { BROWSER_GLOBALS } from
|
|
1
|
+
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
|
+
import { getSpan, isCallTo } from '../../utils/ast'
|
|
3
|
+
import { BROWSER_GLOBALS } from '../../utils/imports'
|
|
4
4
|
|
|
5
5
|
export const noWindowInSsr: Rule = {
|
|
6
6
|
meta: {
|
|
7
|
-
id:
|
|
8
|
-
category:
|
|
9
|
-
description:
|
|
10
|
-
severity:
|
|
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
11
|
fixable: false,
|
|
12
12
|
},
|
|
13
13
|
create(context) {
|
|
@@ -16,12 +16,12 @@ export const noWindowInSsr: Rule = {
|
|
|
16
16
|
|
|
17
17
|
const callbacks: VisitorCallbacks = {
|
|
18
18
|
CallExpression(node: any) {
|
|
19
|
-
if (isCallTo(node,
|
|
19
|
+
if (isCallTo(node, 'onMount') || isCallTo(node, 'effect')) {
|
|
20
20
|
safeDepth++
|
|
21
21
|
}
|
|
22
22
|
},
|
|
23
|
-
|
|
24
|
-
if (isCallTo(node,
|
|
23
|
+
'CallExpression:exit'(node: any) {
|
|
24
|
+
if (isCallTo(node, 'onMount') || isCallTo(node, 'effect')) {
|
|
25
25
|
safeDepth--
|
|
26
26
|
}
|
|
27
27
|
},
|
|
@@ -29,19 +29,19 @@ export const noWindowInSsr: Rule = {
|
|
|
29
29
|
// typeof window !== "undefined"
|
|
30
30
|
const test = node.test
|
|
31
31
|
if (
|
|
32
|
-
test?.type ===
|
|
33
|
-
test.left?.type ===
|
|
34
|
-
test.left.operator ===
|
|
32
|
+
test?.type === 'BinaryExpression' &&
|
|
33
|
+
test.left?.type === 'UnaryExpression' &&
|
|
34
|
+
test.left.operator === 'typeof'
|
|
35
35
|
) {
|
|
36
36
|
typeofGuardDepth++
|
|
37
37
|
}
|
|
38
38
|
},
|
|
39
|
-
|
|
39
|
+
'IfStatement:exit'(node: any) {
|
|
40
40
|
const test = node.test
|
|
41
41
|
if (
|
|
42
|
-
test?.type ===
|
|
43
|
-
test.left?.type ===
|
|
44
|
-
test.left.operator ===
|
|
42
|
+
test?.type === 'BinaryExpression' &&
|
|
43
|
+
test.left?.type === 'UnaryExpression' &&
|
|
44
|
+
test.left.operator === 'typeof'
|
|
45
45
|
) {
|
|
46
46
|
typeofGuardDepth--
|
|
47
47
|
}
|
|
@@ -51,18 +51,18 @@ export const noWindowInSsr: Rule = {
|
|
|
51
51
|
if (!BROWSER_GLOBALS.has(node.name)) return
|
|
52
52
|
|
|
53
53
|
// Skip typeof expressions: typeof window
|
|
54
|
-
if (parent?.type ===
|
|
54
|
+
if (parent?.type === 'UnaryExpression' && parent.operator === 'typeof') return
|
|
55
55
|
|
|
56
56
|
// Skip import specifiers
|
|
57
57
|
if (
|
|
58
|
-
parent?.type ===
|
|
59
|
-
parent?.type ===
|
|
60
|
-
parent?.type ===
|
|
58
|
+
parent?.type === 'ImportSpecifier' ||
|
|
59
|
+
parent?.type === 'ImportDefaultSpecifier' ||
|
|
60
|
+
parent?.type === 'ImportNamespaceSpecifier'
|
|
61
61
|
)
|
|
62
62
|
return
|
|
63
63
|
|
|
64
64
|
// Skip property access on member expressions (only flag when used as the object)
|
|
65
|
-
if (parent?.type ===
|
|
65
|
+
if (parent?.type === 'MemberExpression' && parent.property === node && !parent.computed)
|
|
66
66
|
return
|
|
67
67
|
|
|
68
68
|
context.report({
|
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
import type { Rule, VisitorCallbacks } from
|
|
2
|
-
import { getSpan, isCallTo } from
|
|
1
|
+
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
|
+
import { getSpan, isCallTo } from '../../utils/ast'
|
|
3
3
|
|
|
4
4
|
export const preferRequestContext: Rule = {
|
|
5
5
|
meta: {
|
|
6
|
-
id:
|
|
7
|
-
category:
|
|
6
|
+
id: 'pyreon/prefer-request-context',
|
|
7
|
+
category: 'ssr',
|
|
8
8
|
description:
|
|
9
|
-
|
|
10
|
-
severity:
|
|
9
|
+
'Warn about module-level signal()/createStore() in server files — use request context instead.',
|
|
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(
|
|
17
|
-
filePath.includes(
|
|
18
|
-
filePath.endsWith(
|
|
19
|
-
filePath.endsWith(
|
|
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
|
|
|
@@ -25,24 +25,24 @@ export const preferRequestContext: Rule = {
|
|
|
25
25
|
FunctionDeclaration() {
|
|
26
26
|
functionDepth++
|
|
27
27
|
},
|
|
28
|
-
|
|
28
|
+
'FunctionDeclaration:exit'() {
|
|
29
29
|
functionDepth--
|
|
30
30
|
},
|
|
31
31
|
FunctionExpression() {
|
|
32
32
|
functionDepth++
|
|
33
33
|
},
|
|
34
|
-
|
|
34
|
+
'FunctionExpression:exit'() {
|
|
35
35
|
functionDepth--
|
|
36
36
|
},
|
|
37
37
|
ArrowFunctionExpression() {
|
|
38
38
|
functionDepth++
|
|
39
39
|
},
|
|
40
|
-
|
|
40
|
+
'ArrowFunctionExpression:exit'() {
|
|
41
41
|
functionDepth--
|
|
42
42
|
},
|
|
43
43
|
CallExpression(node: any) {
|
|
44
44
|
if (functionDepth > 0) return // only flag module-level calls
|
|
45
|
-
if (isCallTo(node,
|
|
45
|
+
if (isCallTo(node, 'signal') || isCallTo(node, 'createStore')) {
|
|
46
46
|
const name = node.callee.name
|
|
47
47
|
context.report({
|
|
48
48
|
message: `Module-level \`${name}()\` in a server file — this state is shared across all requests. Use \`runWithRequestContext()\` for per-request isolation.`,
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import type { Rule, VisitorCallbacks } from
|
|
2
|
-
import { getSpan, isCallTo } from
|
|
1
|
+
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
|
+
import { getSpan, isCallTo } from '../../utils/ast'
|
|
3
3
|
|
|
4
4
|
export const noDuplicateStoreId: Rule = {
|
|
5
5
|
meta: {
|
|
6
|
-
id:
|
|
7
|
-
category:
|
|
8
|
-
description:
|
|
9
|
-
severity:
|
|
6
|
+
id: 'pyreon/no-duplicate-store-id',
|
|
7
|
+
category: 'store',
|
|
8
|
+
description: 'Disallow duplicate defineStore() IDs in the same file.',
|
|
9
|
+
severity: 'error',
|
|
10
10
|
fixable: false,
|
|
11
11
|
},
|
|
12
12
|
create(context) {
|
|
@@ -14,7 +14,7 @@ export const noDuplicateStoreId: Rule = {
|
|
|
14
14
|
|
|
15
15
|
const callbacks: VisitorCallbacks = {
|
|
16
16
|
CallExpression(node: any) {
|
|
17
|
-
if (!isCallTo(node,
|
|
17
|
+
if (!isCallTo(node, 'defineStore')) return
|
|
18
18
|
const args = node.arguments
|
|
19
19
|
if (!args || args.length === 0) return
|
|
20
20
|
|
|
@@ -22,11 +22,11 @@ export const noDuplicateStoreId: Rule = {
|
|
|
22
22
|
if (!firstArg) return
|
|
23
23
|
|
|
24
24
|
let id: string | null = null
|
|
25
|
-
if (firstArg.type ===
|
|
25
|
+
if (firstArg.type === 'Literal' || firstArg.type === 'StringLiteral') {
|
|
26
26
|
id = firstArg.value as string
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
if (typeof id !==
|
|
29
|
+
if (typeof id !== 'string') return
|
|
30
30
|
|
|
31
31
|
if (storeIds.has(id)) {
|
|
32
32
|
context.report({
|
|
@@ -1,30 +1,30 @@
|
|
|
1
|
-
import type { Rule, VisitorCallbacks } from
|
|
2
|
-
import { getSpan } from
|
|
1
|
+
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
|
+
import { getSpan } from '../../utils/ast'
|
|
3
3
|
|
|
4
4
|
export const noMutateStoreState: Rule = {
|
|
5
5
|
meta: {
|
|
6
|
-
id:
|
|
7
|
-
category:
|
|
8
|
-
description:
|
|
9
|
-
severity:
|
|
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
10
|
fixable: false,
|
|
11
11
|
},
|
|
12
12
|
create(context) {
|
|
13
13
|
const callbacks: VisitorCallbacks = {
|
|
14
14
|
CallExpression(node: any) {
|
|
15
15
|
const callee = node.callee
|
|
16
|
-
if (!callee || callee.type !==
|
|
17
|
-
if (callee.property?.type !==
|
|
16
|
+
if (!callee || callee.type !== 'MemberExpression') return
|
|
17
|
+
if (callee.property?.type !== 'Identifier' || callee.property.name !== 'set') return
|
|
18
18
|
|
|
19
19
|
// Check for store.signal.set() pattern — member.member.set()
|
|
20
20
|
const obj = callee.object
|
|
21
|
-
if (!obj || obj.type !==
|
|
21
|
+
if (!obj || obj.type !== 'MemberExpression') return
|
|
22
22
|
const outerObj = obj.object
|
|
23
|
-
if (!outerObj || outerObj.type !==
|
|
23
|
+
if (!outerObj || outerObj.type !== 'Identifier') return
|
|
24
24
|
|
|
25
25
|
const name: string = outerObj.name
|
|
26
26
|
// Heuristic: if the outer object name contains "store" (case-insensitive)
|
|
27
|
-
if (name.toLowerCase().includes(
|
|
27
|
+
if (name.toLowerCase().includes('store')) {
|
|
28
28
|
context.report({
|
|
29
29
|
message: `Direct \`.set()\` on store state \`${name}\` — use store actions to mutate state for better traceability.`,
|
|
30
30
|
span: getSpan(node),
|
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
import type { Rule, VisitorCallbacks } from
|
|
2
|
-
import { getSpan } from
|
|
3
|
-
import { extractImportInfo } from
|
|
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:
|
|
8
|
-
category:
|
|
9
|
-
description:
|
|
10
|
-
severity:
|
|
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(
|
|
17
|
-
filePath.includes(
|
|
18
|
-
filePath.endsWith(
|
|
19
|
-
filePath.endsWith(
|
|
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 ===
|
|
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 !==
|
|
41
|
+
if (!callee || callee.type !== 'Identifier') return
|
|
42
42
|
const name: string = callee.name
|
|
43
|
-
if (name.endsWith(
|
|
43
|
+
if (name.endsWith('Store') && name.startsWith('use')) {
|
|
44
44
|
storeHookCalls.push({ name, span: getSpan(node) })
|
|
45
45
|
}
|
|
46
46
|
},
|
|
47
|
-
|
|
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
|
|
2
|
-
import { getSpan, isCallTo } from
|
|
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:
|
|
7
|
-
category:
|
|
6
|
+
id: 'pyreon/no-dynamic-styled',
|
|
7
|
+
category: 'styling',
|
|
8
8
|
description:
|
|
9
|
-
|
|
10
|
-
severity:
|
|
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
|
-
|
|
19
|
+
'FunctionDeclaration:exit'() {
|
|
20
20
|
functionDepth--
|
|
21
21
|
},
|
|
22
22
|
FunctionExpression() {
|
|
23
23
|
functionDepth++
|
|
24
24
|
},
|
|
25
|
-
|
|
25
|
+
'FunctionExpression:exit'() {
|
|
26
26
|
functionDepth--
|
|
27
27
|
},
|
|
28
28
|
ArrowFunctionExpression() {
|
|
29
29
|
functionDepth++
|
|
30
30
|
},
|
|
31
|
-
|
|
31
|
+
'ArrowFunctionExpression:exit'() {
|
|
32
32
|
functionDepth--
|
|
33
33
|
},
|
|
34
34
|
CallExpression(node: any) {
|
|
35
35
|
if (functionDepth === 0) return
|
|
36
|
-
if (isCallTo(node,
|
|
36
|
+
if (isCallTo(node, 'styled')) {
|
|
37
37
|
context.report({
|
|
38
38
|
message:
|
|
39
|
-
|
|
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 ===
|
|
49
|
+
if (tag.type === 'CallExpression' && isCallTo(tag, 'styled')) {
|
|
50
50
|
context.report({
|
|
51
51
|
message:
|
|
52
|
-
|
|
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
|
|
2
|
-
import { getSpan } from
|
|
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:
|
|
7
|
-
category:
|
|
8
|
-
description:
|
|
9
|
-
severity:
|
|
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 !==
|
|
15
|
+
if (node.name?.type !== 'JSXIdentifier' || node.name.name !== 'style') return
|
|
16
16
|
const value = node.value
|
|
17
|
-
if (!value || value.type !==
|
|
17
|
+
if (!value || value.type !== 'JSXExpressionContainer') return
|
|
18
18
|
const expr = value.expression
|
|
19
|
-
if (expr?.type ===
|
|
19
|
+
if (expr?.type === 'ObjectExpression') {
|
|
20
20
|
context.report({
|
|
21
21
|
message:
|
|
22
|
-
|
|
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
|
|
2
|
-
import { getSpan, isCallTo } from
|
|
3
|
-
import { extractImportInfo } from
|
|
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:
|
|
8
|
-
category:
|
|
9
|
-
description:
|
|
10
|
-
severity:
|
|
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 ===
|
|
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,
|
|
28
|
+
if (isCallTo(node, 'useTheme')) {
|
|
29
29
|
themeCalls.push({ span: getSpan(node) })
|
|
30
30
|
}
|
|
31
31
|
},
|
|
32
|
-
|
|
32
|
+
'Program:exit'() {
|
|
33
33
|
if (hasProviderImport) return
|
|
34
34
|
for (const call of themeCalls) {
|
|
35
35
|
context.report({
|
|
36
36
|
message:
|
|
37
|
-
|
|
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
|
|
2
|
-
import { getSpan } from
|
|
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:
|
|
7
|
-
category:
|
|
6
|
+
id: 'pyreon/prefer-cx',
|
|
7
|
+
category: 'styling',
|
|
8
8
|
description:
|
|
9
|
-
|
|
10
|
-
severity:
|
|
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 !==
|
|
16
|
+
if (node.name?.type !== 'JSXIdentifier' || node.name.name !== 'class') return
|
|
17
17
|
const value = node.value
|
|
18
|
-
if (!value || value.type !==
|
|
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 ===
|
|
23
|
+
if (expr.type === 'BinaryExpression' && expr.operator === '+') {
|
|
24
24
|
context.report({
|
|
25
25
|
message:
|
|
26
|
-
|
|
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 ===
|
|
33
|
+
if (expr.type === 'TemplateLiteral' && expr.expressions?.length > 0) {
|
|
34
34
|
context.report({
|
|
35
35
|
message:
|
|
36
|
-
|
|
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
|
|
2
|
-
import type { AstCache } from
|
|
1
|
+
import { parseSync, Visitor } from 'oxc-parser'
|
|
2
|
+
import type { AstCache } from './cache'
|
|
3
3
|
import type {
|
|
4
4
|
Diagnostic,
|
|
5
5
|
LintConfig,
|
|
@@ -8,22 +8,21 @@ import type {
|
|
|
8
8
|
RuleContext,
|
|
9
9
|
Severity,
|
|
10
10
|
VisitorCallbacks,
|
|
11
|
-
} from
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
const JS_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mts", ".mjs"])
|
|
11
|
+
} from './types'
|
|
12
|
+
import { JS_EXTENSIONS } from './utils/index'
|
|
13
|
+
import { LineIndex } from './utils/source'
|
|
15
14
|
|
|
16
15
|
function getExtension(filePath: string): string {
|
|
17
|
-
const lastDot = filePath.lastIndexOf(
|
|
18
|
-
return lastDot === -1 ?
|
|
16
|
+
const lastDot = filePath.lastIndexOf('.')
|
|
17
|
+
return lastDot === -1 ? '' : filePath.slice(lastDot)
|
|
19
18
|
}
|
|
20
19
|
|
|
21
|
-
type OxcLang =
|
|
20
|
+
type OxcLang = 'jsx' | 'tsx' | 'ts' | 'js' | 'dts'
|
|
22
21
|
|
|
23
22
|
function getLang(ext: string): OxcLang {
|
|
24
|
-
if (ext ===
|
|
25
|
-
if (ext ===
|
|
26
|
-
return
|
|
23
|
+
if (ext === '.tsx' || ext === '.jsx') return 'tsx'
|
|
24
|
+
if (ext === '.ts' || ext === '.mts') return 'ts'
|
|
25
|
+
return 'js'
|
|
27
26
|
}
|
|
28
27
|
|
|
29
28
|
function createRuleContext(
|
|
@@ -114,7 +113,7 @@ export function lintFile(
|
|
|
114
113
|
lineIndex = new LineIndex(sourceText)
|
|
115
114
|
try {
|
|
116
115
|
const result = parseSync(filePath, sourceText, {
|
|
117
|
-
sourceType:
|
|
116
|
+
sourceType: 'module',
|
|
118
117
|
lang: getLang(ext),
|
|
119
118
|
})
|
|
120
119
|
program = result.program
|
|
@@ -130,7 +129,7 @@ export function lintFile(
|
|
|
130
129
|
const allCallbacks: VisitorCallbacks[] = []
|
|
131
130
|
for (const rule of rules) {
|
|
132
131
|
const severity = config.rules[rule.meta.id]
|
|
133
|
-
if (severity === undefined || severity ===
|
|
132
|
+
if (severity === undefined || severity === 'off') continue
|
|
134
133
|
const ctx = createRuleContext(rule, severity, diagnostics, lineIndex, sourceText, filePath)
|
|
135
134
|
allCallbacks.push(rule.create(ctx))
|
|
136
135
|
}
|