@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,29 +1,29 @@
|
|
|
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 noNestedEffect: Rule = {
|
|
5
5
|
meta: {
|
|
6
|
-
id:
|
|
7
|
-
category:
|
|
8
|
-
description:
|
|
9
|
-
severity:
|
|
6
|
+
id: 'pyreon/no-nested-effect',
|
|
7
|
+
category: 'reactivity',
|
|
8
|
+
description: 'Warn against nesting effect() inside another effect().',
|
|
9
|
+
severity: 'warn',
|
|
10
10
|
fixable: false,
|
|
11
11
|
},
|
|
12
12
|
create(context) {
|
|
13
13
|
let effectDepth = 0
|
|
14
14
|
const callbacks: VisitorCallbacks = {
|
|
15
15
|
CallExpression(node: any) {
|
|
16
|
-
if (!isCallTo(node,
|
|
16
|
+
if (!isCallTo(node, 'effect')) return
|
|
17
17
|
if (effectDepth > 0) {
|
|
18
18
|
context.report({
|
|
19
|
-
message:
|
|
19
|
+
message: 'Nested `effect()` — consider using `computed()` for derived values instead.',
|
|
20
20
|
span: getSpan(node),
|
|
21
21
|
})
|
|
22
22
|
}
|
|
23
23
|
effectDepth++
|
|
24
24
|
},
|
|
25
|
-
|
|
26
|
-
if (isCallTo(node,
|
|
25
|
+
'CallExpression:exit'(node: any) {
|
|
26
|
+
if (isCallTo(node, 'effect')) {
|
|
27
27
|
effectDepth--
|
|
28
28
|
}
|
|
29
29
|
},
|
|
@@ -1,31 +1,31 @@
|
|
|
1
|
-
import type { Rule, VisitorCallbacks } from
|
|
2
|
-
import { getSpan, isCallTo, isPeekCall } from
|
|
1
|
+
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
|
+
import { getSpan, isCallTo, isPeekCall } from '../../utils/ast'
|
|
3
3
|
|
|
4
4
|
export const noPeekInTracked: Rule = {
|
|
5
5
|
meta: {
|
|
6
|
-
id:
|
|
7
|
-
category:
|
|
8
|
-
description:
|
|
9
|
-
severity:
|
|
6
|
+
id: 'pyreon/no-peek-in-tracked',
|
|
7
|
+
category: 'reactivity',
|
|
8
|
+
description: 'Disallow .peek() inside effect() or computed() — it bypasses tracking.',
|
|
9
|
+
severity: 'error',
|
|
10
10
|
fixable: false,
|
|
11
11
|
},
|
|
12
12
|
create(context) {
|
|
13
13
|
let trackedDepth = 0
|
|
14
14
|
const callbacks: VisitorCallbacks = {
|
|
15
15
|
CallExpression(node: any) {
|
|
16
|
-
if (isCallTo(node,
|
|
16
|
+
if (isCallTo(node, 'effect') || isCallTo(node, 'computed')) {
|
|
17
17
|
trackedDepth++
|
|
18
18
|
}
|
|
19
19
|
if (trackedDepth > 0 && isPeekCall(node)) {
|
|
20
20
|
context.report({
|
|
21
21
|
message:
|
|
22
|
-
|
|
22
|
+
'`.peek()` inside a tracked scope (effect/computed) bypasses dependency tracking — use a normal signal read instead.',
|
|
23
23
|
span: getSpan(node),
|
|
24
24
|
})
|
|
25
25
|
}
|
|
26
26
|
},
|
|
27
|
-
|
|
28
|
-
if (isCallTo(node,
|
|
27
|
+
'CallExpression:exit'(node: any) {
|
|
28
|
+
if (isCallTo(node, 'effect') || isCallTo(node, 'computed')) {
|
|
29
29
|
trackedDepth--
|
|
30
30
|
}
|
|
31
31
|
},
|
|
@@ -1,12 +1,12 @@
|
|
|
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 noSignalInLoop: Rule = {
|
|
5
5
|
meta: {
|
|
6
|
-
id:
|
|
7
|
-
category:
|
|
8
|
-
description:
|
|
9
|
-
severity:
|
|
6
|
+
id: 'pyreon/no-signal-in-loop',
|
|
7
|
+
category: 'reactivity',
|
|
8
|
+
description: 'Disallow creating signals or computeds inside loops.',
|
|
9
|
+
severity: 'error',
|
|
10
10
|
fixable: false,
|
|
11
11
|
},
|
|
12
12
|
create(context) {
|
|
@@ -15,38 +15,38 @@ export const noSignalInLoop: Rule = {
|
|
|
15
15
|
ForStatement() {
|
|
16
16
|
loopDepth++
|
|
17
17
|
},
|
|
18
|
-
|
|
18
|
+
'ForStatement:exit'() {
|
|
19
19
|
loopDepth--
|
|
20
20
|
},
|
|
21
21
|
ForInStatement() {
|
|
22
22
|
loopDepth++
|
|
23
23
|
},
|
|
24
|
-
|
|
24
|
+
'ForInStatement:exit'() {
|
|
25
25
|
loopDepth--
|
|
26
26
|
},
|
|
27
27
|
ForOfStatement() {
|
|
28
28
|
loopDepth++
|
|
29
29
|
},
|
|
30
|
-
|
|
30
|
+
'ForOfStatement:exit'() {
|
|
31
31
|
loopDepth--
|
|
32
32
|
},
|
|
33
33
|
WhileStatement() {
|
|
34
34
|
loopDepth++
|
|
35
35
|
},
|
|
36
|
-
|
|
36
|
+
'WhileStatement:exit'() {
|
|
37
37
|
loopDepth--
|
|
38
38
|
},
|
|
39
39
|
DoWhileStatement() {
|
|
40
40
|
loopDepth++
|
|
41
41
|
},
|
|
42
|
-
|
|
42
|
+
'DoWhileStatement:exit'() {
|
|
43
43
|
loopDepth--
|
|
44
44
|
},
|
|
45
45
|
CallExpression(node: any) {
|
|
46
46
|
if (loopDepth === 0) return
|
|
47
47
|
const callee = node.callee
|
|
48
|
-
if (!callee || callee.type !==
|
|
49
|
-
if (callee.name ===
|
|
48
|
+
if (!callee || callee.type !== 'Identifier') return
|
|
49
|
+
if (callee.name === 'signal' || callee.name === 'computed') {
|
|
50
50
|
context.report({
|
|
51
51
|
message: `\`${callee.name}()\` inside a loop — signals should be created once at component setup, not on every iteration.`,
|
|
52
52
|
span: getSpan(node),
|
|
@@ -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 noSignalLeak: Rule = {
|
|
5
5
|
meta: {
|
|
6
|
-
id:
|
|
7
|
-
category:
|
|
8
|
-
description:
|
|
9
|
-
severity:
|
|
6
|
+
id: 'pyreon/no-signal-leak',
|
|
7
|
+
category: 'reactivity',
|
|
8
|
+
description: 'Warn about unused signal declarations (potential leaks).',
|
|
9
|
+
severity: 'warn',
|
|
10
10
|
fixable: false,
|
|
11
11
|
},
|
|
12
12
|
create(context) {
|
|
@@ -19,9 +19,9 @@ export const noSignalLeak: Rule = {
|
|
|
19
19
|
const callbacks: VisitorCallbacks = {
|
|
20
20
|
VariableDeclarator(node: any) {
|
|
21
21
|
const init = node.init
|
|
22
|
-
if (!init || !isCallTo(init,
|
|
22
|
+
if (!init || !isCallTo(init, 'signal')) return
|
|
23
23
|
const id = node.id
|
|
24
|
-
if (!id || id.type !==
|
|
24
|
+
if (!id || id.type !== 'Identifier') return
|
|
25
25
|
signalDecls.set(id.name, {
|
|
26
26
|
span: getSpan(node),
|
|
27
27
|
declStart: id.start as number,
|
|
@@ -39,7 +39,7 @@ export const noSignalLeak: Rule = {
|
|
|
39
39
|
])
|
|
40
40
|
}
|
|
41
41
|
},
|
|
42
|
-
|
|
42
|
+
'Program:exit'() {
|
|
43
43
|
for (const [name, { span, declStart, declEnd }] of signalDecls) {
|
|
44
44
|
const occurrences = identifierOccurrences.get(name) ?? []
|
|
45
45
|
// Filter out the declaration identifier itself
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { Rule, VisitorCallbacks } from
|
|
2
|
-
import { getSpan, isCallTo, isSetCall } from
|
|
1
|
+
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
|
+
import { getSpan, isCallTo, isSetCall } from '../../utils/ast'
|
|
3
3
|
|
|
4
4
|
interface ScopeInfo {
|
|
5
5
|
setCalls: Array<{ span: { start: number; end: number } }>
|
|
@@ -10,10 +10,10 @@ interface ScopeInfo {
|
|
|
10
10
|
|
|
11
11
|
export const noUnbatchedUpdates: Rule = {
|
|
12
12
|
meta: {
|
|
13
|
-
id:
|
|
14
|
-
category:
|
|
15
|
-
description:
|
|
16
|
-
severity:
|
|
13
|
+
id: 'pyreon/no-unbatched-updates',
|
|
14
|
+
category: 'reactivity',
|
|
15
|
+
description: 'Warn when 3+ .set() calls occur in the same function without batch().',
|
|
16
|
+
severity: 'warn',
|
|
17
17
|
fixable: false,
|
|
18
18
|
},
|
|
19
19
|
create(context) {
|
|
@@ -39,24 +39,24 @@ export const noUnbatchedUpdates: Rule = {
|
|
|
39
39
|
FunctionDeclaration(node: any) {
|
|
40
40
|
enterScope(node)
|
|
41
41
|
},
|
|
42
|
-
|
|
42
|
+
'FunctionDeclaration:exit'() {
|
|
43
43
|
exitScope()
|
|
44
44
|
},
|
|
45
45
|
FunctionExpression(node: any) {
|
|
46
46
|
enterScope(node)
|
|
47
47
|
},
|
|
48
|
-
|
|
48
|
+
'FunctionExpression:exit'() {
|
|
49
49
|
exitScope()
|
|
50
50
|
},
|
|
51
51
|
ArrowFunctionExpression(node: any) {
|
|
52
52
|
enterScope(node)
|
|
53
53
|
},
|
|
54
|
-
|
|
54
|
+
'ArrowFunctionExpression:exit'() {
|
|
55
55
|
exitScope()
|
|
56
56
|
},
|
|
57
57
|
CallExpression(node: any) {
|
|
58
58
|
const currentScope = scopeStack.length > 0 ? scopeStack[scopeStack.length - 1] : undefined
|
|
59
|
-
if (isCallTo(node,
|
|
59
|
+
if (isCallTo(node, 'batch')) {
|
|
60
60
|
batchDepth++
|
|
61
61
|
if (currentScope) {
|
|
62
62
|
currentScope.hasBatch = true
|
|
@@ -66,8 +66,8 @@ export const noUnbatchedUpdates: Rule = {
|
|
|
66
66
|
currentScope.setCalls.push({ span: getSpan(node) })
|
|
67
67
|
}
|
|
68
68
|
},
|
|
69
|
-
|
|
70
|
-
if (isCallTo(node,
|
|
69
|
+
'CallExpression:exit'(node: any) {
|
|
70
|
+
if (isCallTo(node, 'batch')) {
|
|
71
71
|
batchDepth--
|
|
72
72
|
}
|
|
73
73
|
},
|
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
import type { Rule, VisitorCallbacks } from
|
|
2
|
-
import { getSpan, isCallTo, isSetCall } from
|
|
1
|
+
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
|
+
import { getSpan, isCallTo, isSetCall } from '../../utils/ast'
|
|
3
3
|
|
|
4
4
|
export const preferComputed: Rule = {
|
|
5
5
|
meta: {
|
|
6
|
-
id:
|
|
7
|
-
category:
|
|
8
|
-
description:
|
|
9
|
-
severity:
|
|
6
|
+
id: 'pyreon/prefer-computed',
|
|
7
|
+
category: 'reactivity',
|
|
8
|
+
description: 'Suggest computed() when an effect only contains a single .set() call.',
|
|
9
|
+
severity: 'warn',
|
|
10
10
|
fixable: false,
|
|
11
11
|
},
|
|
12
12
|
create(context) {
|
|
13
13
|
const callbacks: VisitorCallbacks = {
|
|
14
14
|
CallExpression(node: any) {
|
|
15
|
-
if (!isCallTo(node,
|
|
15
|
+
if (!isCallTo(node, 'effect')) return
|
|
16
16
|
const args = node.arguments
|
|
17
17
|
if (!args || args.length === 0) return
|
|
18
18
|
|
|
@@ -20,30 +20,30 @@ export const preferComputed: Rule = {
|
|
|
20
20
|
if (!fn) return
|
|
21
21
|
|
|
22
22
|
let body: any = null
|
|
23
|
-
if (fn.type ===
|
|
23
|
+
if (fn.type === 'ArrowFunctionExpression' || fn.type === 'FunctionExpression') {
|
|
24
24
|
body = fn.body
|
|
25
25
|
}
|
|
26
26
|
if (!body) return
|
|
27
27
|
|
|
28
28
|
// Arrow with expression body: effect(() => x.set(y))
|
|
29
|
-
if (body.type ===
|
|
29
|
+
if (body.type === 'CallExpression' && isSetCall(body)) {
|
|
30
30
|
context.report({
|
|
31
31
|
message:
|
|
32
|
-
|
|
32
|
+
'Effect contains a single `.set()` — consider using `computed()` instead for derived values.',
|
|
33
33
|
span: getSpan(node),
|
|
34
34
|
})
|
|
35
35
|
return
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
// Block body with single statement: effect(() => { x.set(y) })
|
|
39
|
-
if (body.type ===
|
|
39
|
+
if (body.type === 'BlockStatement') {
|
|
40
40
|
const stmts = body.body
|
|
41
41
|
if (stmts && stmts.length === 1) {
|
|
42
42
|
const stmt = stmts[0]
|
|
43
|
-
if (stmt.type ===
|
|
43
|
+
if (stmt.type === 'ExpressionStatement' && isSetCall(stmt.expression)) {
|
|
44
44
|
context.report({
|
|
45
45
|
message:
|
|
46
|
-
|
|
46
|
+
'Effect contains a single `.set()` — consider using `computed()` instead for derived values.',
|
|
47
47
|
span: getSpan(node),
|
|
48
48
|
})
|
|
49
49
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { noHrefNavigation } from
|
|
2
|
-
export { noImperativeNavigateInRender } from
|
|
3
|
-
export { noMissingFallback } from
|
|
4
|
-
export { preferUseIsActive } from
|
|
1
|
+
export { noHrefNavigation } from './no-href-navigation'
|
|
2
|
+
export { noImperativeNavigateInRender } from './no-imperative-navigate-in-render'
|
|
3
|
+
export { noMissingFallback } from './no-missing-fallback'
|
|
4
|
+
export { preferUseIsActive } from './prefer-use-is-active'
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import type { Rule, VisitorCallbacks } from
|
|
2
|
-
import { getJSXAttribute, getSpan } from
|
|
3
|
-
import { extractImportInfo } from
|
|
1
|
+
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
|
+
import { getJSXAttribute, getSpan } from '../../utils/ast'
|
|
3
|
+
import { extractImportInfo } from '../../utils/imports'
|
|
4
4
|
|
|
5
|
-
const EXTERNAL_PREFIXES = [
|
|
5
|
+
const EXTERNAL_PREFIXES = ['http://', 'https://', 'mailto:', 'tel:']
|
|
6
6
|
|
|
7
7
|
export const noHrefNavigation: Rule = {
|
|
8
8
|
meta: {
|
|
9
|
-
id:
|
|
10
|
-
category:
|
|
9
|
+
id: 'pyreon/no-href-navigation',
|
|
10
|
+
category: 'router',
|
|
11
11
|
description:
|
|
12
|
-
|
|
13
|
-
severity:
|
|
12
|
+
'Warn when `<a href>` is used in files that import @pyreon/router — use `<Link>` instead.',
|
|
13
|
+
severity: 'warn',
|
|
14
14
|
fixable: false,
|
|
15
15
|
},
|
|
16
16
|
create(context) {
|
|
@@ -19,29 +19,29 @@ export const noHrefNavigation: Rule = {
|
|
|
19
19
|
const callbacks: VisitorCallbacks = {
|
|
20
20
|
ImportDeclaration(node: any) {
|
|
21
21
|
const info = extractImportInfo(node)
|
|
22
|
-
if (info && info.source ===
|
|
22
|
+
if (info && info.source === '@pyreon/router') {
|
|
23
23
|
importsRouter = true
|
|
24
24
|
}
|
|
25
25
|
},
|
|
26
26
|
JSXOpeningElement(node: any) {
|
|
27
27
|
if (!importsRouter) return
|
|
28
28
|
const name = node.name
|
|
29
|
-
if (!name || name.type !==
|
|
29
|
+
if (!name || name.type !== 'JSXIdentifier' || name.name !== 'a') return
|
|
30
30
|
|
|
31
|
-
const hrefAttr = getJSXAttribute(node,
|
|
31
|
+
const hrefAttr = getJSXAttribute(node, 'href')
|
|
32
32
|
if (!hrefAttr) return
|
|
33
33
|
|
|
34
34
|
// Get the href value
|
|
35
35
|
const value = hrefAttr.value
|
|
36
|
-
if (value?.type ===
|
|
36
|
+
if (value?.type === 'Literal' && typeof value.value === 'string') {
|
|
37
37
|
const href: string = value.value
|
|
38
38
|
// Skip external URLs and anchor links
|
|
39
|
-
if (href.startsWith(
|
|
39
|
+
if (href.startsWith('#') || EXTERNAL_PREFIXES.some((p) => href.startsWith(p))) return
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
context.report({
|
|
43
43
|
message:
|
|
44
|
-
|
|
44
|
+
'`<a href>` in a router file — use `<Link>` or `<RouterLink>` for client-side navigation.',
|
|
45
45
|
span: getSpan(node),
|
|
46
46
|
})
|
|
47
47
|
},
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import type { Rule, VisitorCallbacks } from
|
|
2
|
-
import { getSpan, isCallTo, isMemberCallTo } from
|
|
1
|
+
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
|
+
import { getSpan, isCallTo, isMemberCallTo } from '../../utils/ast'
|
|
3
3
|
|
|
4
4
|
export const noImperativeNavigateInRender: Rule = {
|
|
5
5
|
meta: {
|
|
6
|
-
id:
|
|
7
|
-
category:
|
|
6
|
+
id: 'pyreon/no-imperative-navigate-in-render',
|
|
7
|
+
category: 'router',
|
|
8
8
|
description:
|
|
9
|
-
|
|
10
|
-
severity:
|
|
9
|
+
'Error when navigate() or router.push() is called at the top level of a component — causes infinite render loops.',
|
|
10
|
+
severity: 'error',
|
|
11
11
|
fixable: false,
|
|
12
12
|
},
|
|
13
13
|
create(context) {
|
|
@@ -20,27 +20,27 @@ export const noImperativeNavigateInRender: Rule = {
|
|
|
20
20
|
|
|
21
21
|
const callbacks: VisitorCallbacks = {
|
|
22
22
|
FunctionDeclaration(node: any) {
|
|
23
|
-
const name: string = node.id?.name ??
|
|
23
|
+
const name: string = node.id?.name ?? ''
|
|
24
24
|
if (/^[A-Z]/.test(name)) {
|
|
25
25
|
componentBodyDepth++
|
|
26
26
|
}
|
|
27
27
|
},
|
|
28
|
-
|
|
29
|
-
const name: string = node.id?.name ??
|
|
28
|
+
'FunctionDeclaration:exit'(node: any) {
|
|
29
|
+
const name: string = node.id?.name ?? ''
|
|
30
30
|
if (/^[A-Z]/.test(name)) {
|
|
31
31
|
componentBodyDepth--
|
|
32
32
|
}
|
|
33
33
|
},
|
|
34
34
|
// For arrow functions, we use VariableDeclarator to detect component assignment
|
|
35
35
|
VariableDeclarator(node: any) {
|
|
36
|
-
const name: string = node.id?.name ??
|
|
37
|
-
if (/^[A-Z]/.test(name) && node.init?.type ===
|
|
36
|
+
const name: string = node.id?.name ?? ''
|
|
37
|
+
if (/^[A-Z]/.test(name) && node.init?.type === 'ArrowFunctionExpression') {
|
|
38
38
|
componentBodyDepth++
|
|
39
39
|
}
|
|
40
40
|
},
|
|
41
|
-
|
|
42
|
-
const name: string = node.id?.name ??
|
|
43
|
-
if (/^[A-Z]/.test(name) && node.init?.type ===
|
|
41
|
+
'VariableDeclarator:exit'(node: any) {
|
|
42
|
+
const name: string = node.id?.name ?? ''
|
|
43
|
+
if (/^[A-Z]/.test(name) && node.init?.type === 'ArrowFunctionExpression') {
|
|
44
44
|
componentBodyDepth--
|
|
45
45
|
}
|
|
46
46
|
},
|
|
@@ -56,15 +56,15 @@ export const noImperativeNavigateInRender: Rule = {
|
|
|
56
56
|
// Only report if we're in a component body and NOT inside a safe callback
|
|
57
57
|
if (safeDepth > 0) return
|
|
58
58
|
|
|
59
|
-
if (isCallTo(node,
|
|
59
|
+
if (isCallTo(node, 'navigate') || isMemberCallTo(node, 'router', 'push')) {
|
|
60
60
|
context.report({
|
|
61
61
|
message:
|
|
62
|
-
|
|
62
|
+
'Imperative navigation at the top level of a component — this runs on every render and causes infinite loops. Move inside `onMount`, `effect`, or an event handler.',
|
|
63
63
|
span: getSpan(node),
|
|
64
64
|
})
|
|
65
65
|
}
|
|
66
66
|
},
|
|
67
|
-
|
|
67
|
+
'CallExpression:exit'(node: any) {
|
|
68
68
|
if (componentBodyDepth <= 0) return
|
|
69
69
|
if (isSafeWrapperCall(node)) {
|
|
70
70
|
safeDepth--
|
|
@@ -77,7 +77,7 @@ export const noImperativeNavigateInRender: Rule = {
|
|
|
77
77
|
|
|
78
78
|
function isSafeWrapperCall(node: any): boolean {
|
|
79
79
|
const callee = node.callee
|
|
80
|
-
if (!callee || callee.type !==
|
|
80
|
+
if (!callee || callee.type !== 'Identifier') return false
|
|
81
81
|
const name: string = callee.name
|
|
82
|
-
return name ===
|
|
82
|
+
return name === 'onMount' || name === 'effect' || name === 'onUnmount'
|
|
83
83
|
}
|
|
@@ -1,27 +1,27 @@
|
|
|
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
|
function isCatchAllPath(value: string): boolean {
|
|
6
|
-
return value ===
|
|
6
|
+
return value === '*' || value.endsWith('*')
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
function getPathValue(prop: any): string | null {
|
|
10
10
|
const key = prop.key
|
|
11
11
|
if (!key) return null
|
|
12
|
-
const keyName = key.type ===
|
|
13
|
-
if (keyName !==
|
|
12
|
+
const keyName = key.type === 'Identifier' ? key.name : null
|
|
13
|
+
if (keyName !== 'path') return null
|
|
14
14
|
const val = prop.value
|
|
15
|
-
if (val?.type ===
|
|
15
|
+
if (val?.type === 'Literal' && typeof val.value === 'string') {
|
|
16
16
|
return val.value
|
|
17
17
|
}
|
|
18
18
|
return null
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
function hasPathProperty(obj: any): boolean {
|
|
22
|
-
if (!obj || obj.type !==
|
|
22
|
+
if (!obj || obj.type !== 'ObjectExpression') return false
|
|
23
23
|
for (const prop of obj.properties ?? []) {
|
|
24
|
-
if (prop.type !==
|
|
24
|
+
if (prop.type !== 'Property') continue
|
|
25
25
|
if (getPathValue(prop) !== null) return true
|
|
26
26
|
}
|
|
27
27
|
return false
|
|
@@ -29,9 +29,9 @@ function hasPathProperty(obj: any): boolean {
|
|
|
29
29
|
|
|
30
30
|
function hasCatchAllRoute(elements: any[]): boolean {
|
|
31
31
|
for (const elem of elements) {
|
|
32
|
-
if (!elem || elem.type !==
|
|
32
|
+
if (!elem || elem.type !== 'ObjectExpression') continue
|
|
33
33
|
for (const prop of elem.properties ?? []) {
|
|
34
|
-
if (prop.type !==
|
|
34
|
+
if (prop.type !== 'Property') continue
|
|
35
35
|
const pathVal = getPathValue(prop)
|
|
36
36
|
if (pathVal !== null && isCatchAllPath(pathVal)) return true
|
|
37
37
|
}
|
|
@@ -41,11 +41,11 @@ function hasCatchAllRoute(elements: any[]): boolean {
|
|
|
41
41
|
|
|
42
42
|
export const noMissingFallback: Rule = {
|
|
43
43
|
meta: {
|
|
44
|
-
id:
|
|
45
|
-
category:
|
|
44
|
+
id: 'pyreon/no-missing-fallback',
|
|
45
|
+
category: 'router',
|
|
46
46
|
description:
|
|
47
47
|
'Warn when route config has no catch-all route (`path: "*"` or `path: "/:rest*"`).',
|
|
48
|
-
severity:
|
|
48
|
+
severity: 'warn',
|
|
49
49
|
fixable: false,
|
|
50
50
|
},
|
|
51
51
|
create(context) {
|
|
@@ -56,7 +56,7 @@ export const noMissingFallback: Rule = {
|
|
|
56
56
|
const callbacks: VisitorCallbacks = {
|
|
57
57
|
ImportDeclaration(node: any) {
|
|
58
58
|
const info = extractImportInfo(node)
|
|
59
|
-
if (info && info.source ===
|
|
59
|
+
if (info && info.source === '@pyreon/router') {
|
|
60
60
|
importsRouter = true
|
|
61
61
|
}
|
|
62
62
|
},
|
|
@@ -73,7 +73,7 @@ export const noMissingFallback: Rule = {
|
|
|
73
73
|
foundCatchAll = true
|
|
74
74
|
}
|
|
75
75
|
},
|
|
76
|
-
|
|
76
|
+
'Program:exit'() {
|
|
77
77
|
if (!importsRouter || !routeArraySpan || foundCatchAll) return
|
|
78
78
|
context.report({
|
|
79
79
|
message:
|
|
@@ -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 preferUseIsActive: Rule = {
|
|
5
5
|
meta: {
|
|
6
|
-
id:
|
|
7
|
-
category:
|
|
6
|
+
id: 'pyreon/prefer-use-is-active',
|
|
7
|
+
category: 'router',
|
|
8
8
|
description:
|
|
9
9
|
'Suggest useIsActive() instead of `location.pathname === "/foo"` or `route.path === "/foo"` patterns.',
|
|
10
|
-
severity:
|
|
10
|
+
severity: 'info',
|
|
11
11
|
fixable: false,
|
|
12
12
|
},
|
|
13
13
|
create(context) {
|
|
14
14
|
const callbacks: VisitorCallbacks = {
|
|
15
15
|
BinaryExpression(node: any) {
|
|
16
|
-
if (node.operator !==
|
|
16
|
+
if (node.operator !== '===' && node.operator !== '==') return
|
|
17
17
|
|
|
18
18
|
// Check both sides for location.pathname or route.path
|
|
19
19
|
if (isPathComparison(node.left) || isPathComparison(node.right)) {
|
|
20
20
|
context.report({
|
|
21
21
|
message:
|
|
22
|
-
|
|
22
|
+
'Manual path comparison — use `useIsActive()` for reactive route matching with segment-aware prefix matching.',
|
|
23
23
|
span: getSpan(node),
|
|
24
24
|
})
|
|
25
25
|
}
|
|
@@ -30,16 +30,16 @@ export const preferUseIsActive: Rule = {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
function isPathComparison(node: any): boolean {
|
|
33
|
-
if (!node || node.type !==
|
|
33
|
+
if (!node || node.type !== 'MemberExpression') return false
|
|
34
34
|
const obj = node.object
|
|
35
35
|
const prop = node.property
|
|
36
|
-
if (!obj || !prop || prop.type !==
|
|
36
|
+
if (!obj || !prop || prop.type !== 'Identifier') return false
|
|
37
37
|
|
|
38
38
|
// location.pathname
|
|
39
|
-
if (obj.type ===
|
|
39
|
+
if (obj.type === 'Identifier' && obj.name === 'location' && prop.name === 'pathname') return true
|
|
40
40
|
|
|
41
41
|
// route.path
|
|
42
|
-
if (obj.type ===
|
|
42
|
+
if (obj.type === 'Identifier' && obj.name === 'route' && prop.name === 'path') return true
|
|
43
43
|
|
|
44
44
|
return false
|
|
45
45
|
}
|