@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
package/src/types.ts CHANGED
@@ -42,23 +42,64 @@ export type RuleCategory =
42
42
  | 'accessibility'
43
43
  | 'router'
44
44
 
45
+ /**
46
+ * Declared type of an option slot. Minimal on purpose — sufficient for
47
+ * the exemption patterns we actually use. Extend when a rule needs more.
48
+ */
49
+ export type OptionType = 'string' | 'string[]' | 'number' | 'boolean'
50
+
51
+ /**
52
+ * Schema for a rule's options bag — keys are option names, values are
53
+ * their declared types. Unknown keys in user config emit a warning;
54
+ * wrong-typed values disable the rule and emit an error. Rules with no
55
+ * schema accept any options (no validation).
56
+ */
57
+ export type RuleOptionsSchema = Record<string, OptionType>
58
+
45
59
  export interface RuleMeta {
46
60
  id: string
47
61
  category: RuleCategory
48
62
  description: string
49
63
  severity: Severity
50
64
  fixable: boolean
65
+ /**
66
+ * Declared options shape. Validated once when a config enables the rule;
67
+ * bad options either get reported (unknown key → warn, wrong type →
68
+ * error + rule disabled for that run).
69
+ */
70
+ schema?: RuleOptionsSchema
51
71
  }
52
72
 
73
+ // ── Rule Options ────────────────────────────────────────────────────────────
74
+ //
75
+ // Rules can be configured with an options object in addition to severity.
76
+ // This lets users opt files out of a rule without hardcoding paths in the
77
+ // rule source (which would ship to every consuming project).
78
+ //
79
+ // Convention: rules that support path-based exemption read
80
+ // `options.exemptPaths: string[]` — each entry is a substring matched
81
+ // against the file path. See `utils/exempt-paths.ts` for the helper.
82
+
83
+ export type RuleOptions = Record<string, unknown>
84
+
53
85
  // ── Rule Context & Visitor ──────────────────────────────────────────────────
54
86
 
55
87
  export interface RuleContext {
56
88
  report(diagnostic: Omit<Diagnostic, 'ruleId' | 'severity' | 'loc'>): void
57
89
  getSourceText(): string
58
90
  getFilePath(): string
91
+ /** Options passed via config (tuple form: `[severity, options]`). */
92
+ getOptions(): RuleOptions
59
93
  }
60
94
 
61
- export type VisitorCallback = (node: any, parent?: any) => void
95
+ /**
96
+ * Visitor callback. oxc's walker only passes the current node — it does NOT
97
+ * pass `parent`. Rules that need parent context must track it via
98
+ * enter/exit depth counters or pre-mark child nodes via WeakSet on the way
99
+ * in. An earlier `parent?: any` signature here was a false promise that
100
+ * silently disabled `parent.type === '…'` checks across multiple rules.
101
+ */
102
+ export type VisitorCallback = (node: any) => void
62
103
 
