@pyreon/lint 0.12.13 → 0.12.14
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,5 +1,6 @@
|
|
|
1
1
|
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
2
|
import { getSpan, isCallTo } from '../../utils/ast'
|
|
3
|
+
import { isPathExempt } from '../../utils/exempt-paths'
|
|
3
4
|
import { BROWSER_GLOBALS } from '../../utils/imports'
|
|
4
5
|
|
|
5
6
|
export const noWindowInSsr: Rule = {
|
|
@@ -9,61 +10,443 @@ export const noWindowInSsr: Rule = {
|
|
|
9
10
|
description: 'Disallow browser globals outside onMount/effect/typeof guards — they break SSR.',
|
|
10
11
|
severity: 'error',
|
|
11
12
|
fixable: false,
|
|
13
|
+
schema: { exemptPaths: 'string[]' },
|
|
12
14
|
},
|
|
13
15
|
create(context) {
|
|
16
|
+
// Configurable `exemptPaths` option — projects opt out directories
|
|
17
|
+
// that legitimately run in a DOM-only environment (e.g. a DOM renderer
|
|
18
|
+
// package has no SSR scenario). Monorepo configures its own paths in
|
|
19
|
+
// `.pyreonlintrc.json`; user apps typically leave this empty.
|
|
20
|
+
if (isPathExempt(context)) return {}
|
|
21
|
+
|
|
14
22
|
let safeDepth = 0
|
|
15
23
|
let typeofGuardDepth = 0
|
|
24
|
+
// Inside `typeof X` itself, the identifier mention is safe — `typeof` is
|
|
25
|
+
// the only operator that doesn't evaluate its operand. We track this via
|
|
26
|
+
// visitor enter/exit because the oxc visitor doesn't pass `parent` to
|
|
27
|
+
// identifier callbacks (the previous `parent.operator === 'typeof'`
|
|
28
|
+
// check was silently inert).
|
|
29
|
+
let inTypeofExpr = 0
|
|
30
|
+
// Identifiers that are member-property names (`x.addEventListener`),
|
|
31
|
+
// object-property keys (`{ document: 1 }`), or import-specifier names
|
|
32
|
+
// are NOT global references — pre-collected when their containing
|
|
33
|
+
// node is visited, then skipped when the bare Identifier visitor fires.
|
|
34
|
+
// Same root cause as `inTypeofExpr`: the previous `parent.type ===
|
|
35
|
+
// 'MemberExpression'` check was inert (oxc visitor doesn't pass parent).
|
|
36
|
+
const skipPropertyNodes = new WeakSet<any>()
|
|
37
|
+
// Identifiers inside TypeScript type-position nodes (`let x: Window`,
|
|
38
|
+
// `interface X { y: Document }`, `type T = Navigator`, generics, etc.)
|
|
39
|
+
// are type references — they're erased at compile time. Track via depth
|
|
40
|
+
// counter on any `TS*` node entry; identifiers visited while depth > 0
|
|
41
|
+
// are skipped.
|
|
42
|
+
let inTsTypePos = 0
|
|
43
|
+
|
|
44
|
+
// Track `const isBrowser = typeof window !== 'undefined'` (or any
|
|
45
|
+
// const whose initializer is a typeof check). Treats `if (isBrowser)`
|
|
46
|
+
// the same as `if (typeof window !== 'undefined')` — the universal
|
|
47
|
+
// browser-detection idiom. Set captured at module scope; lookups by name.
|
|
48
|
+
const typeofBoundConsts = new Set<string>()
|
|
49
|
+
function isPositiveTypeofCheck(expr: any): boolean {
|
|
50
|
+
if (!expr) return false
|
|
51
|
+
// `typeof X !== 'undefined'` (or `!=`) — the POSITIVE form: body is
|
|
52
|
+
// the browser-safe branch.
|
|
53
|
+
if (
|
|
54
|
+
expr.type === 'BinaryExpression' &&
|
|
55
|
+
(expr.operator === '!==' || expr.operator === '!=') &&
|
|
56
|
+
expr.left?.type === 'UnaryExpression' &&
|
|
57
|
+
expr.left.operator === 'typeof'
|
|
58
|
+
)
|
|
59
|
+
return true
|
|
60
|
+
// Bare `typeof X` as truthiness check (rare).
|
|
61
|
+
if (expr.type === 'UnaryExpression' && expr.operator === 'typeof') return true
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
/** Used by VariableDeclaration to decide whether to bind a const. */
|
|
65
|
+
function isTypeofCheckForBinding(expr: any): boolean {
|
|
66
|
+
if (!expr) return false
|
|
67
|
+
if (
|
|
68
|
+
expr.type === 'BinaryExpression' &&
|
|
69
|
+
expr.left?.type === 'UnaryExpression' &&
|
|
70
|
+
expr.left.operator === 'typeof'
|
|
71
|
+
)
|
|
72
|
+
return true
|
|
73
|
+
if (expr.type === 'UnaryExpression' && expr.operator === 'typeof') return true
|
|
74
|
+
// `const useVT = _isBrowser && ... && typeof X === 'function'` — if
|
|
75
|
+
// any term in an AND chain is a typeof check (direct or via another
|
|
76
|
+
// typeof-bound const), the whole expression is typeof-derived: every
|
|
77
|
+
// non-falsy value requires every term to have evaluated truthy.
|
|
78
|
+
if (expr.type === 'LogicalExpression' && expr.operator === '&&') {
|
|
79
|
+
return isTypeofCheckForBinding(expr.left) || isTypeofCheckForBinding(expr.right)
|
|
80
|
+
}
|
|
81
|
+
// `const handler = _isBrowser ? (e) => … : null` / `_isBrowser ? fn()
|
|
82
|
+
// : null` — ternary with a typeof-derived const as test. The non-null
|
|
83
|
+
// branch only exists when the guard is truthy, so the binding is
|
|
84
|
+
// transitively typeof-derived. Same for `_isBrowser ? X : null`
|
|
85
|
+
// where `X` is typeof-derived.
|
|
86
|
+
if (expr.type === 'ConditionalExpression') {
|
|
87
|
+
return isTypeofCheckForBinding(expr.test)
|
|
88
|
+
}
|
|
89
|
+
if (expr.type === 'Identifier' && typeofBoundConsts.has(expr.name)) return true
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* `if (test) { … }` — does the test indicate the body is the
|
|
94
|
+
* BROWSER-SAFE branch? Only positive forms qualify here. Negated
|
|
95
|
+
* forms (`typeof X === 'undefined'`, `!isBrowser`) are early-return
|
|
96
|
+
* guards handled separately.
|
|
97
|
+
*/
|
|
98
|
+
function testIsTypeofGuard(test: any): boolean {
|
|
99
|
+
if (!test) return false
|
|
100
|
+
if (isPositiveTypeofCheck(test)) return true
|
|
101
|
+
// `if (isBrowser)` — bound from a typeof, body is browser-safe.
|
|
102
|
+
if (test.type === 'Identifier' && typeofBoundConsts.has(test.name)) return true
|
|
103
|
+
// `if (isBrowser())` — function whose body returns a typeof check.
|
|
104
|
+
if (
|
|
105
|
+
test.type === 'CallExpression' &&
|
|
106
|
+
test.callee?.type === 'Identifier' &&
|
|
107
|
+
typeofGuardFunctions.has(test.callee.name)
|
|
108
|
+
)
|
|
109
|
+
return true
|
|
110
|
+
// `if (typeofGuard && other)` / `if (other && typeofGuard)` — either
|
|
111
|
+
// side being a typeof guard means the body only runs when the guard
|
|
112
|
+
// is truthy (AND short-circuits). Common in ternary tests like
|
|
113
|
+
// `IS_BROWSER && active() ? <Portal … /> : null`.
|
|
114
|
+
if (test.type === 'LogicalExpression' && test.operator === '&&') {
|
|
115
|
+
return testIsTypeofGuard(test.left) || testIsTypeofGuard(test.right)
|
|
116
|
+
}
|
|
117
|
+
return false
|
|
118
|
+
}
|
|
119
|
+
// Functions whose body is `return <typeof check expr>` — invoked as
|
|
120
|
+
// the convention `isBrowser()` / `isClient()` early-return guards.
|
|
121
|
+
// E.g. `function isBrowser() { return typeof window !== 'undefined' }`.
|
|
122
|
+
// Calls to these functions count as typeof checks for guard analysis.
|
|
123
|
+
// Pre-seeded with conventional names (recognised across module
|
|
124
|
+
// boundaries — same approach as `dev-guard-warnings` recognises
|
|
125
|
+
// `__DEV__`/`IS_DEV`/etc. by name): user-supplied implementations of
|
|
126
|
+
// `isBrowser` / `isClient` / `isServer` / `isSSR` are treated as
|
|
127
|
+
// typeof guards even when imported from another file. Each file's
|
|
128
|
+
// local `function isBrowser() { return typeof window !== 'undefined' }`
|
|
129
|
+
// also adds itself to the set.
|
|
130
|
+
const typeofGuardFunctions = new Set<string>(['isBrowser', 'isClient', 'isServer', 'isSSR'])
|
|
131
|
+
function bodyIsTypeofGuard(body: any): boolean {
|
|
132
|
+
if (!body) return false
|
|
133
|
+
// Arrow concise body: `() => typeof window !== 'undefined'` →
|
|
134
|
+
// body IS the expression directly, not a BlockStatement.
|
|
135
|
+
if (body.type !== 'BlockStatement') return isReturnedTypeofExpr(body)
|
|
136
|
+
// Block body: must be a single `return <expr>` statement.
|
|
137
|
+
const stmts = body.body ?? []
|
|
138
|
+
if (stmts.length !== 1) return false
|
|
139
|
+
const stmt = stmts[0]
|
|
140
|
+
if (stmt?.type !== 'ReturnStatement') return false
|
|
141
|
+
return isReturnedTypeofExpr(stmt.argument)
|
|
142
|
+
}
|
|
143
|
+
function isReturnedTypeofExpr(expr: any): boolean {
|
|
144
|
+
if (!expr) return false
|
|
145
|
+
if (isPositiveTypeofCheck(expr)) return true
|
|
146
|
+
// AND-chain of typeof checks (or typeof-bound consts) — a function
|
|
147
|
+
// returning `typeof window !== 'undefined' && typeof document !== 'undefined'`
|
|
148
|
+
// is still a typeof guard.
|
|
149
|
+
if (expr.type === 'LogicalExpression' && expr.operator === '&&') {
|
|
150
|
+
return isReturnedTypeofExpr(expr.left) && isReturnedTypeofExpr(expr.right)
|
|
151
|
+
}
|
|
152
|
+
// Identifier reference to a previously-bound typeof const.
|
|
153
|
+
if (expr.type === 'Identifier' && typeofBoundConsts.has(expr.name)) return true
|
|
154
|
+
return false
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Track functions that begin with an early-return on a NEGATED typeof
|
|
158
|
+
// guard: `if (typeof window === 'undefined') return …` or
|
|
159
|
+
// `if (!isBrowser) return`. After such a guard, the rest of the
|
|
160
|
+
// function body is implicitly typeof-guarded (the SSR path bailed).
|
|
161
|
+
// We use enter/exit on function nodes to bracket the guard zone.
|
|
162
|
+
function isNegatedTypeofExpr(test: any): boolean {
|
|
163
|
+
if (!test) return false
|
|
164
|
+
// `typeof X === 'undefined'` — explicit equality form
|
|
165
|
+
if (
|
|
166
|
+
test.type === 'BinaryExpression' &&
|
|
167
|
+
(test.operator === '===' || test.operator === '==') &&
|
|
168
|
+
test.left?.type === 'UnaryExpression' &&
|
|
169
|
+
test.left.operator === 'typeof'
|
|
170
|
+
)
|
|
171
|
+
return true
|
|
172
|
+
// `!isBrowser` where isBrowser is bound from typeof
|
|
173
|
+
if (
|
|
174
|
+
test.type === 'UnaryExpression' &&
|
|
175
|
+
test.operator === '!' &&
|
|
176
|
+
test.argument?.type === 'Identifier' &&
|
|
177
|
+
typeofBoundConsts.has(test.argument.name)
|
|
178
|
+
)
|
|
179
|
+
return true
|
|
180
|
+
// `!isBrowser()` where isBrowser is a typeof-guard function — common
|
|
181
|
+
// SSR pattern in storage adapters: `if (!isBrowser()) return null`.
|
|
182
|
+
if (
|
|
183
|
+
test.type === 'UnaryExpression' &&
|
|
184
|
+
test.operator === '!' &&
|
|
185
|
+
test.argument?.type === 'CallExpression' &&
|
|
186
|
+
test.argument.callee?.type === 'Identifier' &&
|
|
187
|
+
typeofGuardFunctions.has(test.argument.callee.name)
|
|
188
|
+
)
|
|
189
|
+
return true
|
|
190
|
+
// `typeof X === 'undefined' || typeof Y === 'undefined'` — chained
|
|
191
|
+
// SSR bailouts (common when a feature needs multiple browser APIs).
|
|
192
|
+
// Both sides must be negated-typeof checks.
|
|
193
|
+
if (test.type === 'LogicalExpression' && test.operator === '||') {
|
|
194
|
+
return isNegatedTypeofExpr(test.left) && isNegatedTypeofExpr(test.right)
|
|
195
|
+
}
|
|
196
|
+
return false
|
|
197
|
+
}
|
|
198
|
+
function isEarlyReturnTypeofGuard(stmt: any): boolean {
|
|
199
|
+
if (!stmt || stmt.type !== 'IfStatement') return false
|
|
200
|
+
if (!isNegatedTypeofExpr(stmt.test)) return false
|
|
201
|
+
// Consequent must terminate the function — either a return or a throw
|
|
202
|
+
// (both bail out, leaving the rest of the body implicitly guarded).
|
|
203
|
+
// Bare statement OR single-statement block are both accepted.
|
|
204
|
+
const c = stmt.consequent
|
|
205
|
+
const isTerminator = (s: any): boolean =>
|
|
206
|
+
s?.type === 'ReturnStatement' || s?.type === 'ThrowStatement'
|
|
207
|
+
if (isTerminator(c)) return true
|
|
208
|
+
if (c?.type === 'BlockStatement' && c.body.length === 1 && isTerminator(c.body[0]))
|
|
209
|
+
return true
|
|
210
|
+
return false
|
|
211
|
+
}
|
|
212
|
+
// Per-function counter of how many typeofGuardDepth bumps were
|
|
213
|
+
// contributed by early-return guards inside this function — popped on
|
|
214
|
+
// function exit to keep the depth balanced.
|
|
215
|
+
const earlyReturnStack: number[] = []
|
|
216
|
+
// Callback nodes (2nd arg of `watch(source, cb)`) pre-marked so the
|
|
217
|
+
// function-scope visitor bumps safeDepth only inside them. The source
|
|
218
|
+
// arg (evaluated at setup) stays unmarked and gets normal analysis.
|
|
219
|
+
const watchCallbackNodes = new WeakSet<any>()
|
|
220
|
+
// Parallel stack recording whether the current function scope bumped
|
|
221
|
+
// safeDepth for being a watch callback. Paired with `popFunctionScope`
|
|
222
|
+
// so the depth is balanced even with nested watch calls.
|
|
223
|
+
const watchCallbackSafeDepthStack: number[] = []
|
|
224
|
+
// Stack of parameter names that shadow browser globals for the current
|
|
225
|
+
// function scope. E.g. `function push(location)` — any `location`
|
|
226
|
+
// identifier inside this function refers to the parameter, not the
|
|
227
|
+
// browser global. Pushed on function enter, popped on exit.
|
|
228
|
+
const shadowedNamesStack: Array<Set<string>> = []
|
|
229
|
+
// Module-level names that shadow browser globals via imports — e.g.
|
|
230
|
+
// `import { history } from '@codemirror/commands'`. Any `history`
|
|
231
|
+
// identifier in the file then refers to the import, not `window.history`.
|
|
232
|
+
// Populated by ImportSpecifier / ImportDefaultSpecifier / ImportNamespaceSpecifier.
|
|
233
|
+
const importShadowedNames = new Set<string>()
|
|
234
|
+
function collectParamNames(params: any[]): Set<string> {
|
|
235
|
+
const names = new Set<string>()
|
|
236
|
+
const walk = (p: any) => {
|
|
237
|
+
if (!p) return
|
|
238
|
+
if (p.type === 'Identifier' && BROWSER_GLOBALS.has(p.name)) names.add(p.name)
|
|
239
|
+
else if (p.type === 'AssignmentPattern') walk(p.left)
|
|
240
|
+
else if (p.type === 'RestElement') walk(p.argument)
|
|
241
|
+
else if (p.type === 'ArrayPattern') for (const el of p.elements ?? []) walk(el)
|
|
242
|
+
else if (p.type === 'ObjectPattern')
|
|
243
|
+
for (const prop of p.properties ?? []) {
|
|
244
|
+
if (prop.type === 'RestElement') walk(prop.argument)
|
|
245
|
+
else walk(prop.value)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
for (const p of params ?? []) walk(p)
|
|
249
|
+
return names
|
|
250
|
+
}
|
|
251
|
+
function isNameShadowed(name: string): boolean {
|
|
252
|
+
for (let i = shadowedNamesStack.length - 1; i >= 0; i--) {
|
|
253
|
+
if (shadowedNamesStack[i]!.has(name)) return true
|
|
254
|
+
}
|
|
255
|
+
return false
|
|
256
|
+
}
|
|
257
|
+
function pushFunctionScope(node?: any) {
|
|
258
|
+
earlyReturnStack.push(0)
|
|
259
|
+
shadowedNamesStack.push(node ? collectParamNames(node.params ?? []) : new Set())
|
|
260
|
+
if (node && watchCallbackNodes.has(node)) {
|
|
261
|
+
safeDepth++
|
|
262
|
+
watchCallbackSafeDepthStack.push(1)
|
|
263
|
+
} else {
|
|
264
|
+
watchCallbackSafeDepthStack.push(0)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
function popFunctionScope() {
|
|
268
|
+
const bumps = earlyReturnStack.pop() ?? 0
|
|
269
|
+
typeofGuardDepth -= bumps
|
|
270
|
+
shadowedNamesStack.pop()
|
|
271
|
+
const watchBump = watchCallbackSafeDepthStack.pop() ?? 0
|
|
272
|
+
if (watchBump > 0) safeDepth -= watchBump
|
|
273
|
+
}
|
|
274
|
+
function noteEarlyReturnGuardVisit() {
|
|
275
|
+
typeofGuardDepth++
|
|
276
|
+
if (earlyReturnStack.length > 0) {
|
|
277
|
+
earlyReturnStack[earlyReturnStack.length - 1]!++
|
|
278
|
+
}
|
|
279
|
+
}
|
|
16
280
|
|
|
17
281
|
const callbacks: VisitorCallbacks = {
|
|
282
|
+
VariableDeclaration(node: any) {
|
|
283
|
+
for (const decl of node.declarations ?? []) {
|
|
284
|
+
if (decl.id?.type !== 'Identifier') continue
|
|
285
|
+
// const isBrowser = typeof window !== 'undefined'
|
|
286
|
+
if (isTypeofCheckForBinding(decl.init)) {
|
|
287
|
+
typeofBoundConsts.add(decl.id.name)
|
|
288
|
+
}
|
|
289
|
+
// const isBrowser = () => typeof window !== 'undefined'
|
|
290
|
+
// const isBrowser = function () { return typeof window !== 'undefined' }
|
|
291
|
+
if (
|
|
292
|
+
(decl.init?.type === 'ArrowFunctionExpression' ||
|
|
293
|
+
decl.init?.type === 'FunctionExpression') &&
|
|
294
|
+
bodyIsTypeofGuard(decl.init.body)
|
|
295
|
+
) {
|
|
296
|
+
typeofGuardFunctions.add(decl.id.name)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
FunctionDeclaration(node: any) {
|
|
301
|
+
// function isBrowser() { return typeof window !== 'undefined' }
|
|
302
|
+
if (node.id?.type === 'Identifier' && bodyIsTypeofGuard(node.body)) {
|
|
303
|
+
typeofGuardFunctions.add(node.id.name)
|
|
304
|
+
}
|
|
305
|
+
pushFunctionScope(node)
|
|
306
|
+
},
|
|
307
|
+
'FunctionDeclaration:exit': popFunctionScope,
|
|
308
|
+
FunctionExpression: pushFunctionScope,
|
|
309
|
+
'FunctionExpression:exit': popFunctionScope,
|
|
310
|
+
ArrowFunctionExpression: pushFunctionScope,
|
|
311
|
+
'ArrowFunctionExpression:exit': popFunctionScope,
|
|
18
312
|
CallExpression(node: any) {
|
|
19
|
-
|
|
313
|
+
// `onMount` / `onUnmount` / `onCleanup` / `effect` / `renderEffect` /
|
|
314
|
+
// `requestAnimationFrame` — the whole call's arguments are safe:
|
|
315
|
+
// the callback runs post-mount / in a browser frame, and those
|
|
316
|
+
// hooks accept a single callback arg (no setup-time source).
|
|
317
|
+
if (
|
|
318
|
+
isCallTo(node, 'onMount') ||
|
|
319
|
+
isCallTo(node, 'onUnmount') ||
|
|
320
|
+
isCallTo(node, 'onCleanup') ||
|
|
321
|
+
isCallTo(node, 'effect') ||
|
|
322
|
+
isCallTo(node, 'renderEffect') ||
|
|
323
|
+
isCallTo(node, 'requestAnimationFrame')
|
|
324
|
+
) {
|
|
20
325
|
safeDepth++
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
// `watch(source, cb)` — the SOURCE arg is evaluated synchronously
|
|
329
|
+
// at setup time (to track signals) so browser-global access inside
|
|
330
|
+
// it is NOT safe. Only the CALLBACK arg fires deferred. Pre-mark
|
|
331
|
+
// the second arg so the ArrowFn/FunctionExpression visitor bumps
|
|
332
|
+
// safeDepth only there — not across the whole CallExpression.
|
|
333
|
+
if (isCallTo(node, 'watch')) {
|
|
334
|
+
const cb = node.arguments?.[1]
|
|
335
|
+
if (
|
|
336
|
+
cb?.type === 'ArrowFunctionExpression' ||
|
|
337
|
+
cb?.type === 'FunctionExpression' ||
|
|
338
|
+
cb?.type === 'FunctionDeclaration'
|
|
339
|
+
) {
|
|
340
|
+
watchCallbackNodes.add(cb)
|
|
341
|
+
}
|
|
21
342
|
}
|
|
22
343
|
},
|
|
23
344
|
'CallExpression:exit'(node: any) {
|
|
24
|
-
if (
|
|
345
|
+
if (
|
|
346
|
+
isCallTo(node, 'onMount') ||
|
|
347
|
+
isCallTo(node, 'onUnmount') ||
|
|
348
|
+
isCallTo(node, 'onCleanup') ||
|
|
349
|
+
isCallTo(node, 'effect') ||
|
|
350
|
+
isCallTo(node, 'renderEffect') ||
|
|
351
|
+
isCallTo(node, 'requestAnimationFrame')
|
|
352
|
+
) {
|
|
25
353
|
safeDepth--
|
|
26
354
|
}
|
|
27
355
|
},
|
|
28
356
|
IfStatement(node: any) {
|
|
29
|
-
// typeof
|
|
30
|
-
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
|
|
357
|
+
// `if (typeof X !== 'undefined') { … browser-only … }` —
|
|
358
|
+
// body-scoped typeof guard.
|
|
359
|
+
if (testIsTypeofGuard(node.test)) typeofGuardDepth++
|
|
360
|
+
// `if (typeof X === 'undefined') return` — early-return guard.
|
|
361
|
+
// Bumps the guard depth FROM HERE through the rest of the
|
|
362
|
+
// enclosing function (popped at function exit). Done at IfStatement
|
|
363
|
+
// visit (not function enter) so `typeofBoundConsts` is already
|
|
364
|
+
// populated by any `const isBrowser = …` above this if.
|
|
365
|
+
else if (isEarlyReturnTypeofGuard(node)) noteEarlyReturnGuardVisit()
|
|
38
366
|
},
|
|
39
367
|
'IfStatement:exit'(node: any) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
368
|
+
if (testIsTypeofGuard(node.test)) typeofGuardDepth--
|
|
369
|
+
},
|
|
370
|
+
// Ternary `typeof X !== 'undefined' ? safe : fallback` — the
|
|
371
|
+
// `consequent` branch is type-guarded. Tracked via depth: enter
|
|
372
|
+
// increments globally because the visitor doesn't tell us which
|
|
373
|
+
// branch we're in. That's an over-approximation (the fallback also
|
|
374
|
+
// gets the depth bump), but the fallback typically doesn't reference
|
|
375
|
+
// browser globals; the consequent does. Conservative; matches real
|
|
376
|
+
// code patterns.
|
|
377
|
+
ConditionalExpression(node: any) {
|
|
378
|
+
if (testIsTypeofGuard(node.test)) typeofGuardDepth++
|
|
379
|
+
},
|
|
380
|
+
'ConditionalExpression:exit'(node: any) {
|
|
381
|
+
if (testIsTypeofGuard(node.test)) typeofGuardDepth--
|
|
382
|
+
},
|
|
383
|
+
UnaryExpression(node: any) {
|
|
384
|
+
if (node.operator === 'typeof') inTypeofExpr++
|
|
385
|
+
},
|
|
386
|
+
'UnaryExpression:exit'(node: any) {
|
|
387
|
+
if (node.operator === 'typeof') inTypeofExpr--
|
|
388
|
+
},
|
|
389
|
+
// TypeScript type-position nodes — identifiers inside these are
|
|
390
|
+
// type references (erased at compile), not runtime accesses. Cover
|
|
391
|
+
// the common entry points; depth counter handles any nested
|
|
392
|
+
// `TSTypeAnnotation` etc.
|
|
393
|
+
TSTypeAnnotation(_n: any) { inTsTypePos++ },
|
|
394
|
+
'TSTypeAnnotation:exit'(_n: any) { inTsTypePos-- },
|
|
395
|
+
TSTypeReference(_n: any) { inTsTypePos++ },
|
|
396
|
+
'TSTypeReference:exit'(_n: any) { inTsTypePos-- },
|
|
397
|
+
TSTypeAliasDeclaration(_n: any) { inTsTypePos++ },
|
|
398
|
+
'TSTypeAliasDeclaration:exit'(_n: any) { inTsTypePos-- },
|
|
399
|
+
TSInterfaceDeclaration(_n: any) { inTsTypePos++ },
|
|
400
|
+
'TSInterfaceDeclaration:exit'(_n: any) { inTsTypePos-- },
|
|
401
|
+
TSTypeParameter(_n: any) { inTsTypePos++ },
|
|
402
|
+
'TSTypeParameter:exit'(_n: any) { inTsTypePos-- },
|
|
403
|
+
MemberExpression(node: any) {
|
|
404
|
+
// `x.addEventListener` — `addEventListener` is the property name, not
|
|
405
|
+
// a global. Pre-mark so the Identifier visitor skips it.
|
|
406
|
+
if (!node.computed && node.property?.type === 'Identifier') {
|
|
407
|
+
skipPropertyNodes.add(node.property)
|
|
408
|
+
}
|
|
409
|
+
},
|
|
410
|
+
Property(node: any) {
|
|
411
|
+
// `{ document: 1 }` — `document` is a key, not a global ref.
|
|
412
|
+
if (!node.computed && node.key?.type === 'Identifier') {
|
|
413
|
+
skipPropertyNodes.add(node.key)
|
|
47
414
|
}
|
|
48
415
|
},
|
|
49
|
-
|
|
50
|
-
if (
|
|
416
|
+
ImportSpecifier(node: any) {
|
|
417
|
+
if (node.imported?.type === 'Identifier') skipPropertyNodes.add(node.imported)
|
|
418
|
+
if (node.local?.type === 'Identifier' && node.local !== node.imported)
|
|
419
|
+
skipPropertyNodes.add(node.local)
|
|
420
|
+
// Track imported names that shadow a browser global so all later
|
|
421
|
+
// uses of that name in the file are skipped — e.g. `import { history }
|
|
422
|
+
// from '@codemirror/commands'` makes every `history` identifier a
|
|
423
|
+
// CodeMirror reference, not `window.history`.
|
|
424
|
+
if (node.local?.type === 'Identifier' && BROWSER_GLOBALS.has(node.local.name)) {
|
|
425
|
+
importShadowedNames.add(node.local.name)
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
ImportDefaultSpecifier(node: any) {
|
|
429
|
+
if (node.local?.type === 'Identifier') {
|
|
430
|
+
skipPropertyNodes.add(node.local)
|
|
431
|
+
if (BROWSER_GLOBALS.has(node.local.name)) importShadowedNames.add(node.local.name)
|
|
432
|
+
}
|
|
433
|
+
},
|
|
434
|
+
ImportNamespaceSpecifier(node: any) {
|
|
435
|
+
if (node.local?.type === 'Identifier') {
|
|
436
|
+
skipPropertyNodes.add(node.local)
|
|
437
|
+
if (BROWSER_GLOBALS.has(node.local.name)) importShadowedNames.add(node.local.name)
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
Identifier(node: any) {
|
|
441
|
+
if (safeDepth > 0 || typeofGuardDepth > 0 || inTypeofExpr > 0 || inTsTypePos > 0) return
|
|
442
|
+
if (skipPropertyNodes.has(node)) return
|
|
51
443
|
if (!BROWSER_GLOBALS.has(node.name)) return
|
|
52
|
-
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
// Skip import
|
|
57
|
-
if (
|
|
58
|
-
parent?.type === 'ImportSpecifier' ||
|
|
59
|
-
parent?.type === 'ImportDefaultSpecifier' ||
|
|
60
|
-
parent?.type === 'ImportNamespaceSpecifier'
|
|
61
|
-
)
|
|
62
|
-
return
|
|
63
|
-
|
|
64
|
-
// Skip property access on member expressions (only flag when used as the object)
|
|
65
|
-
if (parent?.type === 'MemberExpression' && parent.property === node && !parent.computed)
|
|
66
|
-
return
|
|
444
|
+
// Skip identifiers shadowed by a parameter of the same name —
|
|
445
|
+
// `function push(location)` inside: every `location` refers to the
|
|
446
|
+
// parameter, not `window.location`.
|
|
447
|
+
if (isNameShadowed(node.name)) return
|
|
448
|
+
// Skip identifiers shadowed by a module-level import binding.
|
|
449
|
+
if (importShadowedNames.has(node.name)) return
|
|
67
450
|
|
|
68
451
|
context.report({
|
|
69
452
|
message: `Browser global \`${node.name}\` used outside \`onMount\`/\`effect\`/typeof guard — this will fail during SSR. Wrap in \`onMount(() => { ... })\`.`,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
2
|
import { getSpan, isCallTo } from '../../utils/ast'
|
|
3
|
+
import { isTestFile } from '../../utils/file-roles'
|
|
3
4
|
|
|
4
5
|
export const noDuplicateStoreId: Rule = {
|
|
5
6
|
meta: {
|
|
@@ -10,6 +11,16 @@ export const noDuplicateStoreId: Rule = {
|
|
|
10
11
|
fixable: false,
|
|
11
12
|
},
|
|
12
13
|
create(context) {
|
|
14
|
+
// Heuristic: skip test files. The rule catches a real bug (two
|
|
15
|
+
// `defineStore('foo', ...)` calls in production code clobber each
|
|
16
|
+
// other), but store tests deliberately duplicate IDs to assert
|
|
17
|
+
// collision-handling behavior. A truly precise check would need to
|
|
18
|
+
// detect "this duplicate is wrapped in `expect(...).toThrow`" or
|
|
19
|
+
// similar — impractical at lint level. For prod code that intentionally
|
|
20
|
+
// (re)defines a store ID, use `// pyreon-lint-disable-next-line` at
|
|
21
|
+
// the second declaration.
|
|
22
|
+
if (isTestFile(context.getFilePath())) return {}
|
|
23
|
+
|
|
13
24
|
const storeIds = new Map<string, { start: number; end: number }>()
|
|
14
25
|
|
|
15
26
|
const callbacks: VisitorCallbacks = {
|
|
@@ -1,17 +1,27 @@
|
|
|
1
1
|
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
2
|
import { getSpan } from '../../utils/ast'
|
|
3
|
+
import { createComponentContextTracker } from '../../utils/component-context'
|
|
3
4
|
|
|
4
5
|
export const noMutateStoreState: Rule = {
|
|
5
6
|
meta: {
|
|
6
7
|
id: 'pyreon/no-mutate-store-state',
|
|
7
8
|
category: 'store',
|
|
8
|
-
description:
|
|
9
|
+
description:
|
|
10
|
+
'Warn when calling .set() on store signals from a component or hook — use store actions instead.',
|
|
9
11
|
severity: 'warn',
|
|
10
12
|
fixable: false,
|
|
11
13
|
},
|
|
12
14
|
create(context) {
|
|
15
|
+
// The wrong pattern is mutating store state from a component / event
|
|
16
|
+
// handler / hook. Inside the store's own setup or in tests asserting
|
|
17
|
+
// reactivity, `.set()` is fine. Component-context detection naturally
|
|
18
|
+
// skips both cases without a path-based heuristic.
|
|
19
|
+
const ctx = createComponentContextTracker()
|
|
20
|
+
|
|
13
21
|
const callbacks: VisitorCallbacks = {
|
|
22
|
+
...ctx.callbacks,
|
|
14
23
|
CallExpression(node: any) {
|
|
24
|
+
if (!ctx.isInComponentOrHook()) return
|
|
15
25
|
const callee = node.callee
|
|
16
26
|
if (!callee || callee.type !== 'MemberExpression') return
|
|
17
27
|
if (callee.property?.type !== 'Identifier' || callee.property.name !== 'set') return
|
|
@@ -1,55 +1,44 @@
|
|
|
1
1
|
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
2
|
import { getSpan, isCallTo } from '../../utils/ast'
|
|
3
|
+
import { createComponentContextTracker } from '../../utils/component-context'
|
|
3
4
|
|
|
4
5
|
export const noDynamicStyled: Rule = {
|
|
5
6
|
meta: {
|
|
6
7
|
id: 'pyreon/no-dynamic-styled',
|
|
7
8
|
category: 'styling',
|
|
8
9
|
description:
|
|
9
|
-
'Warn when styled() is called inside a
|
|
10
|
+
'Warn when styled() is called inside a component or hook — it creates new CSS on every render.',
|
|
10
11
|
severity: 'warn',
|
|
11
12
|
fixable: false,
|
|
12
13
|
},
|
|
13
14
|
create(context) {
|
|
14
|
-
|
|
15
|
+
// Only flag when *inside* a component / hook setup body. Module-level
|
|
16
|
+
// `styled()` is the correct pattern. Inside a utility function, factory,
|
|
17
|
+
// or test callback `styled()` runs once per call but isn't tied to a
|
|
18
|
+
// render path — the per-render-allocation warning doesn't apply.
|
|
19
|
+
const ctx = createComponentContextTracker()
|
|
20
|
+
|
|
15
21
|
const callbacks: VisitorCallbacks = {
|
|
16
|
-
|
|
17
|
-
functionDepth++
|
|
18
|
-
},
|
|
19
|
-
'FunctionDeclaration:exit'() {
|
|
20
|
-
functionDepth--
|
|
21
|
-
},
|
|
22
|
-
FunctionExpression() {
|
|
23
|
-
functionDepth++
|
|
24
|
-
},
|
|
25
|
-
'FunctionExpression:exit'() {
|
|
26
|
-
functionDepth--
|
|
27
|
-
},
|
|
28
|
-
ArrowFunctionExpression() {
|
|
29
|
-
functionDepth++
|
|
30
|
-
},
|
|
31
|
-
'ArrowFunctionExpression:exit'() {
|
|
32
|
-
functionDepth--
|
|
33
|
-
},
|
|
22
|
+
...ctx.callbacks,
|
|
34
23
|
CallExpression(node: any) {
|
|
35
|
-
if (
|
|
24
|
+
if (!ctx.isInComponentOrHook()) return
|
|
36
25
|
if (isCallTo(node, 'styled')) {
|
|
37
26
|
context.report({
|
|
38
27
|
message:
|
|
39
|
-
'`styled()` inside a
|
|
28
|
+
'`styled()` inside a component or hook — this creates new CSS rules on every render. Move `styled()` to module scope.',
|
|
40
29
|
span: getSpan(node),
|
|
41
30
|
})
|
|
42
31
|
}
|
|
43
32
|
},
|
|
44
33
|
TaggedTemplateExpression(node: any) {
|
|
45
|
-
if (
|
|
34
|
+
if (!ctx.isInComponentOrHook()) return
|
|
46
35
|
const tag = node.tag
|
|
47
36
|
if (!tag) return
|
|
48
37
|
// styled('div')`...` — tag is a CallExpression of styled
|
|
49
38
|
if (tag.type === 'CallExpression' && isCallTo(tag, 'styled')) {
|
|
50
39
|
context.report({
|
|
51
40
|
message:
|
|
52
|
-
'`styled()` tagged template inside a
|
|
41
|
+
'`styled()` tagged template inside a component or hook — this creates new CSS rules on every render. Move to module scope.',
|
|
53
42
|
span: getSpan(node),
|
|
54
43
|
})
|
|
55
44
|
}
|