@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.
Files changed (45) hide show
  1. package/README.md +55 -2
  2. package/lib/analysis/cli.js.html +1 -1
  3. package/lib/analysis/index.js.html +1 -1
  4. package/lib/cli.js +960 -162
  5. package/lib/cli.js.map +1 -1
  6. package/lib/index.js +935 -161
  7. package/lib/index.js.map +1 -1
  8. package/lib/types/index.d.ts +96 -23
  9. package/lib/types/index.d.ts.map +1 -1
  10. package/package.json +2 -1
  11. package/schema/pyreonlintrc.schema.json +64 -0
  12. package/src/cli.ts +44 -2
  13. package/src/config/presets.ts +13 -1
  14. package/src/index.ts +7 -0
  15. package/src/lint.ts +37 -6
  16. package/src/lsp/index.ts +15 -2
  17. package/src/rules/architecture/dev-guard-warnings.ts +172 -17
  18. package/src/rules/architecture/no-circular-import.ts +7 -0
  19. package/src/rules/architecture/no-process-dev-gate.ts +18 -45
  20. package/src/rules/architecture/require-browser-smoke-test.ts +227 -0
  21. package/src/rules/form/no-submit-without-validation.ts +9 -0
  22. package/src/rules/form/no-unregistered-field.ts +9 -0
  23. package/src/rules/hooks/no-raw-addeventlistener.ts +7 -0
  24. package/src/rules/hooks/no-raw-localstorage.ts +12 -1
  25. package/src/rules/hooks/no-raw-setinterval.ts +14 -0
  26. package/src/rules/index.ts +4 -1
  27. package/src/rules/jsx/no-props-destructure.ts +20 -6
  28. package/src/rules/lifecycle/no-dom-in-setup.ts +67 -7
  29. package/src/rules/reactivity/no-bare-signal-in-jsx.ts +12 -1
  30. package/src/rules/reactivity/no-unbatched-updates.ts +3 -0
  31. package/src/rules/router/no-imperative-navigate-in-render.ts +131 -35
  32. package/src/rules/ssr/no-window-in-ssr.ts +418 -35
  33. package/src/rules/store/no-duplicate-store-id.ts +11 -0
  34. package/src/rules/store/no-mutate-store-state.ts +11 -1
  35. package/src/rules/styling/no-dynamic-styled.ts +13 -24
  36. package/src/rules/styling/no-theme-outside-provider.ts +34 -2
  37. package/src/runner.ts +100 -10
  38. package/src/tests/runner.test.ts +1573 -21
  39. package/src/types.ts +74 -3
  40. package/src/utils/component-context.ts +106 -0
  41. package/src/utils/exempt-paths.ts +39 -0
  42. package/src/utils/file-roles.ts +32 -0
  43. package/src/utils/imports.ts +4 -1
  44. package/src/utils/validate-options.ts +68 -0
  45. 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
- if (isCallTo(node, 'onMount') || isCallTo(node, 'effect')) {
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 (isCallTo(node, 'onMount') || isCallTo(node, 'effect')) {
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 window !== "undefined"
30
- const test = node.test
31
- if (
32
- test?.type === 'BinaryExpression' &&
33
- test.left?.type === 'UnaryExpression' &&
34
- test.left.operator === 'typeof'
35
- ) {
36
- typeofGuardDepth++
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
- const test = node.test
41
- if (
42
- test?.type === 'BinaryExpression' &&
43
- test.left?.type === 'UnaryExpression' &&
44
- test.left.operator === 'typeof'
45
- ) {
46
- typeofGuardDepth--
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
- Identifier(node: any, parent: any) {
50
- if (safeDepth > 0 || typeofGuardDepth > 0) return
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
- // Skip typeof expressions: typeof window
54
- if (parent?.type === 'UnaryExpression' && parent.operator === 'typeof') return
55
-
56
- // Skip import specifiers
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: 'Warn when directly calling .set() on store signals — use store actions instead.',
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 function — it creates new CSS on every render.',
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
- let functionDepth = 0
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
- FunctionDeclaration() {
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 (functionDepth === 0) return
24
+ if (!ctx.isInComponentOrHook()) return
36
25
  if (isCallTo(node, 'styled')) {
37
26
  context.report({
38
27
  message:
39
- '`styled()` inside a function — this creates new CSS rules on every render. Move `styled()` to module scope.',
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 (functionDepth === 0) return
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 function — this creates new CSS rules on every render. Move to module scope.',
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
  }