@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,7 @@
1
1
  import type { Rule, VisitorCallbacks } from '../../types'
2
2
  import { getSpan } from '../../utils/ast'
3
+ import { isPathExempt } from '../../utils/exempt-paths'
4
+ import { isTestFile } from '../../utils/file-roles'
3
5
 
4
6
  export const devGuardWarnings: Rule = {
5
7
  meta: {
@@ -8,31 +10,183 @@ export const devGuardWarnings: Rule = {
8
10
  description: 'Require console.warn/error calls to be wrapped in `if (__DEV__)` guards.',
9
11
  severity: 'error',
10
12
  fixable: false,
13
+ schema: { exemptPaths: 'string[]', devFlagNames: 'string[]' },
11
14
  },
12
15
  create(context) {
13
- const filePath = context.getFilePath()
14
- // Skip test and example files
15
- if (
16
- filePath.includes('/tests/') ||
17
- filePath.includes('/test/') ||
18
- filePath.includes('/examples/') ||
19
- filePath.includes('.test.') ||
20
- filePath.includes('.spec.')
21
- ) {
22
- return {}
16
+ // Skip test files — universal convention (`*.test.*` etc. exist in
17
+ // every project this linter runs on and don't ship to production).
18
+ if (isTestFile(context.getFilePath())) return {}
19
+
20
+ // Configurable `exemptPaths` — projects opt out directories where the
21
+ // rule's premise doesn't apply (server-only code where dev/prod is
22
+ // `process.env.NODE_ENV`, or example / demo directories that ship
23
+ // as documentation rather than production).
24
+ if (isPathExempt(context)) return {}
25
+
26
+ // Project-level additions to the built-in dev-flag name list. Merged
27
+ // with the defaults so a custom flag like `__DEBUG__` still picks up
28
+ // the built-in `__DEV__`/`IS_DEVELOPMENT`/etc. without restating them.
29
+ const userFlagNames = context.getOptions().devFlagNames
30
+ const extraFlagNames = Array.isArray(userFlagNames)
31
+ ? userFlagNames.filter((n): n is string => typeof n === 'string')
32
+ : []
33
+
34
+ // Identifiers bound via `const X = <devFlag expression>` — e.g.
35
+ // `const IS_DEVELOPMENT = import.meta.env.DEV === true`. These act as
36
+ // the same dev-mode gate as the raw flag at their call sites.
37
+ const devFlagBoundConsts = new Set<string>()
38
+ function exprResolvesToDevFlag(expr: any): boolean {
39
+ if (!expr) return false
40
+ if (expr.type === 'ChainExpression') return exprResolvesToDevFlag(expr.expression)
41
+ if (isDevFlag(expr)) return true
42
+ // `import.meta.env.DEV === true` / `true === import.meta.env.DEV`
43
+ if (
44
+ expr.type === 'BinaryExpression' &&
45
+ (expr.operator === '===' || expr.operator === '==')
46
+ ) {
47
+ return exprResolvesToDevFlag(expr.left) || exprResolvesToDevFlag(expr.right)
48
+ }
49
+ return false
50
+ }
51
+
52
+ // Conventional identifier names treated as dev-mode gates. Covers both
53
+ // local `const __DEV__ = …` style and imported flags like `IS_DEV` /
54
+ // `IS_DEVELOPMENT` from a package's shared utils module. The rule can't
55
+ // follow cross-module imports to verify that the binding really resolves
56
+ // to `import.meta.env.DEV`, so we fall back to the name convention —
57
+ // consistent with how the existing `__DEV__` identifier works. Projects
58
+ // can extend the list via the `devFlagNames` rule option.
59
+ const DEV_FLAG_NAMES = new Set<string>([
60
+ '__DEV__',
61
+ 'IS_DEV',
62
+ 'IS_DEVELOPMENT',
63
+ 'isDev',
64
+ ...extraFlagNames,
65
+ ])
66
+
67
+ // Direct dev-mode flags this rule treats as guards.
68
+ function isDevFlag(node: any): boolean {
69
+ if (!node) return false
70
+ if (node.type === 'ChainExpression') return isDevFlag(node.expression)
71
+ // Conventional dev-flag identifier names.
72
+ if (node.type === 'Identifier' && DEV_FLAG_NAMES.has(node.name)) return true
73
+ // Const-bound dev flag, e.g. `const devMode = import.meta.env.DEV`.
74
+ if (node.type === 'Identifier' && devFlagBoundConsts.has(node.name)) return true
75
+ // `import.meta.env.DEV` (and `import.meta.env?.DEV` after ChainExpression unwrap)
76
+ if (
77
+ node.type === 'MemberExpression' &&
78
+ node.property?.type === 'Identifier' &&
79
+ node.property.name === 'DEV'
80
+ ) {
81
+ const obj = node.object
82
+ if (
83
+ obj?.type === 'MemberExpression' &&
84
+ obj.property?.type === 'Identifier' &&
85
+ obj.property.name === 'env' &&
86
+ obj.object?.type === 'MetaProperty'
87
+ )
88
+ return true
89
+ }
90
+ return false
91
+ }
92
+
93
+ // Match `<flag>`, `<flag> && X`, `X && <flag>`, `<flag> === true`, etc.
94
+ // `&&` only — `||` doesn't guarantee dev-only execution.
95
+ function containsDevGuard(test: any): boolean {
96
+ if (!test) return false
97
+ if (isDevFlag(test)) return true
98
+ if (test.type === 'LogicalExpression' && test.operator === '&&') {
99
+ return containsDevGuard(test.left) || containsDevGuard(test.right)
100
+ }
101
+ // `flag === true` or `true === flag` — common after `?? === true` shape.
102
+ if (
103
+ test.type === 'BinaryExpression' &&
104
+ (test.operator === '===' || test.operator === '==')
105
+ ) {
106
+ return isDevFlag(test.left) || isDevFlag(test.right)
107
+ }
108
+ return false
109
+ }
110
+
111
+ // Detects an early-return DEV guard at the head of a function body:
112
+ // `if (!__DEV__) return` / `if (!import.meta.env.DEV) return`
113
+ // Everything after this in the function is implicitly dev-only.
114
+ function isEarlyReturnDevGuard(node: any): boolean {
115
+ if (!node || node.type !== 'IfStatement') return false
116
+ const t = node.test
117
+ const arg = t?.type === 'UnaryExpression' && t.operator === '!' ? t.argument : null
118
+ if (!arg) return false
119
+ if (!isDevFlag(arg)) return false
120
+ const c = node.consequent
121
+ if (c?.type === 'ReturnStatement') return true
122
+ if (c?.type === 'BlockStatement' && c.body.length === 1 && c.body[0]?.type === 'ReturnStatement') return true
123
+ return false
23
124
  }
24
125
 
25
126
  let devGuardDepth = 0
127
+ let catchDepth = 0
128
+ // For each function we enter, record whether its first statement is an
129
+ // early-return DEV guard. If yes, the function's body is dev-only and
130
+ // we treat it as one guard depth for the duration.
131
+ const funcGuardStack: number[] = []
132
+ function enterFunction(node: any) {
133
+ const body = node?.body
134
+ const stmts = body?.type === 'BlockStatement' ? body.body : null
135
+ let guarded = 0
136
+ if (stmts && stmts.length > 0 && isEarlyReturnDevGuard(stmts[0])) {
137
+ guarded = 1
138
+ devGuardDepth++
139
+ }
140
+ funcGuardStack.push(guarded)
141
+ }
142
+ function exitFunction() {
143
+ const g = funcGuardStack.pop() ?? 0
144
+ if (g > 0) devGuardDepth -= g
145
+ }
146
+
26
147
  const callbacks: VisitorCallbacks = {
27
- IfStatement(node: any) {
28
- if (node.test?.type === 'Identifier' && node.test.name === '__DEV__') {
29
- devGuardDepth++
148
+ VariableDeclaration(node: any) {
149
+ for (const decl of node.declarations ?? []) {
150
+ if (decl.id?.type === 'Identifier' && exprResolvesToDevFlag(decl.init)) {
151
+ devFlagBoundConsts.add(decl.id.name)
152
+ }
30
153
  }
31
154
  },
155
+ FunctionDeclaration: enterFunction,
156
+ 'FunctionDeclaration:exit': exitFunction,
157
+ FunctionExpression: enterFunction,
158
+ 'FunctionExpression:exit': exitFunction,
159
+ ArrowFunctionExpression: enterFunction,
160
+ 'ArrowFunctionExpression:exit': exitFunction,
161
+
162
+ IfStatement(node: any) {
163
+ if (containsDevGuard(node.test)) devGuardDepth++
164
+ },
32
165
  'IfStatement:exit'(node: any) {
33
- if (node.test?.type === 'Identifier' && node.test.name === '__DEV__') {
34
- devGuardDepth--
35
- }
166
+ if (containsDevGuard(node.test)) devGuardDepth--
167
+ },
168
+ // Conditional expression as a statement — `__DEV__ && console.warn(...)`
169
+ // and `__DEV__ ? console.warn(...) : null` are equivalent dev-only hints.
170
+ LogicalExpression(node: any) {
171
+ if (node.operator === '&&' && containsDevGuard(node.left)) devGuardDepth++
172
+ },
173
+ 'LogicalExpression:exit'(node: any) {
174
+ if (node.operator === '&&' && containsDevGuard(node.left)) devGuardDepth--
175
+ },
176
+ ConditionalExpression(node: any) {
177
+ if (containsDevGuard(node.test)) devGuardDepth++
178
+ },
179
+ 'ConditionalExpression:exit'(node: any) {
180
+ if (containsDevGuard(node.test)) devGuardDepth--
181
+ },
182
+ // `console.error` in a catch block is legitimate production error
183
+ // reporting (the error already happened — surfacing it isn't a dev hint).
184
+ // `console.warn` in catch is still flagged: warnings should be DEV-only.
185
+ CatchClause() {
186
+ catchDepth++
187
+ },
188
+ 'CatchClause:exit'() {
189
+ catchDepth--
36
190
  },
37
191
  CallExpression(node: any) {
38
192
  if (devGuardDepth > 0) return
@@ -45,8 +199,9 @@ export const devGuardWarnings: Rule = {
45
199
  callee.property?.type === 'Identifier' &&
46
200
  (callee.property.name === 'warn' || callee.property.name === 'error')
47
201
  ) {
202
+ if (callee.property.name === 'error' && catchDepth > 0) return
48
203
  context.report({
49
- message: `\`console.${callee.property.name}()\` without \`__DEV__\` guard — dev warnings must be tree-shakeable in production. Wrap in \`if (__DEV__) { ... }\`.`,
204
+ message: `\`console.${callee.property.name}()\` without \`__DEV__\` guard — dev warnings must be tree-shakeable in production. Wrap in \`if (__DEV__) { ... }\` (or \`__DEV__ && ...\`). Production error logging in \`catch\` blocks is exempt for \`console.error\`.`,
50
205
  span: getSpan(node),
51
206
  })
52
207
  }
@@ -1,6 +1,7 @@
1
1
  import type { Rule, VisitorCallbacks } from '../../types'
2
2
  import { getSpan } from '../../utils/ast'
3
3
  import { isPyreonImport } from '../../utils/imports'
4
+ import { isTestFile } from '../../utils/file-roles'
4
5
 
5
6
  const LAYER_ORDER: Record<string, number> = {
6
7
  '@pyreon/reactivity': 0,
@@ -35,6 +36,12 @@ export const noCircularImport: Rule = {
35
36
  },
36
37
  create(context) {
37
38
  const filePath = context.getFilePath()
39
+ // Tests don't ship as part of the layered production dep graph — they're
40
+ // verification scaffolding. Cross-layer imports are routine and correct
41
+ // there (e.g. a `runtime-dom` test importing `renderToString` from
42
+ // `runtime-server` to compare SSR vs CSR output). Path-based skip is the
43
+ // semantic truth for this rule, not a heuristic.
44
+ if (isTestFile(filePath)) return {}
38
45
  const fileLayer = getFileLayer(filePath)
39
46
  if (fileLayer === null) return {}
40
47
 
@@ -1,5 +1,7 @@
1
1
  import type { Rule, VisitorCallbacks } from '../../types'
2
2
  import { getSpan } from '../../utils/ast'
3
+ import { isPathExempt } from '../../utils/exempt-paths'
4
+ import { isTestFile } from '../../utils/file-roles'
3
5
 
4
6
  /**
5
7
  * `pyreon/no-process-dev-gate` — flag the broken `typeof process` dev-mode gate
@@ -33,35 +35,19 @@ import { getSpan } from '../../utils/ast'
33
35
  * Does NOT delete the const declaration — that has to happen by hand because
34
36
  * the variable name and downstream usages may need updating in callers.
35
37
  *
36
- * **Server-package exception**: server-only files run in Node where `process`
37
- * is always defined, so the pattern is correct there. The rule skips files
38
- * matching `packages/zero/`, `packages/core/server/`, `packages/core/runtime-server/`,
39
- * `packages/tools/vite-plugin/`, and any file containing `/server/` in its
40
- * path. Add new server packages to `SERVER_PACKAGE_PATTERNS` below.
41
- */
42
- /**
43
- * File-path patterns for server-only packages. Substring match against the
44
- * file path passed to the rule. Patterns intentionally do NOT start with `/`
45
- * so they match both absolute paths (`/Users/.../packages/zero/...`) and
46
- * relative paths (`packages/zero/...`) — different lint runners pass paths
47
- * differently.
38
+ * **Server-only exemption**: projects configure `exemptPaths` per-file for
39
+ * server-only code (Node environments where `process` is always defined and
40
+ * the pattern is correct). Configure in `.pyreonlintrc.json`:
41
+ *
42
+ * {
43
+ * "rules": {
44
+ * "pyreon/no-process-dev-gate": [
45
+ * "error",
46
+ * { "exemptPaths": ["packages/zero/", "packages/core/server/"] }
47
+ * ]
48
+ * }
49
+ * }
48
50
  */
49
- const SERVER_PACKAGE_PATTERNS = [
50
- 'packages/zero/',
51
- 'packages/core/server/',
52
- 'packages/core/runtime-server/',
53
- 'packages/tools/vite-plugin/',
54
- 'packages/tools/cli/',
55
- 'packages/tools/lint/',
56
- 'packages/tools/mcp/',
57
- 'packages/tools/storybook/',
58
- 'packages/tools/typescript/',
59
- 'scripts/',
60
- ]
61
-
62
- function isServerOnlyFile(filePath: string): boolean {
63
- return SERVER_PACKAGE_PATTERNS.some((pat) => filePath.includes(pat))
64
- }
65
51
 
66
52
  export const noProcessDevGate: Rule = {
67
53
  meta: {
@@ -73,25 +59,12 @@ export const noProcessDevGate: Rule = {
73
59
  fixable: true,
74
60
  },
75
61
  create(context) {
76
- const filePath = context.getFilePath()
77
-
78
62
  // Skip test files — vitest has `process`, the gate works there, and
79
- // tests are not shipped to users.
80
- if (
81
- filePath.includes('/tests/') ||
82
- filePath.includes('/test/') ||
83
- filePath.includes('/__tests__/') ||
84
- filePath.includes('.test.') ||
85
- filePath.includes('.spec.')
86
- ) {
87
- return {}
88
- }
63
+ // tests are not shipped to users. Universal, not a heuristic.
64
+ if (isTestFile(context.getFilePath())) return {}
89
65
 
90
- // Skip server-only packages — they run in Node where `process` is
91
- // always defined, so the pattern is correct there.
92
- if (isServerOnlyFile(filePath)) {
93
- return {}
94
- }
66
+ // Configurable `exemptPaths` option for server-only directories.
67
+ if (isPathExempt(context)) return {}
95
68
 
96
69
  /**
97
70
  * Match the broken pattern at the AST level. We're looking for any
@@ -0,0 +1,227 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import type { Rule, VisitorCallbacks } from '../../types'
4
+ import { isPathExempt } from '../../utils/exempt-paths'
5
+
6
+ /**
7
+ * `pyreon/require-browser-smoke-test` — every browser-categorized package
8
+ * must ship at least one `*.browser.test.{ts,tsx}` file under `src/`.
9
+ *
10
+ * Locks in the durability of the T1.1 browser smoke harness (PRs #224,
11
+ * #227, #229, #231). Without this rule, any new browser-running package
12
+ * can quietly ship without a real-browser smoke test and we drift back
13
+ * to the world before T1.1 — where happy-dom silently masks
14
+ * environment-divergence bugs (PR #197 mock-vnode metadata drop, PR
15
+ * #200 `typeof process` dead code, the multi-word event delegation bug
16
+ * fixed alongside PR #231).
17
+ *
18
+ * **What it checks**: when linting a package's `src/index.ts`, the rule
19
+ * looks at the package directory for any file matching
20
+ * `**\/*.browser.test.{ts,tsx}`. If none are found AND the package's
21
+ * name appears in the browser-categorized list, the rule reports an
22
+ * error on `src/index.ts`.
23
+ *
24
+ * **Why src/index.ts only**: the rule needs to fire exactly once per
25
+ * package, not per file. `src/index.ts` is a stable per-package entry
26
+ * point. Files inside the package are not browser-test files
27
+ * themselves, so they get skipped via the path check.
28
+ *
29
+ * **Default browser packages list**: matches the categorization in
30
+ * `.claude/rules/test-environment-parity.md`. Override via the
31
+ * `additionalPackages` option to opt in new packages, or via
32
+ * `exemptPaths` to opt out (e.g. for a brand-new package still under
33
+ * construction).
34
+ *
35
+ * @example Configuration in `.pyreonlintrc.json`
36
+ * ```json
37
+ * {
38
+ * "rules": {
39
+ * "pyreon/require-browser-smoke-test": [
40
+ * "error",
41
+ * {
42
+ * "additionalPackages": ["@my-org/my-browser-pkg"],
43
+ * "exemptPaths": ["packages/experimental/"]
44
+ * }
45
+ * ]
46
+ * }
47
+ * }
48
+ * ```
49
+ *
50
+ * **Known limitation — file existence, not test quality.** The rule only
51
+ * checks that at least one `*.browser.test.*` file exists under `src/`;
52
+ * it cannot assess whether the test is meaningful. A package could ship
53
+ * `sanity.browser.test.ts` with `expect(1).toBe(1)` and satisfy the
54
+ * rule. That's accepted by design — the rule is a *gate* against
55
+ * packages shipping with zero smoke coverage, not a quality check.
56
+ * Review the actual test contents on PR. If drive-by one-liner tests
57
+ * become a pattern, add a per-package coverage threshold or a
58
+ * complementary rule that inspects test file contents.
59
+ */
60
+
61
+ /**
62
+ * Single source of truth for browser-categorized packages lives at
63
+ * `.claude/rules/browser-packages.json`. Loading it lazily here means:
64
+ *
65
+ * 1. Updating the list never requires re-publishing `@pyreon/lint`.
66
+ * 2. The script `scripts/check-browser-smoke.ts` + the human-readable
67
+ * `.claude/rules/test-environment-parity.md` share the same source,
68
+ * so they can't drift out of sync silently.
69
+ *
70
+ * The JSON is searched for by walking up from the linted file's directory
71
+ * to the first ancestor containing `.claude/rules/browser-packages.json`.
72
+ * If not found (rule running in a consumer repo that doesn't ship the
73
+ * JSON), the rule falls back to an empty list — `additionalPackages`
74
+ * becomes the only signal and the rule stays opt-in, not a footgun.
75
+ *
76
+ * Cached globally because the list is tiny and lint runs lint thousands
77
+ * of files per invocation.
78
+ */
79
+ let _cachedBrowserPackages: Set<string> | null = null
80
+
81
+ function loadBrowserPackages(fromFile: string): Set<string> {
82
+ if (_cachedBrowserPackages) return _cachedBrowserPackages
83
+ let dir = path.dirname(fromFile)
84
+ // Walk up to /; bounded in practice by the project root.
85
+ for (let i = 0; i < 30; i++) {
86
+ const candidate = path.join(dir, '.claude', 'rules', 'browser-packages.json')
87
+ if (existsSync(candidate)) {
88
+ try {
89
+ const fs = require('node:fs') as typeof import('node:fs')
90
+ const parsed = JSON.parse(fs.readFileSync(candidate, 'utf8')) as {
91
+ packages?: unknown
92
+ }
93
+ if (Array.isArray(parsed.packages)) {
94
+ _cachedBrowserPackages = new Set(
95
+ parsed.packages.filter((p): p is string => typeof p === 'string'),
96
+ )
97
+ return _cachedBrowserPackages
98
+ }
99
+ } catch {
100
+ // fall through to empty-list fallback
101
+ }
102
+ break
103
+ }
104
+ const parent = path.dirname(dir)
105
+ if (parent === dir) break
106
+ dir = parent
107
+ }
108
+ _cachedBrowserPackages = new Set()
109
+ return _cachedBrowserPackages
110
+ }
111
+
112
+ /**
113
+ * Test-only: reset the cached list so unit tests can exercise the
114
+ * filesystem-discovery path multiple times within one process.
115
+ */
116
+ export function _resetBrowserPackagesCache(): void {
117
+ _cachedBrowserPackages = null
118
+ }
119
+
120
+ /**
121
+ * Walk a directory looking for `*.browser.test.{ts,tsx}` files. Bails
122
+ * on the first match — we only need to know `at least one exists`,
123
+ * not enumerate them. Skips `node_modules`, `lib`, `dist`, and dot
124
+ * directories so a package's own dependencies don't pollute the check.
125
+ */
126
+ function hasBrowserTest(dir: string): boolean {
127
+ let entries: string[]
128
+ try {
129
+ entries = readdirSync(dir)
130
+ } catch {
131
+ return false
132
+ }
133
+ for (const name of entries) {
134
+ if (name.startsWith('.') || name === 'node_modules' || name === 'lib' || name === 'dist') {
135
+ continue
136
+ }
137
+ const full = path.join(dir, name)
138
+ let isDir = false
139
+ try {
140
+ isDir = statSync(full).isDirectory()
141
+ } catch {
142
+ continue
143
+ }
144
+ if (isDir) {
145
+ if (hasBrowserTest(full)) return true
146
+ continue
147
+ }
148
+ if (/\.browser\.test\.(?:ts|tsx)$/.test(name)) return true
149
+ }
150
+ return false
151
+ }
152
+
153
+ /**
154
+ * Read the package.json `name` field for the directory containing the
155
+ * given src/index.ts file. Returns null if not found.
156
+ */
157
+ function readPackageName(srcIndexPath: string): string | null {
158
+ // src/index.ts -> ../package.json
159
+ const pkgPath = path.resolve(path.dirname(srcIndexPath), '..', 'package.json')
160
+ if (!existsSync(pkgPath)) return null
161
+ try {
162
+ // Read synchronously; cheap for one file per package per lint run.
163
+ const text = require('node:fs').readFileSync(pkgPath, 'utf8') as string
164
+ const parsed = JSON.parse(text) as { name?: unknown }
165
+ return typeof parsed.name === 'string' ? parsed.name : null
166
+ } catch {
167
+ return null
168
+ }
169
+ }
170
+
171
+ export const requireBrowserSmokeTest: Rule = {
172
+ meta: {
173
+ id: 'pyreon/require-browser-smoke-test',
174
+ category: 'architecture',
175
+ description:
176
+ 'Every browser-categorized package must ship at least one `*.browser.test.{ts,tsx}` file under `src/`. Locks in the T1.1 browser smoke harness.',
177
+ severity: 'error',
178
+ fixable: false,
179
+ schema: {
180
+ additionalPackages: 'string[]',
181
+ exemptPaths: 'string[]',
182
+ },
183
+ },
184
+ create(context): VisitorCallbacks {
185
+ const filePath = context.getFilePath()
186
+
187
+ // Run exactly once per package: only on `<package>/src/index.ts`
188
+ // (or .tsx). Test files in the package are excluded automatically
189
+ // because they don't match this pattern.
190
+ if (
191
+ !filePath.endsWith('/src/index.ts') &&
192
+ !filePath.endsWith('/src/index.tsx')
193
+ ) {
194
+ return {}
195
+ }
196
+
197
+ if (isPathExempt(context)) return {}
198
+
199
+ const pkgName = readPackageName(filePath)
200
+ if (pkgName == null) return {}
201
+
202
+ const options = context.getOptions()
203
+ const additional = Array.isArray(options.additionalPackages)
204
+ ? (options.additionalPackages.filter((s) => typeof s === 'string') as string[])
205
+ : []
206
+ const browserPackages = new Set(loadBrowserPackages(filePath))
207
+ for (const p of additional) browserPackages.add(p)
208
+
209
+ if (!browserPackages.has(pkgName)) return {}
210
+
211
+ const pkgDir = path.dirname(path.dirname(filePath)) // strip /src/index.ts
212
+ if (hasBrowserTest(pkgDir)) return {}
213
+
214
+ return {
215
+ 'Program:exit'(node: { start?: number; end?: number }) {
216
+ context.report({
217
+ message:
218
+ `[Pyreon] Browser-categorized package "${pkgName}" has no \`*.browser.test.{ts,tsx}\` file. ` +
219
+ `Add at least one real-browser smoke test under \`src/\` to catch environment-divergence bugs ` +
220
+ `that happy-dom hides (typeof process dead code, real pointer events, computed styles, etc.). ` +
221
+ `See .claude/rules/test-environment-parity.md for the recipe.`,
222
+ span: { start: node.start ?? 0, end: node.end ?? 0 },
223
+ })
224
+ },
225
+ }
226
+ },
227
+ }
@@ -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 noSubmitWithoutValidation: Rule = {
5
6
  meta: {
@@ -10,6 +11,14 @@ export const noSubmitWithoutValidation: Rule = {
10
11
  fixable: false,
11
12
  },
12
13
  create(context) {
14
+ // Heuristic: skip test files. The rule fires on any `useForm({ onSubmit })`
15
+ // missing validators, but tests deliberately exercise the un-validated
16
+ // path. A truly precise check would need to detect "this `useForm` is
17
+ // a test stub vs a real production form" — impractical at lint level.
18
+ // Keep the heuristic; consumers who want to test forms with validation
19
+ // explicitly opted-out should use `// pyreon-lint-disable-next-line`.
20
+ if (isTestFile(context.getFilePath())) return {}
21
+
13
22
  const callbacks: VisitorCallbacks = {
14
23
  CallExpression(node: any) {
15
24
  if (!isCallTo(node, 'useForm')) return
@@ -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 noUnregisteredField: Rule = {
5
6
  meta: {
@@ -10,6 +11,14 @@ export const noUnregisteredField: Rule = {
10
11
  fixable: false,
11
12
  },
12
13
  create(context) {
14
+ // Heuristic: skip test files. The rule fires when `useField()` is
15
+ // called but no matching `register()` is found — usually a real bug
16
+ // (the field is dead). But form tests routinely call `useField` to
17
+ // assert field state without rendering a real DOM input. A precise
18
+ // check would need to detect "the return is not destructured into
19
+ // props passed to a JSX element" — impractical at lint level.
20
+ if (isTestFile(context.getFilePath())) return {}
21
+
13
22
  const fieldDecls = new Map<string, { span: { start: number; end: number } }>()
14
23
  const registeredNames = new Set<string>()
15
24
 
@@ -1,5 +1,6 @@
1
1
  import type { Rule, VisitorCallbacks } from '../../types'
2
2
  import { getSpan } from '../../utils/ast'
3
+ import { isPathExempt } from '../../utils/exempt-paths'
3
4
 
4
5
  export const noRawAddEventListener: Rule = {
5
6
  meta: {
@@ -8,8 +9,14 @@ export const noRawAddEventListener: Rule = {
8
9
  description: 'Suggest useEventListener() instead of raw .addEventListener() calls.',
9
10
  severity: 'info',
10
11
  fixable: false,
12
+ schema: { exemptPaths: 'string[]' },
11
13
  },
12
14
  create(context) {
15
+ // Configurable `exemptPaths` — for packages that IMPLEMENT the cleanup
16
+ // wrapper this rule recommends (they can't use themselves). Configure
17
+ // per-project; user apps typically leave empty.
18
+ if (isPathExempt(context)) return {}
19
+
13
20
  const callbacks: VisitorCallbacks = {
14
21
  CallExpression(node: any) {
15
22
  const callee = node.callee
@@ -1,5 +1,6 @@
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
  const STORAGE_OBJECTS = new Set(['localStorage', 'sessionStorage'])
5
6
  const STORAGE_METHODS = new Set(['getItem', 'setItem', 'removeItem'])
@@ -8,13 +9,23 @@ export const noRawLocalStorage: Rule = {
8
9
  meta: {
9
10
  id: 'pyreon/no-raw-localstorage',
10
11
  category: 'hooks',
11
- description: 'Suggest useStorage() instead of raw localStorage/sessionStorage access.',
12
+ description:
13
+ 'Suggest useStorage() instead of raw localStorage/sessionStorage inside a component or hook.',
12
14
  severity: 'info',
13
15
  fixable: false,
14
16
  },
15
17
  create(context) {
18
+ // The rule's premise — "use the reactive, cross-tab synced wrapper" —
19
+ // only applies inside a component / hook. Module-level config readers,
20
+ // utility helpers, and storage-library internals legitimately use the
21
+ // raw API. Foundation-package opt-out (e.g. `@pyreon/storage` itself)
22
+ // belongs in the consuming project's lint config, not in rule source.
23
+ const ctx = createComponentContextTracker()
24
+
16
25
  const callbacks: VisitorCallbacks = {
26
+ ...ctx.callbacks,
17
27
  CallExpression(node: any) {
28
+ if (!ctx.isInComponentOrHook()) return
18
29
  const callee = node.callee
19
30
  if (!callee || callee.type !== 'MemberExpression') return
20
31
  if (