@pyreon/lint 0.11.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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +214 -0
  3. package/lib/analysis/index.js.html +5406 -0
  4. package/lib/index.js +2955 -0
  5. package/lib/index.js.map +1 -0
  6. package/lib/types/index.d.ts +260 -0
  7. package/lib/types/index.d.ts.map +1 -0
  8. package/package.json +56 -0
  9. package/src/cache.ts +51 -0
  10. package/src/cli.ts +199 -0
  11. package/src/config/ignore.ts +159 -0
  12. package/src/config/loader.ts +72 -0
  13. package/src/config/presets.ts +62 -0
  14. package/src/index.ts +40 -0
  15. package/src/lint.ts +226 -0
  16. package/src/reporter.ts +85 -0
  17. package/src/rules/accessibility/dialog-a11y.ts +32 -0
  18. package/src/rules/accessibility/overlay-a11y.ts +33 -0
  19. package/src/rules/accessibility/toast-a11y.ts +38 -0
  20. package/src/rules/architecture/dev-guard-warnings.ts +57 -0
  21. package/src/rules/architecture/no-circular-import.ts +59 -0
  22. package/src/rules/architecture/no-cross-layer-import.ts +75 -0
  23. package/src/rules/architecture/no-deep-import.ts +32 -0
  24. package/src/rules/architecture/no-error-without-prefix.ts +75 -0
  25. package/src/rules/form/no-submit-without-validation.ts +45 -0
  26. package/src/rules/form/no-unregistered-field.ts +45 -0
  27. package/src/rules/form/prefer-field-array.ts +41 -0
  28. package/src/rules/hooks/no-raw-addeventlistener.ts +28 -0
  29. package/src/rules/hooks/no-raw-localstorage.ts +35 -0
  30. package/src/rules/hooks/no-raw-setinterval.ts +41 -0
  31. package/src/rules/index.ts +208 -0
  32. package/src/rules/jsx/no-and-conditional.ts +32 -0
  33. package/src/rules/jsx/no-children-access.ts +44 -0
  34. package/src/rules/jsx/no-classname.ts +27 -0
  35. package/src/rules/jsx/no-htmlfor.ts +27 -0
  36. package/src/rules/jsx/no-index-as-by.ts +70 -0
  37. package/src/rules/jsx/no-map-in-jsx.ts +43 -0
  38. package/src/rules/jsx/no-missing-for-by.ts +27 -0
  39. package/src/rules/jsx/no-onchange.ts +46 -0
  40. package/src/rules/jsx/no-props-destructure.ts +64 -0
  41. package/src/rules/jsx/no-ternary-conditional.ts +32 -0
  42. package/src/rules/jsx/use-by-not-key.ts +33 -0
  43. package/src/rules/lifecycle/no-dom-in-setup.ts +53 -0
  44. package/src/rules/lifecycle/no-effect-in-mount.ts +36 -0
  45. package/src/rules/lifecycle/no-missing-cleanup.ts +80 -0
  46. package/src/rules/lifecycle/no-mount-in-effect.ts +35 -0
  47. package/src/rules/performance/no-eager-import.ts +28 -0
  48. package/src/rules/performance/no-effect-in-for.ts +41 -0
  49. package/src/rules/performance/no-large-for-without-by.ts +28 -0
  50. package/src/rules/performance/prefer-show-over-display.ts +47 -0
  51. package/src/rules/reactivity/no-bare-signal-in-jsx.ts +56 -0
  52. package/src/rules/reactivity/no-effect-assignment.ts +65 -0
  53. package/src/rules/reactivity/no-nested-effect.ts +33 -0
  54. package/src/rules/reactivity/no-peek-in-tracked.ts +35 -0
  55. package/src/rules/reactivity/no-signal-in-loop.ts +59 -0
  56. package/src/rules/reactivity/no-signal-leak.ts +58 -0
  57. package/src/rules/reactivity/no-unbatched-updates.ts +77 -0
  58. package/src/rules/reactivity/prefer-computed.ts +56 -0
  59. package/src/rules/router/index.ts +4 -0
  60. package/src/rules/router/no-href-navigation.ts +51 -0
  61. package/src/rules/router/no-imperative-navigate-in-render.ts +83 -0
  62. package/src/rules/router/no-missing-fallback.ts +87 -0
  63. package/src/rules/router/prefer-use-is-active.ts +45 -0
  64. package/src/rules/ssr/no-mismatch-risk.ts +47 -0
  65. package/src/rules/ssr/no-window-in-ssr.ts +76 -0
  66. package/src/rules/ssr/prefer-request-context.ts +56 -0
  67. package/src/rules/store/no-duplicate-store-id.ts +43 -0
  68. package/src/rules/store/no-mutate-store-state.ts +37 -0
  69. package/src/rules/store/no-store-outside-provider.ts +59 -0
  70. package/src/rules/styling/no-dynamic-styled.ts +60 -0
  71. package/src/rules/styling/no-inline-style-object.ts +30 -0
  72. package/src/rules/styling/no-theme-outside-provider.ts +45 -0
  73. package/src/rules/styling/prefer-cx.ts +44 -0
  74. package/src/runner.ts +170 -0
  75. package/src/tests/runner.test.ts +1043 -0
  76. package/src/types.ts +125 -0
  77. package/src/utils/ast.ts +192 -0
  78. package/src/utils/imports.ts +122 -0
  79. package/src/utils/index.ts +39 -0
  80. package/src/utils/source.ts +36 -0
  81. package/src/watcher.ts +118 -0