63
104
  export interface VisitorCallbacks {
64
105
  [nodeType: string]: VisitorCallback
@@ -73,15 +114,25 @@ export interface Rule {
73
114
 
74
115
  // ── Configuration ───────────────────────────────────────────────────────────
75
116
 
117
+ /**
118
+ * A rule entry is either a bare severity (`"error"`, `"warn"`, `"info"`,
119
+ * `"off"`) or a tuple `[severity, options]`. The tuple form lets consumers
120
+ * pass per-rule options without a bespoke API per rule.
121
+ *
122
+ * "pyreon/no-window-in-ssr": "error"
123
+ * "pyreon/no-window-in-ssr": ["error", { "exemptPaths": ["packages/core/runtime-dom/"] }]
124
+ */
125
+ export type RuleEntry = Severity | readonly [Severity, RuleOptions]
126
+
76
127
  export interface LintConfig {
77
- rules: Record<string, Severity>
128
+ rules: Record<string, RuleEntry>
78
129
  include?: string[] | undefined
79
130
  exclude?: string[] | undefined
80
131
  }
81
132
 
82
133
  export interface LintConfigFile {
83
134
  preset?: PresetName | undefined
84
- rules?: Record<string, Severity> | undefined
135
+ rules?: Record<string, RuleEntry> | undefined
85
136
  include?: string[] | undefined
86
137
  exclude?: string[] | undefined
87
138
  }
@@ -96,11 +147,25 @@ export interface LintFileResult {
96
147
  fixedSource?: string | undefined
97
148
  }
98
149
 
150
+ /**
151
+ * Config-level diagnostic — emitted by `validateRuleOptions` when a rule's
152
+ * configured options don't match its declared `schema`. Not tied to a
153
+ * source file; lives on `LintResult.configDiagnostics` so programmatic
154
+ * consumers (CI, LSP, JSON reporters) surface them alongside file diags.
155
+ */
156
+ export interface ConfigDiagnostic {
157
+ ruleId: string
158
+ severity: 'error' | 'warn'
159
+ message: string
160
+ }
161
+
99
162
  export interface LintResult {
100
163
  files: LintFileResult[]
101
164
  totalErrors: number
102
165
  totalWarnings: number
103
166
  totalInfos: number
167
+ /** Config-level diagnostics (malformed rule options, etc.). */
168
+ configDiagnostics: ConfigDiagnostic[]
104
169
  }
105
170
 
106
171
  // ── Lint Options ────────────────────────────────────────────────────────────
@@ -111,6 +176,12 @@ export interface LintOptions {
111
176
  fix?: boolean | undefined
112
177
  quiet?: boolean | undefined
113
178
  ruleOverrides?: Record<string, Severity> | undefined
179
+ /**
180
+ * Per-rule options overrides — typically populated from the
181
+ * `--rule-options id='{json}'` CLI flag. Merged on top of any
182
+ * options coming from the config file's tuple form.
183
+ */
184
+ ruleOptionsOverrides?: Record<string, RuleOptions> | undefined
114
185
  config?: string | undefined
115
186
  ignore?: string | undefined
116
187
  }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Component / hook scope tracker for lint rules.
3
+ *
4
+ * Many rules in this package only matter *inside* a component or hook —
5
+ * e.g. `no-raw-setinterval` wants you to wrap timers in `onMount` so they
6
+ * are cleaned up when the component unmounts. A `setInterval` at module
7
+ * scope, in a utility function, or inside a test callback has its own
8
+ * lifecycle and doesn't need component-tied cleanup.
9
+ *
10
+ * Previously these rules used a path-string `isTestFile()` skip as a
11
+ * proxy for "outside component context". That's a heuristic — it
12
+ * accidentally exempts test files where the pattern *is* still wrong,
13
+ * and it accidentally fires on utility/library files where the pattern
14
+ * is fine.
15
+ *
16
+ * This tracker maintains a "component depth" counter via visitor
17
+ * callbacks. A function counts as a component or hook when its name
18
+ * follows the framework's naming conventions:
19
+ * - `MyThing` (PascalCase) → component
20
+ * - `useThing` (camelCase, `use` prefix + uppercase next char) → hook
21
+ *
22
+ * Rules consume it via `createComponentContextTracker()` and merge the
23
+ * returned `callbacks` into their visitor:
24
+ *
25
+ * ```ts
26
+ * create(context) {
27
+ * const ctx = createComponentContextTracker()
28
+ * return {
29
+ * ...ctx.callbacks,
30
+ * CallExpression(node) {
31
+ * if (!ctx.isInComponentOrHook()) return
32
+ * // ... existing rule logic ...
33
+ * },
34
+ * }
35
+ * }
36
+ * ```
37
+ *
38
+ * The tracker also recognizes named arrow / function expressions assigned
39
+ * to `const X = (props) => …`, since the framework treats those as
40
+ * components too. Anonymous callbacks (e.g. `it('...', () => { … })`,
41
+ * `setTimeout(() => { … }, 0)`, `arr.map(x => x)`) never push depth — so
42
+ * test bodies, IIFEs, and inline callbacks are correctly seen as
43
+ * "outside any component" without needing a path-based skip.
44
+ */
45
+
46
+ import type { VisitorCallbacks } from '../types'
47
+
48
+ const COMPONENT_NAME = /^[A-Z]/
49
+ const HOOK_NAME = /^use[A-Z]/
50
+
51
+ export function isComponentOrHookName(name: string | null | undefined): boolean {
52
+ if (!name) return false
53
+ return COMPONENT_NAME.test(name) || HOOK_NAME.test(name)
54
+ }
55
+
56
+ export interface ComponentContextTracker {
57
+ /** True iff the current AST position is inside a function recognised as a component or hook. */
58
+ isInComponentOrHook(): boolean
59
+ /**
60
+ * Visitor callbacks that maintain the depth counter. Spread them into the
61
+ * rule's returned visitor first; per-node listeners after override only
62
+ * the keys the rule itself implements (FunctionDeclaration etc. rarely).
63
+ */
64
+ callbacks: VisitorCallbacks
65
+ }
66
+
67
+ export function createComponentContextTracker(): ComponentContextTracker {
68
+ let depth = 0
69
+
70
+ // For arrow / function expressions assigned to a `const X = (...) => …`,
71
+ // we can't read the binding name from the function node — and the oxc
72
+ // visitor doesn't pass `parent` to callbacks. Instead, hook the parent
73
+ // `VariableDeclarator` enter/exit: it visits BEFORE its `init` child
74
+ // (the function expression) and EXITS AFTER, so a depth bump tied to
75
+ // the declarator correctly brackets the function body.
76
+ function declaratorIsComponentOrHook(node: any): boolean {
77
+ if (node?.id?.type !== 'Identifier') return false
78
+ const init = node.init
79
+ if (
80
+ init?.type !== 'ArrowFunctionExpression' &&
81
+ init?.type !== 'FunctionExpression'
82
+ )
83
+ return false
84
+ return isComponentOrHookName(node.id.name)
85
+ }
86
+
87
+ return {
88
+ isInComponentOrHook: () => depth > 0,
89
+ callbacks: {
90
+ // function MyComp() {} / function useFoo() {}
91
+ FunctionDeclaration(node: any) {
92
+ if (isComponentOrHookName(node.id?.name)) depth++
93
+ },
94
+ 'FunctionDeclaration:exit'(node: any) {
95
+ if (isComponentOrHookName(node.id?.name)) depth--
96
+ },
97
+ // const MyComp = () => {} / const useFoo = function () {}
98
+ VariableDeclarator(node: any) {
99
+ if (declaratorIsComponentOrHook(node)) depth++
100
+ },
101
+ 'VariableDeclarator:exit'(node: any) {
102
+ if (declaratorIsComponentOrHook(node)) depth--
103
+ },
104
+ },
105
+ }
106
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Helper for rules that support path-based exemption via options.
3
+ *
4
+ * Rules that need to be "turn-off-able for specific directories" (e.g.
5
+ * a package that IS the foundation the rule recommends against using
6
+ * directly) don't hardcode the paths anymore — they read an
7
+ * `exemptPaths: string[]` option from the user's config:
8
+ *
9
+ * ```json
10
+ * // .pyreonlintrc.json
11
+ * {
12
+ * "rules": {
13
+ * "pyreon/no-window-in-ssr": [
14
+ * "error",
15
+ * { "exemptPaths": ["packages/core/runtime-dom/"] }
16
+ * ]
17
+ * }
18
+ * }
19
+ * ```
20
+ *
21
+ * Each entry is substring-matched against the file path (same convention
22
+ * the old hardcoded patterns used). Empty / missing → no exemptions,
23
+ * which is the correct default for a rule shipping to user apps.
24
+ */
25
+
26
+ import type { RuleContext } from '../types'
27
+
28
+ export function isPathExempt(ctx: RuleContext): boolean {
29
+ const options = ctx.getOptions()
30
+ const raw = options.exemptPaths
31
+ if (!Array.isArray(raw) || raw.length === 0) return false
32
+ const filePath = ctx.getFilePath()
33
+ for (const entry of raw) {
34
+ if (typeof entry === 'string' && entry.length > 0 && filePath.includes(entry)) {
35
+ return true
36
+ }
37
+ }
38
+ return false
39
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Universal file-path classifiers for lint rules.
3
+ *
4
+ * What belongs here:
5
+ * - Conventions that exist in every project the linter runs on
6
+ * (test files, example directories — the `*.test.*` convention
7
+ * is not Pyreon-specific).
8
+ *
9
+ * What does NOT belong here:
10
+ * - Monorepo-specific paths like `packages/core/runtime-dom/` —
11
+ * those are implementation knowledge of one particular codebase
12
+ * and have no meaning in a user's app. Exemptions for such paths
13
+ * belong in the consuming project's lint config via the
14
+ * `exemptPaths: string[]` rule option — see `utils/exempt-paths.ts`
15
+ * and the Pyreon monorepo's `.pyreonlintrc.json` at repo root for
16
+ * reference.
17
+ */
18
+
19
+ /**
20
+ * Matches files that are tests by convention. Universal — the
21
+ * `*.test.*` / `*.spec.*` / `/tests/` / `/__tests__/` conventions
22
+ * exist in every codebase this linter runs on, not just Pyreon.
23
+ */
24
+ export function isTestFile(filePath: string): boolean {
25
+ return (
26
+ filePath.includes('/tests/') ||
27
+ filePath.includes('/test/') ||
28
+ filePath.includes('/__tests__/') ||
29
+ filePath.includes('.test.') ||
30
+ filePath.includes('.spec.')
31
+ )
32
+ }
@@ -48,7 +48,10 @@ export const BROWSER_GLOBALS = new Set([
48
48
  'localStorage',
49
49
  'sessionStorage',
50
50
  'indexedDB',
51
- 'fetch',
51
+ // NOTE: `fetch` is intentionally OMITTED — it's a universal global in
52
+ // Node 18+, Bun, Deno, browsers, and edge runtimes. Code using it isn't
53
+ // browser-specific. (`XMLHttpRequest`/`WebSocket` are still here because
54
+ // they're DOM-only and Node code uses different libraries.)
52
55
  'XMLHttpRequest',
53
56
  'WebSocket',
54
57
  'requestAnimationFrame',
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Validate a rule's user-configured options against its declared schema.
3
+ *
4
+ * Called once per (rule, options) pair at config-merge time — NOT per
5
+ * lint'd file. Separates config problems from source-code problems:
6
+ * wrong-typed options aren't a file diagnostic, they're a setup error
7
+ * for the tool.
8
+ *
9
+ * Return shape:
10
+ * - `errors`: hard failures. Runner disables the rule for this run.
11
+ * - `warnings`: unknown option keys, typos, etc. Runner keeps the
12
+ * rule enabled but prints the warning so the user knows.
13
+ *
14
+ * Rules without `meta.schema` accept any options (no validation).
15
+ */
16
+
17
+ import type { OptionType, Rule, RuleOptions, RuleOptionsSchema } from '../types'
18
+
19
+ export interface ValidationResult {
20
+ errors: string[]
21
+ warnings: string[]
22
+ }
23
+
24
+ export function validateRuleOptions(rule: Rule, options: RuleOptions): ValidationResult {
25
+ const schema = rule.meta.schema
26
+ const errors: string[] = []
27
+ const warnings: string[] = []
28
+ if (!schema) return { errors, warnings }
29
+
30
+ for (const [key, value] of Object.entries(options)) {
31
+ const expected = schema[key]
32
+ if (expected === undefined) {
33
+ warnings.push(
34
+ `[${rule.meta.id}] unknown option "${key}" — allowed options: ${Object.keys(schema).join(', ') || '(none)'}`,
35
+ )
36
+ continue
37
+ }
38
+ if (!matchesType(value, expected)) {
39
+ errors.push(
40
+ `[${rule.meta.id}] option "${key}" must be ${expected}, got ${describe(value)}`,
41
+ )
42
+ }
43
+ }
44
+
45
+ return { errors, warnings }
46
+ }
47
+
48
+ function matchesType(value: unknown, type: OptionType): boolean {
49
+ switch (type) {
50
+ case 'string':
51
+ return typeof value === 'string'
52
+ case 'string[]':
53
+ return Array.isArray(value) && value.every((x) => typeof x === 'string')
54
+ case 'number':
55
+ return typeof value === 'number' && Number.isFinite(value)
56
+ case 'boolean':
57
+ return typeof value === 'boolean'
58
+ }
59
+ }
60
+
61
+ function describe(value: unknown): string {
62
+ if (value === null) return 'null'
63
+ if (Array.isArray(value)) {
64
+ const types = new Set(value.map((x) => (x === null ? 'null' : typeof x)))
65
+ return `Array<${[...types].join(' | ') || 'empty'}>`
66
+ }
67
+ return typeof value
68
+ }
package/src/watcher.ts CHANGED
@@ -34,6 +34,7 @@ export function watchAndLint(options: LintOptions & { format: string }): void {
34
34
  const config = getPreset(preset)
35
35
 
36
36
  applyOverrides(config, options.ruleOverrides)
37
+ applyOptionsOverrides(config, options.ruleOptionsOverrides)
37
38
 
38
39
  const cwd = resolve('.')
39
40
  const isIgnored = createIgnoreFilter(cwd, options.ignore)
@@ -81,6 +82,21 @@ function applyOverrides(
81
82
  }
82
83
  }
83
84
 
85
+ function applyOptionsOverrides(
86
+ config: LintConfig,
87
+ overrides?: Record<string, Record<string, unknown>> | undefined,
88
+ ): void {
89
+ if (!overrides) return
90
+ for (const [id, opts] of Object.entries(overrides)) {
91
+ const existing = config.rules[id]
92
+ const [severity, current]: [Severity, Record<string, unknown>] = Array.isArray(existing)
93
+ ? [existing[0] as Severity, (existing[1] ?? {}) as Record<string, unknown>]
94
+ : [(existing ?? 'off') as Severity, {}]
95
+ if (severity === 'off') continue
96
+ config.rules[id] = [severity, { ...current, ...opts }] as const
97
+ }
98
+ }
99
+
84
100
  function relintFile(filePath: string, config: LintConfig, cache: AstCache, format: string): void {
85
101
  let source: string
86
102
  try {
@@ -98,6 +114,7 @@ function relintFile(filePath: string, config: LintConfig, cache: AstCache, forma
98
114
  totalErrors: 0,
99
115
  totalWarnings: 0,
100
116
  totalInfos: 0,
117
+ configDiagnostics: [],
101
118
  }
102
119
 
103
120
  for (const d of fileResult.diagnostics) {