@pyreon/lint 0.12.12 → 0.12.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -2
- package/lib/analysis/cli.js.html +1 -1
- package/lib/analysis/index.js.html +1 -1
- package/lib/cli.js +960 -162
- package/lib/cli.js.map +1 -1
- package/lib/index.js +935 -161
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +96 -23
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +2 -1
- package/schema/pyreonlintrc.schema.json +64 -0
- package/src/cli.ts +44 -2
- package/src/config/presets.ts +13 -1
- package/src/index.ts +7 -0
- package/src/lint.ts +37 -6
- package/src/lsp/index.ts +15 -2
- package/src/rules/architecture/dev-guard-warnings.ts +172 -17
- package/src/rules/architecture/no-circular-import.ts +7 -0
- package/src/rules/architecture/no-process-dev-gate.ts +18 -45
- package/src/rules/architecture/require-browser-smoke-test.ts +227 -0
- package/src/rules/form/no-submit-without-validation.ts +9 -0
- package/src/rules/form/no-unregistered-field.ts +9 -0
- package/src/rules/hooks/no-raw-addeventlistener.ts +7 -0
- package/src/rules/hooks/no-raw-localstorage.ts +12 -1
- package/src/rules/hooks/no-raw-setinterval.ts +14 -0
- package/src/rules/index.ts +4 -1
- package/src/rules/jsx/no-props-destructure.ts +20 -6
- package/src/rules/lifecycle/no-dom-in-setup.ts +67 -7
- package/src/rules/reactivity/no-bare-signal-in-jsx.ts +12 -1
- package/src/rules/reactivity/no-unbatched-updates.ts +3 -0
- package/src/rules/router/no-imperative-navigate-in-render.ts +131 -35
- package/src/rules/ssr/no-window-in-ssr.ts +418 -35
- package/src/rules/store/no-duplicate-store-id.ts +11 -0
- package/src/rules/store/no-mutate-store-state.ts +11 -1
- package/src/rules/styling/no-dynamic-styled.ts +13 -24
- package/src/rules/styling/no-theme-outside-provider.ts +34 -2
- package/src/runner.ts +100 -10
- package/src/tests/runner.test.ts +1573 -21
- package/src/types.ts +74 -3
- package/src/utils/component-context.ts +106 -0
- package/src/utils/exempt-paths.ts +39 -0
- package/src/utils/file-roles.ts +32 -0
- package/src/utils/imports.ts +4 -1
- package/src/utils/validate-options.ts +68 -0
- package/src/watcher.ts +17 -0
|
@@ -1,5 +1,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
|
-
|
|
14
|
-
//
|
|
15
|
-
if (
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
) {
|
|
22
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
34
|
-
|
|
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-
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
-
//
|
|
91
|
-
|
|
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:
|
|
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 (
|