@pyreon/lint 0.12.13 → 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
|
@@ -2,6 +2,8 @@ import type { Rule, VisitorCallbacks } from '../../types'
|
|
|
2
2
|
import { getSpan, isCallTo } from '../../utils/ast'
|
|
3
3
|
import { extractImportInfo } from '../../utils/imports'
|
|
4
4
|
|
|
5
|
+
const HOOK_NAME = /^use[A-Z]/
|
|
6
|
+
|
|
5
7
|
export const noThemeOutsideProvider: Rule = {
|
|
6
8
|
meta: {
|
|
7
9
|
id: 'pyreon/no-theme-outside-provider',
|
|
@@ -12,9 +14,36 @@ export const noThemeOutsideProvider: Rule = {
|
|
|
12
14
|
},
|
|
13
15
|
create(context) {
|
|
14
16
|
let hasProviderImport = false
|
|
15
|
-
|
|
17
|
+
let hookDepth = 0
|
|
18
|
+
// Each useTheme call records whether it was inside a hook implementation.
|
|
19
|
+
// Hook files (e.g. `useThemeValue.ts`) legitimately call `useTheme()` to
|
|
20
|
+
// re-export theme access — they delegate provider responsibility to the
|
|
21
|
+
// consuming component, which is the place the rule actually targets.
|
|
22
|
+
const themeCalls: Array<{ span: { start: number; end: number }; insideHook: boolean }> = []
|
|
23
|
+
|
|
24
|
+
function declaratorIsHook(node: any): boolean {
|
|
25
|
+
if (node?.id?.type !== 'Identifier') return false
|
|
26
|
+
const init = node.init
|
|
27
|
+
if (init?.type !== 'ArrowFunctionExpression' && init?.type !== 'FunctionExpression') return false
|
|
28
|
+
return HOOK_NAME.test(node.id.name)
|
|
29
|
+
}
|
|
16
30
|
|
|
17
31
|
const callbacks: VisitorCallbacks = {
|
|
32
|
+
// `function useFoo() { … }`
|
|
33
|
+
FunctionDeclaration(node: any) {
|
|
34
|
+
if (node.id?.name && HOOK_NAME.test(node.id.name)) hookDepth++
|
|
35
|
+
},
|
|
36
|
+
'FunctionDeclaration:exit'(node: any) {
|
|
37
|
+
if (node.id?.name && HOOK_NAME.test(node.id.name)) hookDepth--
|
|
38
|
+
},
|
|
39
|
+
// `const useFoo = () => { … }` (oxc visitor doesn't pass parent, so
|
|
40
|
+
// hook into the declarator — it brackets the inner function body).
|
|
41
|
+
VariableDeclarator(node: any) {
|
|
42
|
+
if (declaratorIsHook(node)) hookDepth++
|
|
43
|
+
},
|
|
44
|
+
'VariableDeclarator:exit'(node: any) {
|
|
45
|
+
if (declaratorIsHook(node)) hookDepth--
|
|
46
|
+
},
|
|
18
47
|
ImportDeclaration(node: any) {
|
|
19
48
|
const info = extractImportInfo(node)
|
|
20
49
|
if (!info) return
|
|
@@ -26,12 +55,15 @@ export const noThemeOutsideProvider: Rule = {
|
|
|
26
55
|
},
|
|
27
56
|
CallExpression(node: any) {
|
|
28
57
|
if (isCallTo(node, 'useTheme')) {
|
|
29
|
-
themeCalls.push({ span: getSpan(node) })
|
|
58
|
+
themeCalls.push({ span: getSpan(node), insideHook: hookDepth > 0 })
|
|
30
59
|
}
|
|
31
60
|
},
|
|
32
61
|
'Program:exit'() {
|
|
33
62
|
if (hasProviderImport) return
|
|
34
63
|
for (const call of themeCalls) {
|
|
64
|
+
// Inside a hook implementation? The hook delegates provider
|
|
65
|
+
// responsibility to its caller — silence here.
|
|
66
|
+
if (call.insideHook) continue
|
|
35
67
|
context.report({
|
|
36
68
|
message:
|
|
37
69
|
'`useTheme()` without a `PyreonUI` or `ThemeProvider` import — the theme context may not be available.',
|
package/src/runner.ts
CHANGED
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
import { parseSync, Visitor } from 'oxc-parser'
|
|
2
2
|
import type { AstCache } from './cache'
|
|
3
3
|
import type {
|
|
4
|
+
ConfigDiagnostic,
|
|
4
5
|
Diagnostic,
|
|
5
6
|
LintConfig,
|
|
6
7
|
LintFileResult,
|
|
7
8
|
Rule,
|
|
8
9
|
RuleContext,
|
|
10
|
+
RuleOptions,
|
|
9
11
|
Severity,
|
|
10
12
|
VisitorCallbacks,
|
|
11
13
|
} from './types'
|
|
12
14
|
import { JS_EXTENSIONS } from './utils/index'
|
|
13
15
|
import { LineIndex } from './utils/source'
|
|
16
|
+
import { validateRuleOptions } from './utils/validate-options'
|
|
17
|
+
|
|
18
|
+
// Per-process cache so we only validate a given (rule, options) pair once
|
|
19
|
+
// and only print-once even across a multi-file lint run.
|
|
20
|
+
const VALIDATION_CACHE = new Map<string, { ok: boolean; diagnostics: ConfigDiagnostic[] }>()
|
|
21
|
+
|
|
22
|
+
/** Reset caches — exposed for tests; not part of the public surface. */
|
|
23
|
+
export function _resetConfigDiagnosticsCache(): void {
|
|
24
|
+
VALIDATION_CACHE.clear()
|
|
25
|
+
}
|
|
14
26
|
|
|
15
27
|
function getExtension(filePath: string): string {
|
|
16
28
|
const lastDot = filePath.lastIndexOf('.')
|
|
@@ -28,6 +40,7 @@ function getLang(ext: string): OxcLang {
|
|
|
28
40
|
function createRuleContext(
|
|
29
41
|
rule: Rule,
|
|
30
42
|
severity: Severity,
|
|
43
|
+
options: RuleOptions,
|
|
31
44
|
diagnostics: Diagnostic[],
|
|
32
45
|
lineIndex: LineIndex,
|
|
33
46
|
sourceText: string,
|
|
@@ -50,6 +63,9 @@ function createRuleContext(
|
|
|
50
63
|
getFilePath() {
|
|
51
64
|
return filePath
|
|
52
65
|
},
|
|
66
|
+
getOptions() {
|
|
67
|
+
return options
|
|
68
|
+
},
|
|
53
69
|
}
|
|
54
70
|
}
|
|
55
71
|
|
|
@@ -96,6 +112,12 @@ export function lintFile(
|
|
|
96
112
|
rules: Rule[],
|
|
97
113
|
config: LintConfig,
|
|
98
114
|
cache?: AstCache | undefined,
|
|
115
|
+
/**
|
|
116
|
+
* Optional sink for config-level diagnostics (malformed rule options).
|
|
117
|
+
* When provided, diagnostics are appended to it instead of printed to
|
|
118
|
+
* stderr — `lint()` uses this to surface them on `LintResult`.
|
|
119
|
+
*/
|
|
120
|
+
configDiagnosticsSink?: ConfigDiagnostic[],
|
|
99
121
|
): LintFileResult {
|
|
100
122
|
const ext = getExtension(filePath)
|
|
101
123
|
if (!JS_EXTENSIONS.has(ext)) {
|
|
@@ -128,9 +150,66 @@ export function lintFile(
|
|
|
128
150
|
// Filter to enabled rules and create visitor callbacks
|
|
129
151
|
const allCallbacks: VisitorCallbacks[] = []
|
|
130
152
|
for (const rule of rules) {
|
|
131
|
-
const
|
|
132
|
-
if (
|
|
133
|
-
|
|
153
|
+
const entry = config.rules[rule.meta.id]
|
|
154
|
+
if (entry === undefined) continue
|
|
155
|
+
// Normalize bare severity vs `[severity, options]` tuple.
|
|
156
|
+
const [severity, options]: [Severity, RuleOptions] = Array.isArray(entry)
|
|
157
|
+
? [entry[0] as Severity, (entry[1] ?? {}) as RuleOptions]
|
|
158
|
+
: [entry as Severity, {}]
|
|
159
|
+
if (severity === 'off') continue
|
|
160
|
+
|
|
161
|
+
// Validate options against the rule's declared schema. Cached per
|
|
162
|
+
// (rule, options) pair — config doesn't change within a run.
|
|
163
|
+
const cacheKey = `${rule.meta.id}::${JSON.stringify(options)}`
|
|
164
|
+
let cached = VALIDATION_CACHE.get(cacheKey)
|
|
165
|
+
if (!cached) {
|
|
166
|
+
const { errors, warnings } = validateRuleOptions(rule, options)
|
|
167
|
+
const configDiags: ConfigDiagnostic[] = []
|
|
168
|
+
for (const message of warnings) {
|
|
169
|
+
configDiags.push({ ruleId: rule.meta.id, severity: 'warn', message })
|
|
170
|
+
}
|
|
171
|
+
for (const message of errors) {
|
|
172
|
+
configDiags.push({ ruleId: rule.meta.id, severity: 'error', message })
|
|
173
|
+
}
|
|
174
|
+
cached = { ok: errors.length === 0, diagnostics: configDiags }
|
|
175
|
+
VALIDATION_CACHE.set(cacheKey, cached)
|
|
176
|
+
}
|
|
177
|
+
// Surface config diagnostics once per (rule, options) pair: prefer
|
|
178
|
+
// the caller-supplied sink (so `lint()` can put them on LintResult);
|
|
179
|
+
// fall back to stderr for standalone `lintFile` usage.
|
|
180
|
+
if (cached.diagnostics.length > 0) {
|
|
181
|
+
if (configDiagnosticsSink) {
|
|
182
|
+
// Dedupe within the sink by (ruleId, message) so two different rules
|
|
183
|
+
// that happen to produce an identical message don't collapse.
|
|
184
|
+
for (const d of cached.diagnostics) {
|
|
185
|
+
if (
|
|
186
|
+
!configDiagnosticsSink.some(
|
|
187
|
+
(x) => x.ruleId === d.ruleId && x.message === d.message,
|
|
188
|
+
)
|
|
189
|
+
) {
|
|
190
|
+
configDiagnosticsSink.push(d)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
for (const d of cached.diagnostics) {
|
|
195
|
+
// oxlint-disable-next-line no-console
|
|
196
|
+
const emit = d.severity === 'error' ? console.error : console.warn
|
|
197
|
+
emit(`[pyreon-lint] ${d.message}`)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// Hard error in options → skip this rule entirely for the run.
|
|
202
|
+
if (!cached.ok) continue
|
|
203
|
+
|
|
204
|
+
const ctx = createRuleContext(
|
|
205
|
+
rule,
|
|
206
|
+
severity,
|
|
207
|
+
options,
|
|
208
|
+
diagnostics,
|
|
209
|
+
lineIndex,
|
|
210
|
+
sourceText,
|
|
211
|
+
filePath,
|
|
212
|
+
)
|
|
134
213
|
allCallbacks.push(rule.create(ctx))
|
|
135
214
|
}
|
|
136
215
|
|
|
@@ -138,17 +217,28 @@ export function lintFile(
|
|
|
138
217
|
const visitor = new Visitor(mergeCallbacks(allCallbacks))
|
|
139
218
|
visitor.visit(program)
|
|
140
219
|
|
|
141
|
-
// Filter suppressed diagnostics:
|
|
142
|
-
//
|
|
143
|
-
//
|
|
220
|
+
// Filter suppressed diagnostics. Two equivalent comment syntaxes:
|
|
221
|
+
// // pyreon-lint-ignore — suppress all on next line
|
|
222
|
+
// // pyreon-lint-ignore <rule-id> — suppress one rule
|
|
223
|
+
// // pyreon-lint-disable-next-line — alias of `ignore`
|
|
224
|
+
// // pyreon-lint-disable-next-line <rule-id> — alias of `ignore <rule-id>`
|
|
225
|
+
// The `disable-next-line` form is the convention several rule docstrings
|
|
226
|
+
// already document — we accept both so the docs and runtime match.
|
|
227
|
+
// Word-boundary matching prevents typos like `// pyreon-lint-ignored` from
|
|
228
|
+
// accidentally being treated as suppressions.
|
|
144
229
|
const lines = sourceText.split('\n')
|
|
230
|
+
const SUPPRESS_RE = /^\/\/\s*pyreon-lint-(?:ignore|disable-next-line)(?:\s+(\S+))?\s*$/
|
|
145
231
|
const filtered = diagnostics.filter((d) => {
|
|
146
232
|
const prevLineIdx = d.loc.line - 2
|
|
147
233
|
if (prevLineIdx < 0) return true
|
|
148
|
-
const prevLine = lines[prevLineIdx]?.trim()
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
234
|
+
const prevLine = lines[prevLineIdx]?.trim() ?? ''
|
|
235
|
+
const match = SUPPRESS_RE.exec(prevLine)
|
|
236
|
+
if (!match) return true
|
|
237
|
+
const ruleId = match[1]
|
|
238
|
+
// Bare suppression (no rule id) → suppress every diagnostic on next line.
|
|
239
|
+
if (!ruleId) return false
|
|
240
|
+
// Rule-specific suppression → drop only the matching rule.
|
|
241
|
+
return ruleId !== d.ruleId
|
|
152
242
|
})
|
|
153
243
|
|
|
154
244
|
filtered.sort((a, b) => a.span.start - b.span.start)
|