@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.
Files changed (45) hide show
  1. package/README.md +55 -2
  2. package/lib/analysis/cli.js.html +1 -1
  3. package/lib/analysis/index.js.html +1 -1
  4. package/lib/cli.js +960 -162
  5. package/lib/cli.js.map +1 -1
  6. package/lib/index.js +935 -161
  7. package/lib/index.js.map +1 -1
  8. package/lib/types/index.d.ts +96 -23
  9. package/lib/types/index.d.ts.map +1 -1
  10. package/package.json +2 -1
  11. package/schema/pyreonlintrc.schema.json +64 -0
  12. package/src/cli.ts +44 -2
  13. package/src/config/presets.ts +13 -1
  14. package/src/index.ts +7 -0
  15. package/src/lint.ts +37 -6
  16. package/src/lsp/index.ts +15 -2
  17. package/src/rules/architecture/dev-guard-warnings.ts +172 -17
  18. package/src/rules/architecture/no-circular-import.ts +7 -0
  19. package/src/rules/architecture/no-process-dev-gate.ts +18 -45
  20. package/src/rules/architecture/require-browser-smoke-test.ts +227 -0
  21. package/src/rules/form/no-submit-without-validation.ts +9 -0
  22. package/src/rules/form/no-unregistered-field.ts +9 -0
  23. package/src/rules/hooks/no-raw-addeventlistener.ts +7 -0
  24. package/src/rules/hooks/no-raw-localstorage.ts +12 -1
  25. package/src/rules/hooks/no-raw-setinterval.ts +14 -0
  26. package/src/rules/index.ts +4 -1
  27. package/src/rules/jsx/no-props-destructure.ts +20 -6
  28. package/src/rules/lifecycle/no-dom-in-setup.ts +67 -7
  29. package/src/rules/reactivity/no-bare-signal-in-jsx.ts +12 -1
  30. package/src/rules/reactivity/no-unbatched-updates.ts +3 -0
  31. package/src/rules/router/no-imperative-navigate-in-render.ts +131 -35
  32. package/src/rules/ssr/no-window-in-ssr.ts +418 -35
  33. package/src/rules/store/no-duplicate-store-id.ts +11 -0
  34. package/src/rules/store/no-mutate-store-state.ts +11 -1
  35. package/src/rules/styling/no-dynamic-styled.ts +13 -24
  36. package/src/rules/styling/no-theme-outside-provider.ts +34 -2
  37. package/src/runner.ts +100 -10
  38. package/src/tests/runner.test.ts +1573 -21
  39. package/src/types.ts +74 -3
  40. package/src/utils/component-context.ts +106 -0
  41. package/src/utils/exempt-paths.ts +39 -0
  42. package/src/utils/file-roles.ts +32 -0
  43. package/src/utils/imports.ts +4 -1
  44. package/src/utils/validate-options.ts +68 -0
  45. package/src/watcher.ts +17 -0
@@ -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
- const themeCalls: Array<{ span: { start: number; end: number } }> = []
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 severity = config.rules[rule.meta.id]
132
- if (severity === undefined || severity === 'off') continue
133
- const ctx = createRuleContext(rule, severity, diagnostics, lineIndex, sourceText, filePath)
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
- // // pyreon-lint-ignore — suppress all on next line
143
- // // pyreon-lint-ignore rule-name — suppress specific rule on next line
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
- if (!prevLine?.startsWith('// pyreon-lint-ignore')) return true
150
- const rest = prevLine.slice('// pyreon-lint-ignore'.length).trim()
151
- return rest.length > 0 && rest !== d.ruleId
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)