@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/README.md +9 -7
- package/lib/analysis/cli.js.html +1 -1
- package/lib/analysis/index.js.html +1 -1
- package/lib/cli.js +540 -60
- package/lib/index.js +540 -60
- package/package.json +3 -2
- package/src/manifest.ts +6 -6
- package/src/rules/architecture/dev-guard-warnings.ts +56 -6
- package/src/rules/architecture/no-process-dev-gate.ts +141 -62
- package/src/rules/index.ts +11 -2
- package/src/rules/jsx/no-props-destructure.ts +57 -7
- package/src/rules/lifecycle/no-imperative-effect-on-create.ts +278 -0
- package/src/rules/reactivity/no-async-effect.ts +84 -0
- package/src/rules/reactivity/no-signal-call-write.ts +60 -0
- package/src/tests/runner.test.ts +543 -8
- package/lib/cli.js.map +0 -1
- package/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/lint",
|
|
3
|
-
"version": "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.
|
|
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 —
|
|
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 (
|
|
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
|
-
'
|
|
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
|
|
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
|
-
'
|
|
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
|
|
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`
|
|
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
|
-
|
|
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
|
|
8
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
22
|
-
*
|
|
30
|
+
* **The bundler-agnostic standard** (used by React, Vue, Preact, Solid,
|
|
31
|
+
* MobX, Redux):
|
|
23
32
|
*
|
|
24
33
|
* ```ts
|
|
25
|
-
*
|
|
26
|
-
* if (!import.meta.env?.DEV) return
|
|
34
|
+
* if (process.env.NODE_ENV !== 'production') console.warn('...')
|
|
27
35
|
* ```
|
|
28
36
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
35
|
-
*
|
|
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
|
|
39
|
-
* server-only code (Node environments where
|
|
40
|
-
*
|
|
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"`
|
|
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
|
|
63
|
-
//
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
'`
|
|
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
|
}
|
package/src/rules/index.ts
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
|
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
|
|
99
|
-
//
|
|
100
|
-
|
|
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
|