@pyreon/lint 0.12.13 → 0.12.15
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 +55 -2
- package/lib/analysis/cli.js.html +1 -1
- package/lib/analysis/index.js.html +1 -1
- package/lib/cli.js +960 -162
- package/lib/cli.js.map +1 -1
- package/lib/index.js +935 -161
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +96 -23
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +2 -1
- package/schema/pyreonlintrc.schema.json +64 -0
- package/src/cli.ts +44 -2
- package/src/config/presets.ts +13 -1
- package/src/index.ts +7 -0
- package/src/lint.ts +37 -6
- package/src/lsp/index.ts +15 -2
- package/src/rules/architecture/dev-guard-warnings.ts +172 -17
- package/src/rules/architecture/no-circular-import.ts +7 -0
- package/src/rules/architecture/no-process-dev-gate.ts +18 -45
- package/src/rules/architecture/require-browser-smoke-test.ts +227 -0
- package/src/rules/form/no-submit-without-validation.ts +9 -0
- package/src/rules/form/no-unregistered-field.ts +9 -0
- package/src/rules/hooks/no-raw-addeventlistener.ts +7 -0
- package/src/rules/hooks/no-raw-localstorage.ts +12 -1
- package/src/rules/hooks/no-raw-setinterval.ts +14 -0
- package/src/rules/index.ts +4 -1
- package/src/rules/jsx/no-props-destructure.ts +20 -6
- package/src/rules/lifecycle/no-dom-in-setup.ts +67 -7
- package/src/rules/reactivity/no-bare-signal-in-jsx.ts +12 -1
- package/src/rules/reactivity/no-unbatched-updates.ts +3 -0
- package/src/rules/router/no-imperative-navigate-in-render.ts +131 -35
- package/src/rules/ssr/no-window-in-ssr.ts +418 -35
- package/src/rules/store/no-duplicate-store-id.ts +11 -0
- package/src/rules/store/no-mutate-store-state.ts +11 -1
- package/src/rules/styling/no-dynamic-styled.ts +13 -24
- package/src/rules/styling/no-theme-outside-provider.ts +34 -2
- package/src/runner.ts +100 -10
- package/src/tests/runner.test.ts +1573 -21
- package/src/types.ts +74 -3
- package/src/utils/component-context.ts +106 -0
- package/src/utils/exempt-paths.ts +39 -0
- package/src/utils/file-roles.ts +32 -0
- package/src/utils/imports.ts +4 -1
- package/src/utils/validate-options.ts +68 -0
- package/src/watcher.ts +17 -0
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
|
+
import { isPathExempt } from '../../utils/exempt-paths'
|
|
3
|
+
import { createComponentContextTracker } from '../../utils/component-context'
|
|
2
4
|
import { getSpan, isCallTo } from '../../utils/ast'
|
|
3
5
|
|
|
4
6
|
const TIMER_FNS = new Set(['setInterval', 'setTimeout'])
|
|
@@ -10,16 +12,28 @@ export const noRawSetInterval: Rule = {
|
|
|
10
12
|
description: 'Suggest wrapping setInterval/setTimeout in onMount for automatic cleanup.',
|
|
11
13
|
severity: 'info',
|
|
12
14
|
fixable: false,
|
|
15
|
+
schema: { exemptPaths: 'string[]' },
|
|
13
16
|
},
|
|
14
17
|
create(context) {
|
|
18
|
+
// Configurable `exemptPaths` — for packages that IMPLEMENT
|
|
19
|
+
// `useInterval` / `useTimeout` (they can't use themselves).
|
|
20
|
+
if (isPathExempt(context)) return {}
|
|
21
|
+
|
|
22
|
+
// Only flag when *inside* a component / hook setup body. Module-level
|
|
23
|
+
// timers, utility functions, and test callbacks have their own
|
|
24
|
+
// lifecycle and don't need component-tied cleanup.
|
|
25
|
+
const ctx = createComponentContextTracker()
|
|
26
|
+
|
|
15
27
|
let mountDepth = 0
|
|
16
28
|
const callbacks: VisitorCallbacks = {
|
|
29
|
+
...ctx.callbacks,
|
|
17
30
|
CallExpression(node: any) {
|
|
18
31
|
if (isCallTo(node, 'onMount')) {
|
|
19
32
|
mountDepth++
|
|
20
33
|
}
|
|
21
34
|
|
|
22
35
|
if (mountDepth > 0) return
|
|
36
|
+
if (!ctx.isInComponentOrHook()) return
|
|
23
37
|
|
|
24
38
|
const callee = node.callee
|
|
25
39
|
if (!callee || callee.type !== 'Identifier') return
|
package/src/rules/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { noCrossLayerImport } from './architecture/no-cross-layer-import'
|
|
|
10
10
|
import { noDeepImport } from './architecture/no-deep-import'
|
|
11
11
|
import { noErrorWithoutPrefix } from './architecture/no-error-without-prefix'
|
|
12
12
|
import { noProcessDevGate } from './architecture/no-process-dev-gate'
|
|
13
|
+
import { requireBrowserSmokeTest } from './architecture/require-browser-smoke-test'
|
|
13
14
|
import { noSubmitWithoutValidation } from './form/no-submit-without-validation'
|
|
14
15
|
// Form
|
|
15
16
|
import { noUnregisteredField } from './form/no-unregistered-field'
|
|
@@ -108,13 +109,14 @@ export const allRules: Rule[] = [
|
|
|
108
109
|
noWindowInSsr,
|
|
109
110
|
noMismatchRisk,
|
|
110
111
|
preferRequestContext,
|
|
111
|
-
// Architecture (
|
|
112
|
+
// Architecture (7)
|
|
112
113
|
noCircularImport,
|
|
113
114
|
noDeepImport,
|
|
114
115
|
noCrossLayerImport,
|
|
115
116
|
devGuardWarnings,
|
|
116
117
|
noErrorWithoutPrefix,
|
|
117
118
|
noProcessDevGate,
|
|
119
|
+
requireBrowserSmokeTest,
|
|
118
120
|
// Store (3)
|
|
119
121
|
noStoreOutsideProvider,
|
|
120
122
|
noMutateStoreState,
|
|
@@ -187,6 +189,7 @@ export {
|
|
|
187
189
|
noPeekInTracked,
|
|
188
190
|
noProcessDevGate,
|
|
189
191
|
noPropsDestructure,
|
|
192
|
+
requireBrowserSmokeTest,
|
|
190
193
|
// Hooks
|
|
191
194
|
noRawAddEventListener,
|
|
192
195
|
noRawLocalStorage,
|
|
@@ -42,25 +42,40 @@ export const noPropsDestructure: Rule = {
|
|
|
42
42
|
},
|
|
43
43
|
create(context) {
|
|
44
44
|
let functionDepth = 0
|
|
45
|
+
// oxc visitor doesn't pass `parent` to callbacks — previous
|
|
46
|
+
// `parent?.type === 'CallExpression'` check was silently inert. Pre-mark
|
|
47
|
+
// function nodes that appear as CallExpression arguments on the way in.
|
|
48
|
+
const callArgFns = new WeakSet<any>()
|
|
45
49
|
|
|
46
50
|
const callbacks: VisitorCallbacks = {
|
|
51
|
+
CallExpression(node: any) {
|
|
52
|
+
for (const arg of node.arguments ?? []) {
|
|
53
|
+
if (
|
|
54
|
+
arg?.type === 'ArrowFunctionExpression' ||
|
|
55
|
+
arg?.type === 'FunctionExpression' ||
|
|
56
|
+
arg?.type === 'FunctionDeclaration'
|
|
57
|
+
) {
|
|
58
|
+
callArgFns.add(arg)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
},
|
|
47
62
|
ArrowFunctionExpression(node: any) {
|
|
48
63
|
functionDepth++
|
|
49
|
-
checkFunction(node, context, functionDepth)
|
|
64
|
+
checkFunction(node, context, functionDepth, callArgFns)
|
|
50
65
|
},
|
|
51
66
|
'ArrowFunctionExpression:exit'() {
|
|
52
67
|
functionDepth--
|
|
53
68
|
},
|
|
54
69
|
FunctionDeclaration(node: any) {
|
|
55
70
|
functionDepth++
|
|
56
|
-
checkFunction(node, context, functionDepth)
|
|
71
|
+
checkFunction(node, context, functionDepth, callArgFns)
|
|
57
72
|
},
|
|
58
73
|
'FunctionDeclaration:exit'() {
|
|
59
74
|
functionDepth--
|
|
60
75
|
},
|
|
61
76
|
FunctionExpression(node: any) {
|
|
62
77
|
functionDepth++
|
|
63
|
-
checkFunction(node, context, functionDepth)
|
|
78
|
+
checkFunction(node, context, functionDepth, callArgFns)
|
|
64
79
|
},
|
|
65
80
|
'FunctionExpression:exit'() {
|
|
66
81
|
functionDepth--
|
|
@@ -70,7 +85,7 @@ export const noPropsDestructure: Rule = {
|
|
|
70
85
|
},
|
|
71
86
|
}
|
|
72
87
|
|
|
73
|
-
function checkFunction(node: any, context: any, depth: number) {
|
|
88
|
+
function checkFunction(node: any, context: any, depth: number, callArgFns: WeakSet<any>) {
|
|
74
89
|
const params = node.params
|
|
75
90
|
if (!params || params.length === 0) return
|
|
76
91
|
|
|
@@ -82,8 +97,7 @@ function checkFunction(node: any, context: any, depth: number) {
|
|
|
82
97
|
|
|
83
98
|
// Skip functions passed as arguments to HOC factories
|
|
84
99
|
// e.g. createLink(({ href, ...rest }) => <a {...rest} />)
|
|
85
|
-
|
|
86
|
-
if (parent?.type === 'CallExpression' && parent.arguments?.includes(node)) return
|
|
100
|
+
if (callArgFns.has(node)) return
|
|
87
101
|
|
|
88
102
|
const body = node.body
|
|
89
103
|
if (!body) return
|
|
@@ -18,12 +18,74 @@ export const noDomInSetup: Rule = {
|
|
|
18
18
|
fixable: false,
|
|
19
19
|
},
|
|
20
20
|
create(context) {
|
|
21
|
-
let safeDepth = 0
|
|
21
|
+
let safeDepth = 0
|
|
22
|
+
function isSafeContextCall(node: any): boolean {
|
|
23
|
+
// Lifecycle + effect hooks only run post-mount in a browser.
|
|
24
|
+
// `onUnmount` / `onCleanup` fire after the component has mounted so
|
|
25
|
+
// the DOM exists. `renderEffect` is the signal-system equivalent of
|
|
26
|
+
// `effect`. `requestAnimationFrame` only schedules its callback
|
|
27
|
+
// inside a browser frame, so its body is always post-setup execution.
|
|
28
|
+
return (
|
|
29
|
+
isCallTo(node, 'onMount') ||
|
|
30
|
+
isCallTo(node, 'onUnmount') ||
|
|
31
|
+
isCallTo(node, 'onCleanup') ||
|
|
32
|
+
isCallTo(node, 'effect') ||
|
|
33
|
+
isCallTo(node, 'renderEffect') ||
|
|
34
|
+
isCallTo(node, 'requestAnimationFrame')
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
// `if (typeof document === 'undefined') return|throw` at the head of a
|
|
38
|
+
// function makes the rest of the body implicitly browser-safe — the
|
|
39
|
+
// SSR path bailed out. Same heuristic as `no-window-in-ssr`.
|
|
40
|
+
function isNegatedTypeofDocument(test: any): boolean {
|
|
41
|
+
if (!test) return false
|
|
42
|
+
if (
|
|
43
|
+
test.type === 'BinaryExpression' &&
|
|
44
|
+
(test.operator === '===' || test.operator === '==') &&
|
|
45
|
+
test.left?.type === 'UnaryExpression' &&
|
|
46
|
+
test.left.operator === 'typeof' &&
|
|
47
|
+
test.left.argument?.type === 'Identifier' &&
|
|
48
|
+
(test.left.argument.name === 'document' || test.left.argument.name === 'window')
|
|
49
|
+
)
|
|
50
|
+
return true
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
function isEarlyReturnDocumentGuard(stmt: any): boolean {
|
|
54
|
+
if (!stmt || stmt.type !== 'IfStatement') return false
|
|
55
|
+
if (!isNegatedTypeofDocument(stmt.test)) return false
|
|
56
|
+
const c = stmt.consequent
|
|
57
|
+
const isTerminator = (s: any): boolean =>
|
|
58
|
+
s?.type === 'ReturnStatement' || s?.type === 'ThrowStatement'
|
|
59
|
+
if (isTerminator(c)) return true
|
|
60
|
+
if (c?.type === 'BlockStatement' && c.body.length === 1 && isTerminator(c.body[0]))
|
|
61
|
+
return true
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
// Per-function depth bumps from early-return guards — popped on exit.
|
|
65
|
+
const earlyReturnStack: number[] = []
|
|
66
|
+
function pushFunctionScope(node: any) {
|
|
67
|
+
const body = node?.body
|
|
68
|
+
const stmts = body?.type === 'BlockStatement' ? body.body : null
|
|
69
|
+
let bumps = 0
|
|
70
|
+
if (stmts && stmts.length > 0 && isEarlyReturnDocumentGuard(stmts[0])) {
|
|
71
|
+
bumps = 1
|
|
72
|
+
safeDepth++
|
|
73
|
+
}
|
|
74
|
+
earlyReturnStack.push(bumps)
|
|
75
|
+
}
|
|
76
|
+
function popFunctionScope() {
|
|
77
|
+
const bumps = earlyReturnStack.pop() ?? 0
|
|
78
|
+
if (bumps > 0) safeDepth -= bumps
|
|
79
|
+
}
|
|
22
80
|
const callbacks: VisitorCallbacks = {
|
|
81
|
+
FunctionDeclaration: pushFunctionScope,
|
|
82
|
+
'FunctionDeclaration:exit': popFunctionScope,
|
|
83
|
+
FunctionExpression: pushFunctionScope,
|
|
84
|
+
'FunctionExpression:exit': popFunctionScope,
|
|
85
|
+
ArrowFunctionExpression: pushFunctionScope,
|
|
86
|
+
'ArrowFunctionExpression:exit': popFunctionScope,
|
|
23
87
|
CallExpression(node: any) {
|
|
24
|
-
if (
|
|
25
|
-
safeDepth++
|
|
26
|
-
}
|
|
88
|
+
if (isSafeContextCall(node)) safeDepth++
|
|
27
89
|
|
|
28
90
|
if (safeDepth > 0) return
|
|
29
91
|
|
|
@@ -43,9 +105,7 @@ export const noDomInSetup: Rule = {
|
|
|
43
105
|
}
|
|
44
106
|
},
|
|
45
107
|
'CallExpression:exit'(node: any) {
|
|
46
|
-
if (
|
|
47
|
-
safeDepth--
|
|
48
|
-
}
|
|
108
|
+
if (isSafeContextCall(node)) safeDepth--
|
|
49
109
|
},
|
|
50
110
|
}
|
|
51
111
|
return callbacks
|
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
2
|
import { getSpan } from '../../utils/ast'
|
|
3
3
|
|
|
4
|
+
// `use…`/`get…`/`is…`/`has…` are conventional hook/getter prefixes — not
|
|
5
|
+
// signal reads. `[A-Z]…` covers component invocations. The skip-names set
|
|
6
|
+
// covers framework VNode-producing helpers whose call sites always produce
|
|
7
|
+
// JSX, not signal values:
|
|
8
|
+
// - `render` — `@pyreon/ui-core` render-prop helper
|
|
9
|
+
// - `h` — `@pyreon/core` hyperscript (JSX runtime)
|
|
10
|
+
// - `cloneVNode` — `@pyreon/core` VNode-tree cloner (used by kinetic)
|
|
11
|
+
// Matching is on full identifier name, so user-defined signals with these
|
|
12
|
+
// exact names would slip through; rename to `rendered`/`hyperscript`/etc.
|
|
13
|
+
// or move the read outside JSX.
|
|
14
|
+
const SKIP_NAMES = new Set(['render', 'h', 'cloneVNode'])
|
|
4
15
|
const SKIP_PREFIXES = /^(use|get|is|has|[A-Z])/
|
|
5
16
|
|
|
6
17
|
export const noBareSignalInJsx: Rule = {
|
|
@@ -35,7 +46,7 @@ export const noBareSignalInJsx: Rule = {
|
|
|
35
46
|
if (!callee || callee.type !== 'Identifier') return
|
|
36
47
|
|
|
37
48
|
const name: string = callee.name
|
|
38
|
-
if (SKIP_PREFIXES.test(name)) return
|
|
49
|
+
if (SKIP_NAMES.has(name) || SKIP_PREFIXES.test(name)) return
|
|
39
50
|
|
|
40
51
|
const span = getSpan(node)
|
|
41
52
|
const source = context.getSourceText()
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
2
|
import { getSpan, isCallTo, isSetCall } from '../../utils/ast'
|
|
3
|
+
import { isPathExempt } from '../../utils/exempt-paths'
|
|
3
4
|
|
|
4
5
|
interface ScopeInfo {
|
|
5
6
|
setCalls: Array<{ span: { start: number; end: number } }>
|
|
@@ -15,8 +16,10 @@ export const noUnbatchedUpdates: Rule = {
|
|
|
15
16
|
description: 'Warn when 3+ .set() calls occur in the same function without batch().',
|
|
16
17
|
severity: 'warn',
|
|
17
18
|
fixable: false,
|
|
19
|
+
schema: { exemptPaths: 'string[]' },
|
|
18
20
|
},
|
|
19
21
|
create(context) {
|
|
22
|
+
if (isPathExempt(context)) return {}
|
|
20
23
|
const scopeStack: ScopeInfo[] = []
|
|
21
24
|
let batchDepth = 0
|
|
22
25
|
|
|
@@ -11,73 +11,169 @@ export const noImperativeNavigateInRender: Rule = {
|
|
|
11
11
|
fixable: false,
|
|
12
12
|
},
|
|
13
13
|
create(context) {
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
14
|
+
// The rule fires when `navigate()` / `router.push()` runs synchronously
|
|
15
|
+
// as part of rendering the component — the infinite-render-loop case.
|
|
16
|
+
//
|
|
17
|
+
// The render path consists of the component body itself PLUS any nested
|
|
18
|
+
// function that is called synchronously from the render body (IIFEs,
|
|
19
|
+
// locally-bound fns that are invoked immediately). Nested functions
|
|
20
|
+
// that are stored (assigned to a const, returned, passed as a callback
|
|
21
|
+
// to an event handler / setTimeout / .then) are deferred execution and
|
|
22
|
+
// don't contribute to the render-loop bug.
|
|
18
23
|
let componentBodyDepth = 0
|
|
19
|
-
|
|
24
|
+
// Depth inside a nested non-component function AT ALL — used to scope
|
|
25
|
+
// call-tracking (we only record nested-fn calls while inside a
|
|
26
|
+
// component body, not in module-level code).
|
|
27
|
+
let nestedFnDepth = 0
|
|
28
|
+
// Arrow/Function expressions that are the direct init of a PascalCase
|
|
29
|
+
// `VariableDeclarator` (= component assignment) — marked so the
|
|
30
|
+
// ArrowFn/FunctionExpression visitor knows to NOT count them as nested.
|
|
31
|
+
const componentInits = new WeakSet<any>()
|
|
32
|
+
// Names of locally-bound nested fns (e.g. `const fn = () => router.push(...)`)
|
|
33
|
+
// whose body contains a dangerous navigation call. Populated during the
|
|
34
|
+
// nested fn's walk; checked on synchronous `fn()` calls in the render
|
|
35
|
+
// body. Stack-scoped to the enclosing component so nested components
|
|
36
|
+
// don't leak bindings.
|
|
37
|
+
const dangerousBindings: Array<Set<string>> = []
|
|
38
|
+
// When we're inside a nested fn whose binding might be dangerous, mark
|
|
39
|
+
// the current nested fn as "contains navigate" if we see one.
|
|
40
|
+
const nestedFnStack: Array<{ containsNavigate: boolean; bindingName: string | null }> = []
|
|
41
|
+
|
|
42
|
+
function isComponentFunctionDecl(node: any): boolean {
|
|
43
|
+
return /^[A-Z]/.test(node.id?.name ?? '')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function enterNestedFn(node: any, bindingName: string | null) {
|
|
47
|
+
nestedFnDepth++
|
|
48
|
+
nestedFnStack.push({ containsNavigate: false, bindingName })
|
|
49
|
+
}
|
|
50
|
+
function exitNestedFn() {
|
|
51
|
+
nestedFnDepth--
|
|
52
|
+
const frame = nestedFnStack.pop()
|
|
53
|
+
if (!frame) return
|
|
54
|
+
if (frame.containsNavigate && frame.bindingName && dangerousBindings.length > 0) {
|
|
55
|
+
dangerousBindings[dangerousBindings.length - 1]!.add(frame.bindingName)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isNavigateCall(node: any): boolean {
|
|
60
|
+
return isCallTo(node, 'navigate') || isMemberCallTo(node, 'router', 'push')
|
|
61
|
+
}
|
|
20
62
|
|
|
21
63
|
const callbacks: VisitorCallbacks = {
|
|
22
64
|
FunctionDeclaration(node: any) {
|
|
23
|
-
|
|
24
|
-
if (/^[A-Z]/.test(name)) {
|
|
65
|
+
if (isComponentFunctionDecl(node)) {
|
|
25
66
|
componentBodyDepth++
|
|
67
|
+
dangerousBindings.push(new Set())
|
|
68
|
+
} else if (componentBodyDepth > 0) {
|
|
69
|
+
enterNestedFn(node, node.id?.type === 'Identifier' ? node.id.name : null)
|
|
26
70
|
}
|
|
27
71
|
},
|
|
28
72
|
'FunctionDeclaration:exit'(node: any) {
|
|
29
|
-
|
|
30
|
-
if (/^[A-Z]/.test(name)) {
|
|
73
|
+
if (isComponentFunctionDecl(node)) {
|
|
31
74
|
componentBodyDepth--
|
|
75
|
+
dangerousBindings.pop()
|
|
76
|
+
} else if (componentBodyDepth > 0) {
|
|
77
|
+
exitNestedFn()
|
|
32
78
|
}
|
|
33
79
|
},
|
|
34
|
-
// For arrow functions, we use VariableDeclarator to detect component assignment
|
|
35
80
|
VariableDeclarator(node: any) {
|
|
36
|
-
|
|
37
|
-
|
|
81
|
+
if (
|
|
82
|
+
/^[A-Z]/.test(node.id?.name ?? '') &&
|
|
83
|
+
(node.init?.type === 'ArrowFunctionExpression' || node.init?.type === 'FunctionExpression')
|
|
84
|
+
) {
|
|
38
85
|
componentBodyDepth++
|
|
86
|
+
dangerousBindings.push(new Set())
|
|
87
|
+
componentInits.add(node.init)
|
|
39
88
|
}
|
|
40
89
|
},
|
|
41
90
|
'VariableDeclarator:exit'(node: any) {
|
|
42
|
-
|
|
43
|
-
|
|
91
|
+
if (
|
|
92
|
+
/^[A-Z]/.test(node.id?.name ?? '') &&
|
|
93
|
+
(node.init?.type === 'ArrowFunctionExpression' || node.init?.type === 'FunctionExpression')
|
|
94
|
+
) {
|
|
44
95
|
componentBodyDepth--
|
|
96
|
+
dangerousBindings.pop()
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
ArrowFunctionExpression(node: any) {
|
|
100
|
+
if (componentInits.has(node)) return
|
|
101
|
+
if (componentBodyDepth > 0) {
|
|
102
|
+
// Binding name comes from the parent VariableDeclarator if the
|
|
103
|
+
// arrow is its init — e.g. `const fn = () => …`. Parent is not
|
|
104
|
+
// passed by oxc, so we rely on order: `VariableDeclarator` visits
|
|
105
|
+
// before its init. We patch the binding name in a pre-visitor pass.
|
|
106
|
+
enterNestedFn(node, bindingAssignmentNames.get(node) ?? null)
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
'ArrowFunctionExpression:exit'(node: any) {
|
|
110
|
+
if (componentInits.has(node)) return
|
|
111
|
+
if (componentBodyDepth > 0) exitNestedFn()
|
|
112
|
+
},
|
|
113
|
+
FunctionExpression(node: any) {
|
|
114
|
+
if (componentInits.has(node)) return
|
|
115
|
+
if (componentBodyDepth > 0) {
|
|
116
|
+
enterNestedFn(node, bindingAssignmentNames.get(node) ?? null)
|
|
45
117
|
}
|
|
46
118
|
},
|
|
47
|
-
|
|
119
|
+
'FunctionExpression:exit'(node: any) {
|
|
120
|
+
if (componentInits.has(node)) return
|
|
121
|
+
if (componentBodyDepth > 0) exitNestedFn()
|
|
122
|
+
},
|
|
48
123
|
CallExpression(node: any) {
|
|
49
124
|
if (componentBodyDepth <= 0) return
|
|
50
125
|
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
126
|
+
// Direct `navigate()` / `router.push()` call. At render-body depth
|
|
127
|
+
// (nestedFnDepth === 0) it's the classic infinite-loop bug. Inside
|
|
128
|
+
// a nested fn, mark the enclosing frame as dangerous (so a later
|
|
129
|
+
// sync call of that fn's binding re-surfaces the issue).
|
|
130
|
+
if (isNavigateCall(node)) {
|
|
131
|
+
if (nestedFnDepth === 0) {
|
|
132
|
+
context.report({
|
|
133
|
+
message:
|
|
134
|
+
'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.',
|
|
135
|
+
span: getSpan(node),
|
|
136
|
+
})
|
|
137
|
+
} else if (nestedFnStack.length > 0) {
|
|
138
|
+
nestedFnStack[nestedFnStack.length - 1]!.containsNavigate = true
|
|
139
|
+
}
|
|
140
|
+
return
|
|
54
141
|
}
|
|
55
142
|
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (
|
|
143
|
+
// Sync invocation (at render-body depth) of a locally-bound fn
|
|
144
|
+
// whose body contains `navigate()`. `const fn = () => router.push();
|
|
145
|
+
// fn()` IS the infinite-loop bug — the previous rewrite missed it.
|
|
146
|
+
if (
|
|
147
|
+
nestedFnDepth === 0 &&
|
|
148
|
+
node.callee?.type === 'Identifier' &&
|
|
149
|
+
dangerousBindings.length > 0 &&
|
|
150
|
+
dangerousBindings[dangerousBindings.length - 1]!.has(node.callee.name)
|
|
151
|
+
) {
|
|
60
152
|
context.report({
|
|
61
153
|
message:
|
|
62
|
-
'
|
|
154
|
+
'Synchronous call of a nested function that performs imperative navigation — this runs during render and causes infinite loops. Move the call inside `onMount`, `effect`, or an event handler.',
|
|
63
155
|
span: getSpan(node),
|
|
64
156
|
})
|
|
65
157
|
}
|
|
66
158
|
},
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Pre-walk: for each `VariableDeclarator` whose init is an ArrowFn or
|
|
162
|
+
// FunctionExpression, stash the binding name against the init node so
|
|
163
|
+
// the ArrowFn/FunctionExpression visitor can retrieve it without needing
|
|
164
|
+
// parent-in-visitor (oxc doesn't pass parent).
|
|
165
|
+
const bindingAssignmentNames = new WeakMap<any, string>()
|
|
166
|
+
callbacks.VariableDeclaration = (node: any) => {
|
|
167
|
+
for (const decl of node.declarations ?? []) {
|
|
168
|
+
if (
|
|
169
|
+
decl.id?.type === 'Identifier' &&
|
|
170
|
+
(decl.init?.type === 'ArrowFunctionExpression' ||
|
|
171
|
+
decl.init?.type === 'FunctionExpression')
|
|
172
|
+
) {
|
|
173
|
+
bindingAssignmentNames.set(decl.init, decl.id.name)
|
|
71
174
|
}
|
|
72
|
-
}
|
|
175
|
+
}
|
|
73
176
|
}
|
|
74
177
|
return callbacks
|
|
75
178
|
},
|
|
76
179
|
}
|
|
77
|
-
|
|
78
|
-
function isSafeWrapperCall(node: any): boolean {
|
|
79
|
-
const callee = node.callee
|
|
80
|
-
if (!callee || callee.type !== 'Identifier') return false
|
|
81
|
-
const name: string = callee.name
|
|
82
|
-
return name === 'onMount' || name === 'effect' || name === 'onUnmount'
|
|
83
|
-
}
|