package/src/types.ts ADDED
@@ -0,0 +1,125 @@
1
+ // ── Severity & Diagnostics ──────────────────────────────────────────────────
2
+
3
+ export type Severity = "error" | "warn" | "info" | "off"
4
+
5
+ export interface SourceLocation {
6
+ line: number
7
+ column: number
8
+ }
9
+
10
+ export interface Span {
11
+ start: number
12
+ end: number
13
+ }
14
+
15
+ export interface Fix {
16
+ span: Span
17
+ replacement: string
18
+ }
19
+
20
+ export interface Diagnostic {
21
+ ruleId: string
22
+ severity: Severity
23
+ message: string
24
+ span: Span
25
+ loc: SourceLocation
26
+ fix?: Fix | undefined
27
+ }
28
+
29
+ // ── Rule Metadata ───────────────────────────────────────────────────────────
30
+
31
+ export type RuleCategory =
32
+ | "reactivity"
33
+ | "jsx"
34
+ | "lifecycle"
35
+ | "performance"
36
+ | "ssr"
37
+ | "architecture"
38
+ | "store"
39
+ | "form"
40
+ | "styling"
41
+ | "hooks"
42
+ | "accessibility"
43
+ | "router"
44
+
45
+ export interface RuleMeta {
46
+ id: string
47
+ category: RuleCategory
48
+ description: string
49
+ severity: Severity
50
+ fixable: boolean
51
+ }
52
+
53
+ // ── Rule Context & Visitor ──────────────────────────────────────────────────
54
+
55
+ export interface RuleContext {
56
+ report(diagnostic: Omit<Diagnostic, "ruleId" | "severity" | "loc">): void
57
+ getSourceText(): string
58
+ getFilePath(): string
59
+ }
60
+
61
+ export type VisitorCallback = (node: any, parent?: any) => void
62
+
63
+ export interface VisitorCallbacks {
64
+ [nodeType: string]: VisitorCallback
65
+ }
66
+
67
+ // ── Rule Definition ─────────────────────────────────────────────────────────
68
+
69
+ export interface Rule {
70
+ meta: RuleMeta
71
+ create(context: RuleContext): VisitorCallbacks
72
+ }
73
+
74
+ // ── Configuration ───────────────────────────────────────────────────────────
75
+
76
+ export interface LintConfig {
77
+ rules: Record<string, Severity>
78
+ include?: string[] | undefined
79
+ exclude?: string[] | undefined
80
+ }
81
+
82
+ export interface LintConfigFile {
83
+ preset?: PresetName | undefined
84
+ rules?: Record<string, Severity> | undefined
85
+ include?: string[] | undefined
86
+ exclude?: string[] | undefined
87
+ }
88
+
89
+ export type PresetName = "recommended" | "strict" | "app" | "lib"
90
+
91
+ // ── Results ─────────────────────────────────────────────────────────────────
92
+
93
+ export interface LintFileResult {
94
+ filePath: string
95
+ diagnostics: Diagnostic[]
96
+ fixedSource?: string | undefined
97
+ }
98
+
99
+ export interface LintResult {
100
+ files: LintFileResult[]
101
+ totalErrors: number
102
+ totalWarnings: number
103
+ totalInfos: number
104
+ }
105
+
106
+ // ── Lint Options ────────────────────────────────────────────────────────────
107
+
108
+ export interface LintOptions {
109
+ paths: string[]
110
+ preset?: PresetName | undefined
111
+ fix?: boolean | undefined
112
+ quiet?: boolean | undefined
113
+ ruleOverrides?: Record<string, Severity> | undefined
114
+ config?: string | undefined
115
+ ignore?: string | undefined
116
+ }
117
+
118
+ // ── Import Info ─────────────────────────────────────────────────────────────
119
+
120
+ export interface ImportInfo {
121
+ source: string
122
+ specifiers: Array<{ imported: string; local: string }>
123
+ isDefault: boolean
124
+ isNamespace: boolean
125
+ }
@@ -0,0 +1,192 @@
1
+ import type { Span } from "../types"
2
+ import { BROWSER_GLOBALS } from "./imports"
3
+
4
+ /** Check if a node is a call expression to a specific function name. */
5
+ export function isCallTo(node: any, name: string): boolean {
6
+ return (
7
+ node.type === "CallExpression" &&
8
+ node.callee?.type === "Identifier" &&
9
+ node.callee.name === name
10
+ )
11
+ }
12
+
13
+ /** Check if a node is a call expression to any of the given function names. */
14
+ export function isCallToAny(node: any, names: Set<string>): boolean {
15
+ return (
16
+ node.type === "CallExpression" &&
17
+ node.callee?.type === "Identifier" &&
18
+ names.has(node.callee.name)
19
+ )
20
+ }
21
+
22
+ /** Check if a node is a member call like `obj.method()`. */
23
+ export function isMemberCallTo(node: any, objectName: string, methodName: string): boolean {
24
+ return (
25
+ node.type === "CallExpression" &&
26
+ node.callee?.type === "MemberExpression" &&
27
+ node.callee.object?.type === "Identifier" &&
28
+ node.callee.object.name === objectName &&
29
+ node.callee.property?.type === "Identifier" &&
30
+ node.callee.property.name === methodName
31
+ )
32
+ }
33
+
34
+ /** Check if a node is a JSX element (opening or self-closing). */
35
+ export function isJSXElement(node: any): boolean {
36
+ return node.type === "JSXElement" || node.type === "JSXFragment"
37
+ }
38
+
39
+ /** Get the tag name of a JSX element. */
40
+ export function getJSXTagName(node: any): string | null {
41
+ if (node.type === "JSXElement") {
42
+ const opening = node.openingElement
43
+ if (!opening) return null
44
+ const name = opening.name
45
+ if (name?.type === "JSXIdentifier") return name.name
46
+ if (name?.type === "JSXMemberExpression") {
47
+ return `${name.object?.name ?? ""}.${name.property?.name ?? ""}`
48
+ }
49
+ }
50
+ return null
51
+ }
52
+
53
+ /** Get a JSX attribute by name from an opening element. */
54
+ export function getJSXAttribute(openingElement: any, attrName: string): any | null {
55
+ const attrs = openingElement.attributes ?? []
56
+ for (const attr of attrs) {
57
+ if (
58
+ attr.type === "JSXAttribute" &&
59
+ attr.name?.type === "JSXIdentifier" &&
60
+ attr.name.name === attrName
61
+ ) {
62
+ return attr
63
+ }
64
+ }
65
+ return null
66
+ }
67
+
68
+ /** Check if a JSX opening element has an attribute. */
69
+ export function hasJSXAttribute(openingElement: any, attrName: string): boolean {
70
+ return getJSXAttribute(openingElement, attrName) !== null
71
+ }
72
+
73
+ /** Check if a node is inside a function (arrow or regular). */
74
+ export function isInsideFunction(ancestors: any[]): boolean {
75
+ return ancestors.some(
76
+ (a) =>
77
+ a.type === "FunctionDeclaration" ||
78
+ a.type === "FunctionExpression" ||
79
+ a.type === "ArrowFunctionExpression",
80
+ )
81
+ }
82
+
83
+ /** Check if a node is inside JSX. */
84
+ export function isInsideJSX(ancestors: any[]): boolean {
85
+ return ancestors.some((a) => a.type === "JSXElement" || a.type === "JSXFragment")
86
+ }
87
+
88
+ /** Check if a node is an array .map() call. */
89
+ export function isArrayMapCall(node: any): boolean {
90
+ return (
91
+ node.type === "CallExpression" &&
92
+ node.callee?.type === "MemberExpression" &&
93
+ node.callee.property?.type === "Identifier" &&
94
+ node.callee.property.name === "map"
95
+ )
96
+ }
97
+
98
+ /** Check if a node is a function expression or arrow function. */
99
+ export function isFunction(node: any): boolean {
100
+ return (
101
+ node.type === "FunctionDeclaration" ||
102
+ node.type === "FunctionExpression" ||
103
+ node.type === "ArrowFunctionExpression"
104
+ )
105
+ }
106
+
107
+ /** Check if a node is a destructuring pattern. */
108
+ export function isDestructuring(node: any): boolean {
109
+ return node.type === "ObjectPattern" || node.type === "ArrayPattern"
110
+ }
111
+
112
+ /** Check if a node is a ternary with JSX in either branch. */
113
+ export function isTernaryWithJSX(node: any): boolean {
114
+ if (node.type !== "ConditionalExpression") return false
115
+ return containsJSX(node.consequent) || containsJSX(node.alternate)
116
+ }
117
+
118
+ /** Check if a node contains JSX anywhere. */
119
+ function containsJSX(node: any): boolean {
120
+ if (!node) return false
121
+ if (node.type === "JSXElement" || node.type === "JSXFragment") return true
122
+ if (node.type === "ParenthesizedExpression") return containsJSX(node.expression)
123
+ return false
124
+ }
125
+
126
+ /** Check if a JSX element has JSX children. */
127
+ export function hasJSXChild(node: any): boolean {
128
+ if (node.type !== "JSXElement") return false
129
+ return (node.children ?? []).some((c: any) => c.type === "JSXElement" || c.type === "JSXFragment")
130
+ }
131
+
132
+ /** Check if a node is a logical AND with JSX. */
133
+ export function isLogicalAndWithJSX(node: any): boolean {
134
+ if (node.type !== "LogicalExpression" || node.operator !== "&&") return false
135
+ return containsJSX(node.right)
136
+ }
137
+
138
+ /** Check if a node is a .peek() call. */
139
+ export function isPeekCall(node: any): boolean {
140
+ return (
141
+ node.type === "CallExpression" &&
142
+ node.callee?.type === "MemberExpression" &&
143
+ node.callee.property?.type === "Identifier" &&
144
+ node.callee.property.name === "peek"
145
+ )
146
+ }
147
+
148
+ /** Check if a node is a .set() call. */
149
+ export function isSetCall(node: any): boolean {
150
+ return (
151
+ node.type === "CallExpression" &&
152
+ node.callee?.type === "MemberExpression" &&
153
+ node.callee.property?.type === "Identifier" &&
154
+ node.callee.property.name === "set"
155
+ )
156
+ }
157
+
158
+ /** Check if a node references a browser global. */
159
+ export function isBrowserGlobal(node: any): boolean {
160
+ return node.type === "Identifier" && BROWSER_GLOBALS.has(node.name)
161
+ }
162
+
163
+ /** Get the span (byte offsets) of a node. */
164
+ export function getSpan(node: any): Span {
165
+ return { start: node.start as number, end: node.end as number }
166
+ }
167
+
168
+ /** Check if a node is inside a `if (__DEV__)` guard. */
169
+ export function isInsideDevGuard(ancestors: any[]): boolean {
170
+ return ancestors.some(
171
+ (a) => a.type === "IfStatement" && a.test?.type === "Identifier" && a.test.name === "__DEV__",
172
+ )
173
+ }
174
+
175
+ /** Check if a node is inside an onMount callback. */
176
+ export function isInsideOnMount(ancestors: any[]): boolean {
177
+ return ancestors.some(
178
+ (a) =>
179
+ a.type === "CallExpression" && a.callee?.type === "Identifier" && a.callee.name === "onMount",
180
+ )
181
+ }
182
+
183
+ /** Check if a node is inside a typeof guard (e.g., `typeof window !== "undefined"`). */
184
+ export function isInsideTypeofGuard(ancestors: any[]): boolean {
185
+ return ancestors.some(
186
+ (a) =>
187
+ a.type === "IfStatement" &&
188
+ a.test?.type === "BinaryExpression" &&
189
+ a.test.left?.type === "UnaryExpression" &&
190
+ a.test.left.operator === "typeof",
191
+ )
192
+ }
@@ -0,0 +1,122 @@
1
+ import type { ImportInfo } from "../types"
2
+
3
+ export type { ImportInfo }
4
+
5
+ // ── Constants ───────────────────────────────────────────────────────────────
6
+
7
+ export const PYREON_PREFIX = "@pyreon/"
8
+
9
+ export const REACTIVITY_APIS = new Set([
10
+ "signal",
11
+ "computed",
12
+ "effect",
13
+ "batch",
14
+ "onCleanup",
15
+ "createSelector",
16
+ "createStore",
17
+ "untrack",
18
+ ])
19
+
20
+ export const LIFECYCLE_APIS = new Set(["onMount", "onUnmount"])
21
+
22
+ export const CONTEXT_APIS = new Set(["createContext", "provide", "pushContext", "popContext"])
23
+
24
+ export const JSX_COMPONENTS = new Set([
25
+ "For",
26
+ "Show",
27
+ "Switch",
28
+ "Match",
29
+ "Dynamic",
30
+ "ErrorBoundary",
31
+ "Suspense",
32
+ "Portal",
33
+ ])
34
+
35
+ export const HEAVY_PACKAGES = new Set([
36
+ "@pyreon/charts",
37
+ "@pyreon/code",
38
+ "@pyreon/document",
39
+ "@pyreon/flow",
40
+ ])
41
+
42
+ export const BROWSER_GLOBALS = new Set([
43
+ "window",
44
+ "document",
45
+ "navigator",
46
+ "location",
47
+ "history",
48
+ "localStorage",
49
+ "sessionStorage",
50
+ "indexedDB",
51
+ "fetch",
52
+ "XMLHttpRequest",
53
+ "WebSocket",
54
+ "requestAnimationFrame",
55
+ "cancelAnimationFrame",
56
+ "IntersectionObserver",
57
+ "MutationObserver",
58
+ "ResizeObserver",
59
+ "matchMedia",
60
+ "getComputedStyle",
61
+ "addEventListener",
62
+ "removeEventListener",
63
+ ])
64
+
65
+ // ── Functions ───────────────────────────────────────────────────────────────
66
+
67
+ export function isPyreonImport(source: string): boolean {
68
+ return source.startsWith(PYREON_PREFIX)
69
+ }
70
+
71
+ export function isPyreonPackage(source: string): boolean {
72
+ return source.startsWith(PYREON_PREFIX)
73
+ }
74
+
75
+ export function extractImportInfo(node: any): ImportInfo | null {
76
+ if (node.type !== "ImportDeclaration") return null
77
+
78
+ const source = node.source?.value as string
79
+ if (!source) return null
80
+
81
+ const specifiers: ImportInfo["specifiers"] = []
82
+ let isDefault = false
83
+ let isNamespace = false
84
+
85
+ for (const spec of node.specifiers ?? []) {
86
+ if (spec.type === "ImportDefaultSpecifier") {
87
+ isDefault = true
88
+ specifiers.push({ imported: "default", local: spec.local?.name ?? "" })
89
+ } else if (spec.type === "ImportNamespaceSpecifier") {
90
+ isNamespace = true
91
+ specifiers.push({ imported: "*", local: spec.local?.name ?? "" })
92
+ } else if (spec.type === "ImportSpecifier") {
93
+ const imported =
94
+ spec.imported?.type === "Identifier" ? spec.imported.name : (spec.imported?.value ?? "")
95
+ specifiers.push({ imported, local: spec.local?.name ?? "" })
96
+ }
97
+ }
98
+
99
+ return { source, specifiers, isDefault, isNamespace }
100
+ }
101
+
102
+ export function importsName(imports: ImportInfo[], name: string, fromPackage?: string): boolean {
103
+ return imports.some(
104
+ (imp) =>
105
+ (!fromPackage || imp.source === fromPackage) &&
106
+ imp.specifiers.some((s) => s.imported === name),
107
+ )
108
+ }
109
+
110
+ export function getLocalName(
111
+ imports: ImportInfo[],
112
+ name: string,
113
+ fromPackage?: string,
114
+ ): string | null {
115
+ for (const imp of imports) {
116
+ if (fromPackage && imp.source !== fromPackage) continue
117
+ for (const s of imp.specifiers) {
118
+ if (s.imported === name) return s.local
119
+ }
120
+ }
121
+ return null
122
+ }
@@ -0,0 +1,39 @@
1
+ export {
2
+ getJSXAttribute,
3
+ getJSXTagName,
4
+ getSpan,
5
+ hasJSXAttribute,
6
+ hasJSXChild,
7
+ isArrayMapCall,
8
+ isBrowserGlobal,
9
+ isCallTo,
10
+ isCallToAny,
11
+ isDestructuring,
12
+ isFunction,
13
+ isInsideDevGuard,
14
+ isInsideFunction,
15
+ isInsideJSX,
16
+ isInsideOnMount,
17
+ isInsideTypeofGuard,
18
+ isJSXElement,
19
+ isLogicalAndWithJSX,
20
+ isMemberCallTo,
21
+ isPeekCall,
22
+ isSetCall,
23
+ isTernaryWithJSX,
24
+ } from "./ast"
25
+ export {
26
+ BROWSER_GLOBALS,
27
+ CONTEXT_APIS,
28
+ extractImportInfo,
29
+ getLocalName,
30
+ HEAVY_PACKAGES,
31
+ importsName,
32
+ isPyreonImport,
33
+ isPyreonPackage,
34
+ JSX_COMPONENTS,
35
+ LIFECYCLE_APIS,
36
+ PYREON_PREFIX,
37
+ REACTIVITY_APIS,
38
+ } from "./imports"
39
+ export { LineIndex } from "./source"
@@ -0,0 +1,36 @@
1
+ import type { SourceLocation } from "../types"
2
+
3
+ /**
4
+ * Fast offset→line/column conversion using binary search over precomputed line starts.
5
+ */
6
+ export class LineIndex {
7
+ private lineStarts: number[]
8
+
9
+ constructor(sourceText: string) {
10
+ this.lineStarts = [0]
11
+ for (let i = 0; i < sourceText.length; i++) {
12
+ if (sourceText[i] === "\n") {
13
+ this.lineStarts.push(i + 1)
14
+ }
15
+ }
16
+ }
17
+
18
+ /** Convert a byte offset to a 1-based line and 0-based column. */
19
+ locate(offset: number): SourceLocation {
20
+ let lo = 0
21
+ let hi = this.lineStarts.length - 1
22
+
23
+ while (lo <= hi) {
24
+ const mid = (lo + hi) >>> 1
25
+ if ((this.lineStarts[mid] as number) <= offset) {
26
+ lo = mid + 1
27
+ } else {
28
+ hi = mid - 1
29
+ }
30
+ }
31
+
32
+ const line = lo // 1-based (lo points one past the found index)
33
+ const column = offset - (this.lineStarts[line - 1] as number)
34
+ return { line, column }
35
+ }
36
+ }
package/src/watcher.ts ADDED
@@ -0,0 +1,118 @@
1
+ import { readFileSync, watch } from "node:fs"
2
+ import { resolve } from "node:path"
3
+ import { AstCache } from "./cache"
4
+ import { createIgnoreFilter } from "./config/ignore"
5
+ import { getPreset } from "./config/presets"
6
+ import { formatCompact, formatJSON, formatText } from "./reporter"
7
+ import { allRules } from "./rules/index"
8
+ import { lintFile } from "./runner"
9
+ import type { LintConfig, LintOptions, LintResult, Severity } from "./types"
10
+
11
+ const JS_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mts", ".mjs"])
12
+
13
+ function hasJsExtension(filePath: string): boolean {
14
+ const ext = filePath.slice(filePath.lastIndexOf("."))
15
+ return JS_EXTENSIONS.has(ext)
16
+ }
17
+
18
+ function formatOutput(result: LintResult, format: string): string {
19
+ if (format === "json") return formatJSON(result)
20
+ if (format === "compact") return formatCompact(result)
21
+ return formatText(result)
22
+ }
23
+
24
+ /**
25
+ * Watch directories and re-lint changed files.
26
+ *
27
+ * Uses `fs.watch` (recursive) with 100ms debounce.
28
+ * Caches ASTs for unchanged files.
29
+ *
30
+ * @example
31
+ * ```ts
32
+ * import { watchAndLint } from "@pyreon/lint"
33
+ *
34
+ * watchAndLint({ paths: ["src/"], preset: "recommended", format: "text" })
35
+ * ```
36
+ */
37
+ export function watchAndLint(options: LintOptions & { format: string }): void {
38
+ const cache = new AstCache()
39
+ const preset = options.preset ?? "recommended"
40
+ const config = getPreset(preset)
41
+
42
+ applyOverrides(config, options.ruleOverrides)
43
+
44
+ const cwd = resolve(".")
45
+ const isIgnored = createIgnoreFilter(cwd, options.ignore)
46
+
47
+ // Debounce map: filePath -> timeout
48
+ const pending = new Map<string, ReturnType<typeof setTimeout>>()
49
+
50
+ // eslint-disable-next-line no-console
51
+ console.log(`\x1b[2m[pyreon-lint] Watching for changes...\x1b[0m\n`)
52
+
53
+ for (const p of options.paths) {
54
+ const dir = resolve(p)
55
+ try {
56
+ watch(dir, { recursive: true }, (_event, filename) => {
57
+ if (!filename) return
58
+ const filePath = resolve(dir, filename)
59
+
60
+ if (!hasJsExtension(filePath) || isIgnored(filePath)) return
61
+
62
+ // Debounce: clear existing timeout for this file
63
+ const existing = pending.get(filePath)
64
+ if (existing) clearTimeout(existing)
65
+
66
+ pending.set(
67
+ filePath,
68
+ setTimeout(() => {
69
+ pending.delete(filePath)
70
+ relintFile(filePath, config, cache, options.format)
71
+ }, 100),
72
+ )
73
+ })
74
+ } catch {
75
+ console.error(`[pyreon-lint] Could not watch: ${dir}`)
76
+ }
77
+ }
78
+ }
79
+
80
+ function applyOverrides(
81
+ config: LintConfig,
82
+ overrides?: Record<string, Severity> | undefined,
83
+ ): void {
84
+ if (!overrides) return
85
+ for (const [id, severity] of Object.entries(overrides)) {
86
+ config.rules[id] = severity
87
+ }
88
+ }
89
+
90
+ function relintFile(filePath: string, config: LintConfig, cache: AstCache, format: string): void {
91
+ let source: string
92
+ try {
93
+ source = readFileSync(filePath, "utf-8")
94
+ } catch {
95
+ return
96
+ }
97
+
98
+ const fileResult = lintFile(filePath, source, allRules, config, cache)
99
+
100
+ if (fileResult.diagnostics.length === 0) return
101
+
102
+ const result: LintResult = {
103
+ files: [fileResult],
104
+ totalErrors: 0,
105
+ totalWarnings: 0,
106
+ totalInfos: 0,
107
+ }
108
+
109
+ for (const d of fileResult.diagnostics) {
110
+ if (d.severity === "error") result.totalErrors++
111
+ else if (d.severity === "warn") result.totalWarnings++
112
+ else if (d.severity === "info") result.totalInfos++
113
+ }
114
+
115
+ // Clear screen and print
116
+ process.stdout.write("\x1b[2J\x1b[H")
117
+ console.log(formatOutput(result, format))
118
+ }