@pyreon/lint 0.14.0 → 0.15.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/lint",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Pyreon-specific linter — 56 rules for signals, JSX, SSR, performance, router, and architecture",
5
5
  "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/lint#readme",
6
6
  "bugs": {
@@ -17,6 +17,7 @@
17
17
  },
18
18
  "files": [
19
19
  "lib",
20
+ "!lib/**/*.map",
20
21
  "src",
21
22
  "schema",
22
23
  "README.md",
@@ -52,7 +53,7 @@
52
53
  },
53
54
  "dependencies": {
54
55
  "@oxc-project/types": "^0.123.0",
55
- "oxc-parser": "^0.123.0"
56
+ "oxc-parser": "^0.129.0"
56
57
  },
57
58
  "devDependencies": {
58
59
  "@pyreon/manifest": "0.13.1"
package/src/manifest.ts CHANGED
@@ -4,12 +4,12 @@ export default defineManifest({
4
4
  name: '@pyreon/lint',
5
5
  title: 'Pyreon-specific Linter',
6
6
  tagline:
7
- 'Pyreon-specific linter — 59 rules across 12 categories, config files, watch mode, AST cache, CLI + LSP',
7
+ 'Pyreon-specific linter — 62 rules across 12 categories, config files, watch mode, AST cache, CLI + LSP',
8
8
  description:
9
- 'Pyreon-specific lint rules powered by `oxc-parser`. Covers reactivity (10), JSX (11), lifecycle (4), performance (4), SSR (3), architecture (7), store (3), form (3), styling (4), hooks (3), accessibility (3), router (4) — 59 rules total. Programmatic API (`lint`, `lintFile`), CLI (`pyreon-lint`), watch mode (fs.watch + 100ms debounce + AstCache), LSP server, and `.pyreonlintrc.json` config with per-rule options via ESLint-style tuple form. Notable rules: `pyreon/no-process-dev-gate` (auto-fixable; replaces dead-in-browser `typeof process` gates with `import.meta.env?.DEV`), `pyreon/require-browser-smoke-test` (locks in T1.1 browser-test durability).',
9
+ 'Pyreon-specific lint rules powered by `oxc-parser`. Covers reactivity (12), JSX (11), lifecycle (5), performance (4), SSR (3), architecture (7), store (3), form (3), styling (4), hooks (3), accessibility (3), router (4) — 62 rules total. Programmatic API (`lint`, `lintFile`), CLI (`pyreon-lint`), watch mode (fs.watch + 100ms debounce + AstCache), LSP server, and `.pyreonlintrc.json` config with per-rule options via ESLint-style tuple form. Notable rules: `pyreon/no-process-dev-gate` (auto-fixable; replaces dead-in-browser `typeof process` gates with `import.meta.env?.DEV`), `pyreon/require-browser-smoke-test` (locks in T1.1 browser-test durability).',
10
10
  category: 'server',
11
11
  features: [
12
- '59 rules across 12 categories',
12
+ '62 rules across 12 categories',
13
13
  'lint(options) programmatic API + lintFile() low-level entry',
14
14
  'CLI: pyreon-lint with --preset / --fix / --watch / --format / --rule-options',
15
15
  '4 presets: recommended, strict, app, lib',
@@ -43,7 +43,7 @@ const fileResult = lintFile('app.tsx', source, allRules, config, cache)
43
43
  // pyreon-lint --preset strict --quiet # CI mode
44
44
  // pyreon-lint --fix # auto-fix
45
45
  // pyreon-lint --watch src/ # watch mode
46
- // pyreon-lint --list # list all 59 rules
46
+ // pyreon-lint --list # list all 62 rules
47
47
  // pyreon-lint --rule-options 'pyreon/no-window-in-ssr={"exemptPaths":["src/foundation/"]}' src/`,
48
48
  api: [
49
49
  {
@@ -51,7 +51,7 @@ const fileResult = lintFile('app.tsx', source, allRules, config, cache)
51
51
  kind: 'function',
52
52
  signature: 'lint(options?: LintOptions): LintResult',
53
53
  summary:
54
- '59 rules across 12 categories. Auto-loads `.pyreonlintrc.json`. Presets: `recommended`, `strict`, `app`, `lib`. Per-rule options via tuple form in config (`["error", { exemptPaths: [...] }]`) or `ruleOptionsOverrides`. Wrong-typed options surface on `result.configDiagnostics`. Uses `oxc-parser` with AST caching.',
54
+ '62 rules across 12 categories. Auto-loads `.pyreonlintrc.json`. Presets: `recommended`, `strict`, `app`, `lib`. Per-rule options via tuple form in config (`["error", { exemptPaths: [...] }]`) or `ruleOptionsOverrides`. Wrong-typed options surface on `result.configDiagnostics`. Uses `oxc-parser` with AST caching.',
55
55
  example: `import { lint } from "@pyreon/lint"
56
56
 
57
57
  const result = lint({ paths: ["src/"], preset: "recommended" })
@@ -94,7 +94,7 @@ const result = lintFile("app.tsx", source, allRules, config, cache, configSink)`
94
94
  example: `pyreon-lint --preset strict --quiet # CI mode
95
95
  pyreon-lint --fix # auto-fix
96
96
  pyreon-lint --watch src/ # watch mode
97
- pyreon-lint --list # list all 59 rules
97
+ pyreon-lint --list # list all 62 rules
98
98
  pyreon-lint --format json # machine-readable
99
99
  pyreon-lint --rule-options 'pyreon/no-window-in-ssr={"exemptPaths":["src/foundation/"]}' src/`,
100
100
  seeAlso: ['lint'],
@@ -90,11 +90,55 @@ export const devGuardWarnings: Rule = {
90
90
  return false
91
91
  }
92
92
 
93
+ // `process.env.NODE_ENV` access — also matches optional-chaining shapes
94
+ // (`process?.env?.NODE_ENV`). Returns true when the node is the
95
+ // `process.env.NODE_ENV` member access itself.
96
+ function isProcessEnvNodeEnv(node: any): boolean {
97
+ let n = node
98
+ if (n?.type === 'ChainExpression') n = n.expression
99
+ if (n?.type !== 'MemberExpression') return false
100
+ if (n.property?.type !== 'Identifier' || n.property.name !== 'NODE_ENV') return false
101
+ let envObj = n.object
102
+ if (envObj?.type === 'ChainExpression') envObj = envObj.expression
103
+ if (envObj?.type !== 'MemberExpression') return false
104
+ if (envObj.property?.type !== 'Identifier' || envObj.property.name !== 'env') return false
105
+ const procObj = envObj.object
106
+ return procObj?.type === 'Identifier' && procObj.name === 'process'
107
+ }
108
+
109
+ // Match a development-mode check: `process.env.NODE_ENV !== 'production'`
110
+ // (and `!=` variant). The bundler-agnostic library convention for dev
111
+ // gates — every modern bundler auto-replaces `process.env.NODE_ENV`.
112
+ function isDevelopmentCheck(node: any): boolean {
113
+ if (node?.type !== 'BinaryExpression') return false
114
+ if (node.operator !== '!==' && node.operator !== '!=') return false
115
+ const matches = (a: any, b: any) =>
116
+ isProcessEnvNodeEnv(a) &&
117
+ (b?.type === 'Literal' || b?.type === 'StringLiteral') &&
118
+ b.value === 'production'
119
+ return matches(node.left, node.right) || matches(node.right, node.left)
120
+ }
121
+
122
+ // Match a production-mode check: `process.env.NODE_ENV === 'production'`.
123
+ // Used as the early-return guard form: `if (process.env.NODE_ENV === 'production') return`.
124
+ function isProductionCheck(node: any): boolean {
125
+ if (node?.type !== 'BinaryExpression') return false
126
+ if (node.operator !== '===' && node.operator !== '==') return false
127
+ const matches = (a: any, b: any) =>
128
+ isProcessEnvNodeEnv(a) &&
129
+ (b?.type === 'Literal' || b?.type === 'StringLiteral') &&
130
+ b.value === 'production'
131
+ return matches(node.left, node.right) || matches(node.right, node.left)
132
+ }
133
+
93
134
  // Match `<flag>`, `<flag> && X`, `X && <flag>`, `<flag> === true`, etc.
94
135
  // `&&` only — `||` doesn't guarantee dev-only execution.
136
+ // Also accepts the bundler-agnostic `process.env.NODE_ENV !== 'production'`
137
+ // dev check directly.
95
138
  function containsDevGuard(test: any): boolean {
96
139
  if (!test) return false
97
140
  if (isDevFlag(test)) return true
141
+ if (isDevelopmentCheck(test)) return true
98
142
  if (test.type === 'LogicalExpression' && test.operator === '&&') {
99
143
  return containsDevGuard(test.left) || containsDevGuard(test.right)
100
144
  }
@@ -109,18 +153,24 @@ export const devGuardWarnings: Rule = {
109
153
  }
110
154
 
111
155
  // Detects an early-return DEV guard at the head of a function body:
112
- // `if (!__DEV__) return` / `if (!import.meta.env.DEV) return`
156
+ // `if (!__DEV__) return` (legacy)
157
+ // `if (!import.meta.env.DEV) return` (Vite-tied — also legacy)
158
+ // `if (process.env.NODE_ENV === 'production') return` (current bundler-agnostic standard)
113
159
  // Everything after this in the function is implicitly dev-only.
114
160
  function isEarlyReturnDevGuard(node: any): boolean {
115
161
  if (!node || node.type !== 'IfStatement') return false
116
162
  const t = node.test
163
+ const c = node.consequent
164
+ const isReturn =
165
+ c?.type === 'ReturnStatement' ||
166
+ (c?.type === 'BlockStatement' && c.body.length === 1 && c.body[0]?.type === 'ReturnStatement')
167
+ if (!isReturn) return false
168
+ // Bundler-agnostic: `if (process.env.NODE_ENV === 'production') return`
169
+ if (isProductionCheck(t)) return true
170
+ // Legacy: `if (!isDev) return`
117
171
  const arg = t?.type === 'UnaryExpression' && t.operator === '!' ? t.argument : null
118
172
  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
173
+ return isDevFlag(arg)
124
174
  }
125
175
 
126
176
  let devGuardDepth = 0
@@ -4,40 +4,48 @@ import { isPathExempt } from '../../utils/exempt-paths'
4
4
  import { isTestFile } from '../../utils/file-roles'
5
5
 
6
6
  /**
7
- * `pyreon/no-process-dev-gate` — flag the broken `typeof process` dev-mode gate
8
- * pattern that is dead code in real Vite browser bundles.
7
+ * `pyreon/no-process-dev-gate` — flag bundler-coupled dev-gate patterns
8
+ * that are dead code or unsupported in some bundlers Pyreon ships to.
9
9
  *
10
- * The pattern this rule catches:
10
+ * Pyreon publishes libraries to npm. Consumers compile those libraries
11
+ * with whatever bundler they use — Vite, Webpack (Next.js), Rolldown,
12
+ * esbuild, Rollup, Parcel, Bun. The framework should not ship dev gates
13
+ * that only fire in one bundler.
11
14
  *
12
- * ```ts
13
- * const __DEV__ = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'
14
- * ```
15
+ * **Two broken patterns this rule catches:**
16
+ *
17
+ * 1. `typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'`
18
+ * The `typeof process` guard isn't replaced by Vite, evaluates to
19
+ * `false` in the browser, and the whole expression is dead. Wrapped
20
+ * dev warnings never fire for users running Vite browser builds.
15
21
  *
16
- * This works in vitest (Node, `process` is defined) but is **silently dead
17
- * code in real Vite browser bundles** because Vite does not polyfill
18
- * `process` for the client. Every dev warning gated on this constant never
19
- * fires for real users in dev mode.
22
+ * 2. `import.meta.env.DEV` (and the `(import.meta as ViteMeta).env?.DEV`
23
+ * cast variant). Vite/Rolldown literal-replace this at build time, but
24
+ * Webpack/esbuild/Rollup/Parcel/Bun/Node-direct don't. In a Pyreon
25
+ * library shipped to a Next.js (Webpack) app, dev warnings never fire
26
+ * — even in development. PR #200 introduced this pattern as the
27
+ * "correct" replacement for the typeof-process compound; that
28
+ * direction was wrong for library code.
20
29
  *
21
- * The fix is to use `import.meta.env.DEV`, which Vite/Rolldown literal-replace
22
- * at build time:
30
+ * **The bundler-agnostic standard** (used by React, Vue, Preact, Solid,
31
+ * MobX, Redux):
23
32
  *
24
33
  * ```ts
25
- * // No const needed — read directly at the use site so the bundler can fold:
26
- * if (!import.meta.env?.DEV) return
34
+ * if (process.env.NODE_ENV !== 'production') console.warn('...')
27
35
  * ```
28
36
  *
29
- * Vitest sets `import.meta.env.DEV === true` automatically (because it is
30
- * Vite-based), so existing tests continue to pass.
37
+ * Every modern bundler auto-replaces `process.env.NODE_ENV` at consumer
38
+ * build time. No `typeof process` guard needed — bundlers replace the
39
+ * literal regardless of whether `process` is otherwise defined.
31
40
  *
32
41
  * Reference implementation: `packages/fundamentals/flow/src/layout.ts:warnIgnoredOptions`.
33
42
  *
34
- * **Auto-fix**: replaces the assignment with `import.meta.env?.DEV === true`.
35
- * Does NOT delete the const declaration — that has to happen by hand because
36
- * the variable name and downstream usages may need updating in callers.
43
+ * **Auto-fix**: replaces the broken expression with
44
+ * `process.env.NODE_ENV !== 'production'`.
37
45
  *
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`:
46
+ * **Server-only exemption**: projects configure `exemptPaths` per-file
47
+ * for server-only code (Node environments where the typeof-process
48
+ * compound is harmless). Configure in `.pyreonlintrc.json`:
41
49
  *
42
50
  * {
43
51
  * "rules": {
@@ -54,54 +62,49 @@ export const noProcessDevGate: Rule = {
54
62
  id: 'pyreon/no-process-dev-gate',
55
63
  category: 'architecture',
56
64
  description:
57
- 'Forbid `typeof process !== "undefined" && process.env.NODE_ENV !== "production"` as a dev-mode gate. Use `import.meta.env.DEV` instead `typeof process` is dead code in real Vite browser bundles because Vite does not polyfill `process` for the client.',
65
+ 'Forbid bundler-coupled dev gates: `typeof process !== "undefined" && process.env.NODE_ENV !== "production"` is dead in Vite browser bundles, and `import.meta.env.DEV` is Vite/Rolldown-only (dead in Webpack/Next.js, esbuild, Rollup, Parcel, Bun). Use bundler-agnostic `process.env.NODE_ENV !== "production"` every modern bundler auto-replaces it at consumer build time.',
58
66
  severity: 'error',
59
67
  fixable: true,
60
68
  },
61
69
  create(context) {
62
- // Skip test files — vitest has `process`, the gate works there, and
63
- // tests are not shipped to users. Universal, not a heuristic.
70
+ // Skip test files — vitest has both `process` and Vite's `import.meta.env`,
71
+ // both broken patterns work there, and tests aren't shipped to users.
64
72
  if (isTestFile(context.getFilePath())) return {}
65
73
 
66
74
  // Configurable `exemptPaths` option for server-only directories.
67
75
  if (isPathExempt(context)) return {}
68
76
 
77
+ const REPLACEMENT = `process.env.NODE_ENV !== 'production'`
78
+
69
79
  /**
70
- * Match the broken pattern at the AST level. We're looking for any
71
- * `LogicalExpression` whose two sides are:
72
- *
73
- * 1. `typeof process !== 'undefined'` (a UnaryExpression on the LHS
74
- * of a BinaryExpression with operator `!==`)
75
- * 2. `process.env.NODE_ENV !== 'production'` (a MemberExpression on
76
- * the LHS of a BinaryExpression with operator `!==`)
77
- *
78
- * The order can be either way (process check first or NODE_ENV check
79
- * first), and the operator can be `&&` or `||` (we only flag `&&`
80
- * because `||` doesn't make sense as a dev gate).
80
+ * Pattern 1: `typeof process !== 'undefined'`
81
81
  */
82
82
  function isTypeofProcessCheck(node: any): boolean {
83
- // typeof process !== 'undefined'
84
83
  if (node?.type !== 'BinaryExpression') return false
85
84
  if (node.operator !== '!==' && node.operator !== '!=') return false
86
85
  const left = node.left
87
86
  const right = node.right
88
87
  if (left?.type !== 'UnaryExpression' || left.operator !== 'typeof') return false
89
88
  if (left.argument?.type !== 'Identifier' || left.argument.name !== 'process') return false
90
- if (
89
+ return (
91
90
  (right?.type === 'Literal' || right?.type === 'StringLiteral') &&
92
91
  right.value === 'undefined'
93
- ) {
94
- return true
95
- }
96
- return false
92
+ )
97
93
  }
98
94
 
95
+ /**
96
+ * Pattern 1: `process.env.NODE_ENV !== 'production'` — also matches
97
+ * optional-chaining variants (`process?.env?.NODE_ENV`,
98
+ * `process.env?.NODE_ENV`). ESTree wraps optional access in a
99
+ * `ChainExpression` whose `.expression` is the underlying
100
+ * `MemberExpression` chain.
101
+ */
99
102
  function isNodeEnvCheck(node: any): boolean {
100
- // process.env.NODE_ENV !== 'production'
101
103
  if (node?.type !== 'BinaryExpression') return false
102
104
  if (node.operator !== '!==' && node.operator !== '!=') return false
103
- const left = node.left
105
+ let left = node.left
104
106
  const right = node.right
107
+ if (left?.type === 'ChainExpression') left = left.expression
105
108
  if (left?.type !== 'MemberExpression') return false
106
109
  if (left.object?.type !== 'MemberExpression') return false
107
110
  if (left.object.object?.type !== 'Identifier' || left.object.object.name !== 'process') {
@@ -111,42 +114,118 @@ export const noProcessDevGate: Rule = {
111
114
  return false
112
115
  }
113
116
  if (left.property?.type !== 'Identifier' || left.property.name !== 'NODE_ENV') return false
114
- if (
117
+ return (
115
118
  (right?.type === 'Literal' || right?.type === 'StringLiteral') &&
116
119
  right.value === 'production'
117
- ) {
118
- return true
119
- }
120
- return false
120
+ )
121
121
  }
122
122
 
123
- function isBrokenDevGate(node: any): boolean {
123
+ /**
124
+ * Match the typeof-process compound: a `LogicalExpression` whose
125
+ * sides are a typeof-process check + a NODE_ENV check, in either
126
+ * order, joined with `&&`.
127
+ */
128
+ function isTypeofCompound(node: any): boolean {
124
129
  if (node?.type !== 'LogicalExpression') return false
125
130
  if (node.operator !== '&&') return false
126
- // Order can be (typeof process) && (NODE_ENV) OR vice versa
127
131
  return (
128
132
  (isTypeofProcessCheck(node.left) && isNodeEnvCheck(node.right)) ||
129
133
  (isNodeEnvCheck(node.left) && isTypeofProcessCheck(node.right))
130
134
  )
131
135
  }
132
136
 
137
+ /**
138
+ * Strip layers an `import.meta.env.DEV` access can hide behind:
139
+ * - `ChainExpression` (optional chaining)
140
+ * - `TSAsExpression` / `TSTypeAssertion` (`(import.meta as ViteMeta)`)
141
+ * - `ParenthesizedExpression` (just parens)
142
+ */
143
+ function unwrap(node: any): any {
144
+ let n = node
145
+ while (n) {
146
+ if (n.type === 'ChainExpression') n = n.expression
147
+ else if (n.type === 'TSAsExpression' || n.type === 'TSTypeAssertion') n = n.expression
148
+ else if (n.type === 'ParenthesizedExpression') n = n.expression
149
+ else break
150
+ }
151
+ return n
152
+ }
153
+
154
+ function isImportMeta(node: any): boolean {
155
+ const n = unwrap(node)
156
+ if (n?.type !== 'MetaProperty') return false
157
+ const meta = n.meta
158
+ const prop = n.property
159
+ return (
160
+ meta?.type === 'Identifier' &&
161
+ meta.name === 'import' &&
162
+ prop?.type === 'Identifier' &&
163
+ prop.name === 'meta'
164
+ )
165
+ }
166
+
167
+ /**
168
+ * Pattern 2: `import.meta.env.DEV` access (any optional/cast variant).
169
+ * Returns the outermost expression node so the autofix replaces the
170
+ * full `import.meta.env.DEV` access (not just the `.DEV` property).
171
+ */
172
+ function isImportMetaEnvDev(node: any): boolean {
173
+ const outer = unwrap(node)
174
+ if (outer?.type !== 'MemberExpression') return false
175
+ if (outer.property?.type !== 'Identifier' || outer.property.name !== 'DEV') return false
176
+ // outer.object should resolve to `import.meta.env` (an inner MemberExpression
177
+ // whose object is `import.meta` and property is `env`).
178
+ const envAccess = unwrap(outer.object)
179
+ if (envAccess?.type !== 'MemberExpression') return false
180
+ if (envAccess.property?.type !== 'Identifier' || envAccess.property.name !== 'env') {
181
+ return false
182
+ }
183
+ return isImportMeta(envAccess.object)
184
+ }
185
+
186
+ // Track ChainExpression-wrapped MemberExpressions so the inner visitor
187
+ // doesn't double-flag the same access.
188
+ const handledNodes = new WeakSet<object>()
189
+
133
190
  const callbacks: VisitorCallbacks = {
134
191
  LogicalExpression(node: any) {
135
- if (!isBrokenDevGate(node)) return
136
-
192
+ if (!isTypeofCompound(node)) return
193
+ const span = getSpan(node)
194
+ context.report({
195
+ message:
196
+ '`typeof process !== "undefined" && process.env.NODE_ENV !== "production"` is dead code in real Vite browser bundles — Vite does not polyfill `process`, so the guard is `false` and any wrapped dev warnings never fire. Use the bundler-agnostic `process.env.NODE_ENV !== "production"` (no typeof guard) — every modern bundler replaces it at consumer build time. Reference: `packages/fundamentals/flow/src/layout.ts`.',
197
+ span,
198
+ fix: { span, replacement: REPLACEMENT },
199
+ })
200
+ },
201
+ ChainExpression(node: any) {
202
+ // Catch `import.meta.env?.DEV` shapes wrapped in ChainExpression at
203
+ // the outermost layer (e.g. when the optional `?.` is on the
204
+ // outermost `.DEV` access). The inner MemberExpression is then
205
+ // marked handled so the MemberExpression visitor skips it.
206
+ if (!isImportMetaEnvDev(node)) return
207
+ const inner = unwrap(node)
208
+ if (inner) handledNodes.add(inner)
209
+ const span = getSpan(node)
210
+ context.report({
211
+ message:
212
+ '`import.meta.env.DEV` is Vite/Rolldown-specific. In a Pyreon library shipped to consumers using Webpack (Next.js), esbuild, Rollup, Parcel, or Bun, `import.meta.env.DEV` is undefined and dev warnings never fire — even in development. Use bundler-agnostic `process.env.NODE_ENV !== "production"` instead. Reference: `packages/fundamentals/flow/src/layout.ts`.',
213
+ span,
214
+ fix: { span, replacement: REPLACEMENT },
215
+ })
216
+ },
217
+ MemberExpression(node: any) {
218
+ if (handledNodes.has(node)) return
219
+ if (!isImportMetaEnvDev(node)) return
220
+ // Only flag the OUTERMOST `.DEV` access — the visitor will also
221
+ // hit the inner `.env` MemberExpression, which we want to skip.
222
+ if (node.property?.name !== 'DEV') return
137
223
  const span = getSpan(node)
138
- // Auto-fix: replace the entire `typeof process ... && process.env.NODE_ENV ...`
139
- // expression with `import.meta.env?.DEV === true`. We use optional
140
- // chaining + strict equality so the expression is `false` (not
141
- // `undefined`) when `import.meta.env` is missing — preserving the
142
- // boolean shape callers expect.
143
- const replacement = 'import.meta.env?.DEV === true'
144
-
145
224
  context.report({
146
225
  message:
147
- '`typeof process !== "undefined" && process.env.NODE_ENV !== "production"` is dead code in real Vite browser bundles Vite does not polyfill `process`, so this guard is `false` and any wrapped dev warnings never fire for real users. Use `import.meta.env.DEV` instead, which Vite literal-replaces at build time and tree-shakes correctly in prod. Reference implementation: `packages/fundamentals/flow/src/layout.ts:warnIgnoredOptions`.',
226
+ '`import.meta.env.DEV` is Vite/Rolldown-specific. In a Pyreon library shipped to consumers using Webpack (Next.js), esbuild, Rollup, Parcel, or Bun, `import.meta.env.DEV` is undefined and dev warnings never fire even in development. Use bundler-agnostic `process.env.NODE_ENV !== "production"` instead. Reference: `packages/fundamentals/flow/src/layout.ts`.',
148
227
  span,
149
- fix: { span, replacement },
228
+ fix: { span, replacement: REPLACEMENT },
150
229
  })
151
230
  },
152
231
  }
@@ -33,6 +33,7 @@ import { noTernaryConditional } from './jsx/no-ternary-conditional'
33
33
  import { useByNotKey } from './jsx/use-by-not-key'
34
34
  import { noDomInSetup } from './lifecycle/no-dom-in-setup'
35
35
  import { noEffectInMount } from './lifecycle/no-effect-in-mount'
36
+ import { noImperativeEffectOnCreate } from './lifecycle/no-imperative-effect-on-create'
36
37
  // Lifecycle
37
38
  import { noMissingCleanup } from './lifecycle/no-missing-cleanup'
38
39
  import { noMountInEffect } from './lifecycle/no-mount-in-effect'
@@ -42,11 +43,13 @@ import { noEffectInFor } from './performance/no-effect-in-for'
42
43
  import { noLargeForWithoutBy } from './performance/no-large-for-without-by'
43
44
  import { preferShowOverDisplay } from './performance/prefer-show-over-display'
44
45
  // Reactivity
46
+ import { noAsyncEffect } from './reactivity/no-async-effect'
45
47
  import { noBareSignalInJsx } from './reactivity/no-bare-signal-in-jsx'
46
48
  import { noContextDestructure } from './reactivity/no-context-destructure'
47
49
  import { noEffectAssignment } from './reactivity/no-effect-assignment'
48
50
  import { noNestedEffect } from './reactivity/no-nested-effect'
49
51
  import { noPeekInTracked } from './reactivity/no-peek-in-tracked'
52
+ import { noSignalCallWrite } from './reactivity/no-signal-call-write'
50
53
  import { noSignalInLoop } from './reactivity/no-signal-in-loop'
51
54
  import { noSignalInProps } from './reactivity/no-signal-in-props'
52
55
  import { noSignalLeak } from './reactivity/no-signal-leak'
@@ -72,7 +75,8 @@ import { noThemeOutsideProvider } from './styling/no-theme-outside-provider'
72
75
  import { preferCx } from './styling/prefer-cx'
73
76
 
74
77
  export const allRules: Rule[] = [
75
- // Reactivity (10)
78
+ // Reactivity (12)
79
+ noAsyncEffect,
76
80
  noBareSignalInJsx,
77
81
  noContextDestructure,
78
82
  noSignalInLoop,
@@ -83,6 +87,7 @@ export const allRules: Rule[] = [
83
87
  preferComputed,
84
88
  noEffectAssignment,
85
89
  noSignalLeak,
90
+ noSignalCallWrite,
86
91
  // JSX (11)
87
92
  noMapInJsx,
88
93
  useByNotKey,
@@ -95,11 +100,12 @@ export const allRules: Rule[] = [
95
100
  noMissingForBy,
96
101
  noPropsDestructure,
97
102
  noChildrenAccess,
98
- // Lifecycle (4)
103
+ // Lifecycle (5)
99
104
  noMissingCleanup,
100
105
  noMountInEffect,
101
106
  noEffectInMount,
102
107
  noDomInSetup,
108
+ noImperativeEffectOnCreate,
103
109
  // Performance (4)
104
110
  noLargeForWithoutBy,
105
111
  noEffectInFor,
@@ -151,6 +157,7 @@ export {
151
157
  dialogA11y,
152
158
  noAndConditional,
153
159
  // Reactivity
160
+ noAsyncEffect,
154
161
  noBareSignalInJsx,
155
162
  noContextDestructure,
156
163
  noChildrenAccess,
@@ -169,6 +176,7 @@ export {
169
176
  noErrorWithoutPrefix,
170
177
  noHrefNavigation,
171
178
  noHtmlFor,
179
+ noImperativeEffectOnCreate,
172
180
  noImperativeNavigateInRender,
173
181
  noIndexAsBy,
174
182
  // Styling
@@ -189,6 +197,7 @@ export {
189
197
  noPeekInTracked,
190
198
  noProcessDevGate,
191
199
  noPropsDestructure,
200
+ noSignalCallWrite,
192
201
  requireBrowserSmokeTest,
193
202
  // Hooks
194
203
  noRawAddEventListener,
@@ -1,5 +1,6 @@
1
1
  import type { Rule, VisitorCallbacks } from '../../types'
2
2
  import { getSpan, isDestructuring } from '../../utils/ast'
3
+ import { isPathExempt } from '../../utils/exempt-paths'
3
4
 
4
5
  function containsJSXReturn(node: any): boolean {
5
6
  if (!node) return false
@@ -31,6 +32,40 @@ function getDestructuredNames(pattern: any): string[] {
31
32
  return names
32
33
  }
33
34
 
35
+ /**
36
+ * Names of HOC / factory call expressions whose first-argument render
37
+ * function takes Pyreon component props. Destructuring inside these IS
38
+ * a real reactivity bug — same as destructuring at the component
39
+ * signature directly. Do NOT add this exemption for these.
40
+ *
41
+ * The `callArgFns` exemption is intentionally narrow: it only fires for
42
+ * generic call arguments where the parent call is NOT one of these
43
+ * known component-shaped factories.
44
+ */
45
+ const COMPONENT_FACTORY_NAMES = new Set([
46
+ 'createComponent',
47
+ 'defineComponent',
48
+ 'lazy',
49
+ 'memo',
50
+ 'observer',
51
+ 'forwardRef',
52
+ 'rocketstyle',
53
+ 'styled',
54
+ 'attrs',
55
+ 'kinetic',
56
+ ])
57
+
58
+ function isComponentFactoryCall(call: any): boolean {
59
+ if (!call || call.type !== 'CallExpression') return false
60
+ const callee = call.callee
61
+ if (!callee) return false
62
+ if (callee.type === 'Identifier' && COMPONENT_FACTORY_NAMES.has(callee.name)) return true
63
+ // `styled.div\`...\`` template tag falls back to a CallExpression on
64
+ // styled members in some compilers — be conservative and don't try to
65
+ // detect template literal forms here.
66
+ return false
67
+ }
68
+
34
69
  export const noPropsDestructure: Rule = {
35
70
  meta: {
36
71
  id: 'pyreon/no-props-destructure',
@@ -39,13 +74,18 @@ export const noPropsDestructure: Rule = {
39
74
  'Disallow destructuring props in component functions — breaks reactive prop tracking. Use props.x or splitProps().',
40
75
  severity: 'error',
41
76
  fixable: false,
77
+ schema: { exemptPaths: 'string[]' },
42
78
  },
43
79
  create(context) {
80
+ if (isPathExempt(context)) return {}
81
+
44
82
  let functionDepth = 0
45
83
  // oxc visitor doesn't pass `parent` to callbacks — previous
46
84
  // `parent?.type === 'CallExpression'` check was silently inert. Pre-mark
47
85
  // function nodes that appear as CallExpression arguments on the way in.
48
- const callArgFns = new WeakSet<any>()
86
+ // Track BOTH the function and its parent call so we can later refuse
87
+ // the exemption when the parent is a known component factory.
88
+ const callArgFns = new WeakMap<any, any>()
49
89
 
50
90
  const callbacks: VisitorCallbacks = {
51
91
  CallExpression(node: any) {
@@ -55,7 +95,7 @@ export const noPropsDestructure: Rule = {
55
95
  arg?.type === 'FunctionExpression' ||
56
96
  arg?.type === 'FunctionDeclaration'
57
97
  ) {
58
- callArgFns.add(arg)
98
+ callArgFns.set(arg, node)
59
99
  }
60
100
  }
61
101
  },
@@ -85,19 +125,29 @@ export const noPropsDestructure: Rule = {
85
125
  },
86
126
  }
87
127
 
88
- function checkFunction(node: any, context: any, depth: number, callArgFns: WeakSet<any>) {
128
+ function checkFunction(node: any, context: any, depth: number, callArgFns: WeakMap<any, any>) {
89
129
  const params = node.params
90
130
  if (!params || params.length === 0) return
91
131
 
92
132
  const firstParam = params[0]
93
133
  if (!isDestructuring(firstParam)) return
94
134
 
95
- // Skip HOC inner functions (depth > 1)
135
+ // Skip nested functions (depth > 1). This protects render-prop
136
+ // callbacks whose first param is NOT a Pyreon component prop bag —
137
+ // e.g. `<For>{(item) => <li>{item}</li>}</For>` passes raw array
138
+ // items, so destructuring is a non-issue there. The tradeoff is that
139
+ // genuinely-nested component declarations slip past this rule;
140
+ // they're rare enough in practice that the false-negative is
141
+ // acceptable.
96
142
  if (depth > 1) return
97
143
 
98
- // Skip functions passed as arguments to HOC factories
99
- // e.g. createLink(({ href, ...rest }) => <a {...rest} />)
100
- if (callArgFns.has(node)) return
144
+ // Skip functions passed as call arguments (HOC / render-prop
145
+ // pattern), UNLESS the parent call is a known component factory.
146
+ // Component factories receive Pyreon props via the inner function,
147
+ // so destructuring there breaks reactivity exactly like it does at
148
+ // the component signature.
149
+ const parentCall = callArgFns.get(node)
150
+ if (parentCall && !isComponentFactoryCall(parentCall)) return
101
151
 
102
152
  const body = node.body
103
153
  if (!body) return