@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
@@ -0,0 +1,159 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import { join, relative, resolve } from "node:path"
3
+
4
+ /**
5
+ * Create a filter function that returns true if a file path should be ignored.
6
+ *
7
+ * Loads patterns from `.pyreonlintignore` and `.gitignore` in the given directory.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * import { createIgnoreFilter } from "@pyreon/lint"
12
+ *
13
+ * const isIgnored = createIgnoreFilter(process.cwd())
14
+ * if (!isIgnored("src/app.tsx")) lintFile(...)
15
+ * ```
16
+ */
17
+ export function createIgnoreFilter(
18
+ cwd: string,
19
+ extraIgnore?: string | undefined,
20
+ ): (filePath: string) => boolean {
21
+ const patterns: string[] = []
22
+ const resolvedCwd = resolve(cwd)
23
+
24
+ // Load .pyreonlintignore
25
+ loadPatternsFromFile(join(resolvedCwd, ".pyreonlintignore"), patterns)
26
+
27
+ // Load .gitignore
28
+ loadPatternsFromFile(join(resolvedCwd, ".gitignore"), patterns)
29
+
30
+ // Load extra ignore file if provided
31
+ if (extraIgnore) {
32
+ loadPatternsFromFile(resolve(extraIgnore), patterns)
33
+ }
34
+
35
+ // Compile patterns into matchers
36
+ const matchers = patterns.map((p) => compileMatcher(p))
37
+
38
+ return (filePath: string): boolean => {
39
+ const rel = relative(resolvedCwd, resolve(filePath))
40
+ // Normalize to forward slashes
41
+ const normalized = rel.replace(/\\/g, "/")
42
+
43
+ for (const matcher of matchers) {
44
+ if (matcher(normalized)) return true
45
+ }
46
+ return false
47
+ }
48
+ }
49
+
50
+ function loadPatternsFromFile(filePath: string, patterns: string[]): void {
51
+ if (!existsSync(filePath)) return
52
+ try {
53
+ const content = readFileSync(filePath, "utf-8")
54
+ for (const line of content.split("\n")) {
55
+ const trimmed = line.trim()
56
+ // Skip empty lines and comments
57
+ if (!trimmed || trimmed.startsWith("#")) continue
58
+ patterns.push(trimmed)
59
+ }
60
+ } catch {
61
+ // Ignore read errors
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Compile a gitignore-style pattern into a matcher function.
67
+ * Supports: `*` (any non-slash chars), `**` (any path segment), `?` (single char),
68
+ * leading `/` (root-anchored), trailing `/` (directory only).
69
+ */
70
+ function compileMatcher(pattern: string): (path: string) => boolean {
71
+ let p = pattern
72
+ let anchored = false
73
+
74
+ // Negated patterns (not supported — just skip them)
75
+ if (p.startsWith("!")) {
76
+ return () => false
77
+ }
78
+
79
+ // Leading slash means anchored to root
80
+ if (p.startsWith("/")) {
81
+ anchored = true
82
+ p = p.slice(1)
83
+ }
84
+
85
+ // Trailing slash means only match directories (we treat all paths as files, so strip it
86
+ // and match as a prefix)
87
+ let dirOnly = false
88
+ if (p.endsWith("/")) {
89
+ dirOnly = true
90
+ p = p.slice(0, -1)
91
+ }
92
+
93
+ const regex = globToRegex(p)
94
+
95
+ return (path: string): boolean => {
96
+ if (dirOnly) {
97
+ // Match as prefix: the pattern should match a directory portion
98
+ if (anchored) {
99
+ return regex.test(path) || path.startsWith(`${p}/`) || path === p
100
+ }
101
+ // Unanchored directory pattern — match anywhere in path
102
+ return regex.test(path) || path.includes(`/${p}/`) || path.startsWith(`${p}/`) || path === p
103
+ }
104
+
105
+ if (anchored) {
106
+ return regex.test(path)
107
+ }
108
+
109
+ // Unanchored pattern — try matching the full path, or just the basename
110
+ if (regex.test(path)) return true
111
+
112
+ // Also try matching against just the filename
113
+ const lastSlash = path.lastIndexOf("/")
114
+ if (lastSlash !== -1) {
115
+ const basename = path.slice(lastSlash + 1)
116
+ return regex.test(basename)
117
+ }
118
+
119
+ return false
120
+ }
121
+ }
122
+
123
+ const GLOB_CHAR_MAP: Record<string, string> = {
124
+ "?": "[^/]",
125
+ ".": "\\.",
126
+ "/": "/",
127
+ }
128
+
129
+ function handleStar(glob: string, pos: number): { pattern: string; advance: number } {
130
+ if (glob[pos + 1] === "*") {
131
+ if (glob[pos + 2] === "/") return { pattern: "(?:.*/)?", advance: 3 }
132
+ return { pattern: ".*", advance: 2 }
133
+ }
134
+ return { pattern: "[^/]*", advance: 1 }
135
+ }
136
+
137
+ function globToRegex(glob: string): RegExp {
138
+ let result = "^"
139
+ let i = 0
140
+
141
+ while (i < glob.length) {
142
+ const ch = glob[i] as string
143
+ if (ch === "*") {
144
+ const star = handleStar(glob, i)
145
+ result += star.pattern
146
+ i += star.advance
147
+ } else {
148
+ result += GLOB_CHAR_MAP[ch] ?? escapeRegex(ch)
149
+ i++
150
+ }
151
+ }
152
+
153
+ result += "$"
154
+ return new RegExp(result)
155
+ }
156
+
157
+ function escapeRegex(str: string): string {
158
+ return str.replace(/[\\^$+{}[\]|()]/g, "\\$&")
159
+ }
@@ -0,0 +1,72 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import { dirname, join, resolve } from "node:path"
3
+ import type { LintConfigFile } from "../types"
4
+
5
+ const CONFIG_FILENAMES = [".pyreonlintrc.json", ".pyreonlintrc", "pyreonlint.config.json"]
6
+
7
+ /**
8
+ * Load a lint config file from the given directory or its parents.
9
+ *
10
+ * Search order:
11
+ * 1. `.pyreonlintrc.json`
12
+ * 2. `.pyreonlintrc`
13
+ * 3. `pyreonlint.config.json`
14
+ * 4. `package.json` `"pyreonlint"` field
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * import { loadConfig } from "@pyreon/lint"
19
+ *
20
+ * const config = loadConfig(process.cwd())
21
+ * if (config) console.log(config.preset)
22
+ * ```
23
+ */
24
+ export function loadConfig(cwd: string): LintConfigFile | null {
25
+ let dir = resolve(cwd)
26
+ const root = dirname(dir)
27
+
28
+ while (true) {
29
+ const found = searchDirectory(dir)
30
+ if (found !== null) return found
31
+
32
+ const parent = dirname(dir)
33
+ if (parent === dir || parent === root) break
34
+ dir = parent
35
+ }
36
+
37
+ return null
38
+ }
39
+
40
+ function searchDirectory(dir: string): LintConfigFile | null {
41
+ for (const filename of CONFIG_FILENAMES) {
42
+ const content = tryReadJson(join(dir, filename))
43
+ if (content !== null) return content
44
+ }
45
+ return extractPkgConfig(join(dir, "package.json"))
46
+ }
47
+
48
+ function extractPkgConfig(pkgPath: string): LintConfigFile | null {
49
+ const pkg = tryReadJson(pkgPath)
50
+ if (pkg === null || typeof pkg !== "object" || !("pyreonlint" in pkg)) return null
51
+ const field = (pkg as Record<string, unknown>).pyreonlint
52
+ if (field && typeof field === "object") return field as LintConfigFile
53
+ return null
54
+ }
55
+
56
+ /**
57
+ * Load a config file from a specific path.
58
+ */
59
+ export function loadConfigFromPath(filePath: string): LintConfigFile | null {
60
+ return tryReadJson(resolve(filePath))
61
+ }
62
+
63
+ function tryReadJson(filePath: string): any | null {
64
+ if (!existsSync(filePath)) return null
65
+ try {
66
+ const raw = readFileSync(filePath, "utf-8").trim()
67
+ if (!raw) return null
68
+ return JSON.parse(raw)
69
+ } catch {
70
+ return null
71
+ }
72
+ }
@@ -0,0 +1,62 @@
1
+ import { allRules } from "../rules/index"
2
+ import type { LintConfig, PresetName, Severity } from "../types"
3
+
4
+ /** Build a config where every rule uses its default severity. */
5
+ function buildRecommended(): LintConfig {
6
+ const rules: Record<string, Severity> = {}
7
+ for (const rule of allRules) {
8
+ rules[rule.meta.id] = rule.meta.severity
9
+ }
10
+ return { rules }
11
+ }
12
+
13
+ /** Build a config where every warn is promoted to error. */
14
+ function buildStrict(): LintConfig {
15
+ const base = buildRecommended()
16
+ const rules: Record<string, Severity> = {}
17
+ for (const [id, sev] of Object.entries(base.rules)) {
18
+ rules[id] = sev === "warn" ? "error" : sev
19
+ }
20
+ return { rules }
21
+ }
22
+
23
+ /** Build app config — recommended but disable library-only rules. */
24
+ function buildApp(): LintConfig {
25
+ const base = buildRecommended()
26
+ return {
27
+ rules: {
28
+ ...base.rules,
29
+ "pyreon/dev-guard-warnings": "off",
30
+ "pyreon/no-error-without-prefix": "off",
31
+ "pyreon/no-circular-import": "off",
32
+ "pyreon/no-cross-layer-import": "off",
33
+ },
34
+ }
35
+ }
36
+
37
+ /** Build lib config — strict + all architecture rules as error. */
38
+ function buildLib(): LintConfig {
39
+ const base = buildStrict()
40
+ return {
41
+ rules: {
42
+ ...base.rules,
43
+ "pyreon/no-circular-import": "error",
44
+ "pyreon/no-cross-layer-import": "error",
45
+ "pyreon/dev-guard-warnings": "error",
46
+ "pyreon/no-error-without-prefix": "error",
47
+ },
48
+ }
49
+ }
50
+
51
+ const presetBuilders: Record<PresetName, () => LintConfig> = {
52
+ recommended: buildRecommended,
53
+ strict: buildStrict,
54
+ app: buildApp,
55
+ lib: buildLib,
56
+ }
57
+
58
+ export function getPreset(name: PresetName): LintConfig {
59
+ return presetBuilders[name]()
60
+ }
61
+
62
+ export { buildApp, buildLib, buildRecommended, buildStrict }
package/src/index.ts ADDED
@@ -0,0 +1,40 @@
1
+ // Core API
2
+ export { AstCache } from "./cache"
3
+ export { createIgnoreFilter } from "./config/ignore"
4
+ export { loadConfig, loadConfigFromPath } from "./config/loader"
5
+ export { getPreset } from "./config/presets"
6
+ export { lint, listRules } from "./lint"
7
+ export { formatCompact, formatJSON, formatText } from "./reporter"
8
+ // Rules
9
+ export { allRules } from "./rules/index"
10
+ export { applyFixes, lintFile } from "./runner"
11
+ // Types
12
+ export type {
13
+ Diagnostic,
14
+ Fix,
15
+ ImportInfo,
16
+ LintConfig,
17
+ LintConfigFile,
18
+ LintFileResult,
19
+ LintOptions,
20
+ LintResult,
21
+ PresetName,
22
+ Rule,
23
+ RuleCategory,
24
+ RuleContext,
25
+ RuleMeta,
26
+ Severity,
27
+ SourceLocation,
28
+ Span,
29
+ VisitorCallbacks,
30
+ } from "./types"
31
+ export {
32
+ extractImportInfo,
33
+ getLocalName,
34
+ importsName,
35
+ isPyreonImport,
36
+ isPyreonPackage,
37
+ } from "./utils/imports"
38
+ // Utilities
39
+ export { LineIndex } from "./utils/source"
40
+ export { watchAndLint } from "./watcher"
package/src/lint.ts ADDED
@@ -0,0 +1,226 @@
1
+ import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
2
+ import { join, resolve } from "node:path"
3
+ import { AstCache } from "./cache"
4
+ import { createIgnoreFilter } from "./config/ignore"
5
+ import { loadConfig, loadConfigFromPath } from "./config/loader"
6
+ import { getPreset } from "./config/presets"
7
+ import { allRules } from "./rules/index"
8
+ import { applyFixes, lintFile } from "./runner"
9
+ import type { LintConfig, LintFileResult, LintOptions, LintResult, RuleMeta } from "./types"
10
+
11
+ const JS_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mts", ".mjs"])
12
+
13
+ function isHiddenOrVendor(entry: string): boolean {
14
+ return entry.startsWith(".") || entry === "node_modules" || entry === "lib" || entry === "dist"
15
+ }
16
+
17
+ function hasJsExtension(filePath: string): boolean {
18
+ const ext = filePath.slice(filePath.lastIndexOf("."))
19
+ return JS_EXTENSIONS.has(ext)
20
+ }
21
+
22
+ function matchesPatterns(
23
+ filePath: string,
24
+ include?: string[] | undefined,
25
+ exclude?: string[] | undefined,
26
+ ): boolean {
27
+ if (exclude) {
28
+ for (const pattern of exclude) {
29
+ if (filePath.includes(pattern)) return false
30
+ }
31
+ }
32
+ if (include && include.length > 0) {
33
+ for (const pattern of include) {
34
+ if (filePath.includes(pattern)) return true
35
+ }
36
+ return false
37
+ }
38
+ return true
39
+ }
40
+
41
+ function walkDirectory(
42
+ dir: string,
43
+ files: string[],
44
+ isIgnored: (filePath: string) => boolean,
45
+ include?: string[] | undefined,
46
+ exclude?: string[] | undefined,
47
+ ): void {
48
+ let entries: string[]
49
+ try {
50
+ entries = readdirSync(dir)
51
+ } catch {
52
+ return
53
+ }
54
+ for (const entry of entries) {
55
+ if (isHiddenOrVendor(entry)) continue
56
+ const full = join(dir, entry)
57
+ if (isIgnored(full)) continue
58
+ processEntry(full, files, isIgnored, include, exclude)
59
+ }
60
+ }
61
+
62
+ function processEntry(
63
+ full: string,
64
+ files: string[],
65
+ isIgnored: (filePath: string) => boolean,
66
+ include?: string[] | undefined,
67
+ exclude?: string[] | undefined,
68
+ ): void {
69
+ let stat: ReturnType<typeof statSync>
70
+ try {
71
+ stat = statSync(full)
72
+ } catch {
73
+ return
74
+ }
75
+ if (stat.isDirectory()) {
76
+ walkDirectory(full, files, isIgnored, include, exclude)
77
+ } else if (stat.isFile() && hasJsExtension(full) && matchesPatterns(full, include, exclude)) {
78
+ files.push(full)
79
+ }
80
+ }
81
+
82
+ function collectFiles(
83
+ dir: string,
84
+ isIgnored: (filePath: string) => boolean,
85
+ include?: string[] | undefined,
86
+ exclude?: string[] | undefined,
87
+ ): string[] {
88
+ const files: string[] = []
89
+ walkDirectory(dir, files, isIgnored, include, exclude)
90
+ return files
91
+ }
92
+
93
+ function buildConfig(options: LintOptions): {
94
+ config: LintConfig
95
+ include: string[] | undefined
96
+ exclude: string[] | undefined
97
+ isIgnored: (filePath: string) => boolean
98
+ } {
99
+ const cwd = resolve(".")
100
+ const fileConfig = options.config ? loadConfigFromPath(options.config) : loadConfig(cwd)
101
+
102
+ const presetName = options.preset ?? fileConfig?.preset ?? "recommended"
103
+ const config = getPreset(presetName)
104
+
105
+ // Merge config file rule overrides
106
+ if (fileConfig?.rules) {
107
+ for (const [id, severity] of Object.entries(fileConfig.rules)) {
108
+ config.rules[id] = severity
109
+ }
110
+ }
111
+
112
+ // CLI rule overrides (highest priority)
113
+ if (options.ruleOverrides) {
114
+ for (const [id, severity] of Object.entries(options.ruleOverrides)) {
115
+ config.rules[id] = severity
116
+ }
117
+ }
118
+
119
+ return {
120
+ config,
121
+ include: fileConfig?.include,
122
+ exclude: fileConfig?.exclude,
123
+ isIgnored: createIgnoreFilter(cwd, options.ignore),
124
+ }
125
+ }
126
+
127
+ function gatherFiles(
128
+ paths: string[],
129
+ isIgnored: (filePath: string) => boolean,
130
+ include?: string[] | undefined,
131
+ exclude?: string[] | undefined,
132
+ ): string[] {
133
+ const files: string[] = []
134
+ for (const p of paths) {
135
+ const resolved = resolve(p)
136
+ let stat: ReturnType<typeof statSync>
137
+ try {
138
+ stat = statSync(resolved)
139
+ } catch {
140
+ continue
141
+ }
142
+ if (stat.isDirectory()) {
143
+ files.push(...collectFiles(resolved, isIgnored, include, exclude))
144
+ } else if (stat.isFile() && !isIgnored(resolved)) {
145
+ files.push(resolved)
146
+ }
147
+ }
148
+ return files
149
+ }
150
+
151
+ function applyFixesToFile(fileResult: LintFileResult, source: string): void {
152
+ const fixable = fileResult.diagnostics.filter((d) => d.fix)
153
+ if (fixable.length === 0) return
154
+ const fixed = applyFixes(source, fileResult.diagnostics)
155
+ writeFileSync(fileResult.filePath, fixed, "utf-8")
156
+ fileResult.fixedSource = fixed
157
+ fileResult.diagnostics = fileResult.diagnostics.filter((d) => !d.fix)
158
+ }
159
+
160
+ function countDiagnostics(fileResult: LintFileResult, results: LintResult): void {
161
+ for (const d of fileResult.diagnostics) {
162
+ if (d.severity === "error") results.totalErrors++
163
+ else if (d.severity === "warn") results.totalWarnings++
164
+ else if (d.severity === "info") results.totalInfos++
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Lint files and return results.
170
+ *
171
+ * @example
172
+ * ```ts
173
+ * import { lint } from "@pyreon/lint"
174
+ *
175
+ * const result = lint({ paths: ["src/"], preset: "recommended" })
176
+ * console.log(result.totalErrors) // 0
177
+ * ```
178
+ */
179
+ export function lint(options: LintOptions): LintResult {
180
+ const { config, include, exclude, isIgnored } = buildConfig(options)
181
+ const cache = new AstCache()
182
+ const files = gatherFiles(options.paths, isIgnored, include, exclude)
183
+
184
+ const results: LintResult = {
185
+ files: [],
186
+ totalErrors: 0,
187
+ totalWarnings: 0,
188
+ totalInfos: 0,
189
+ }
190
+
191
+ for (const filePath of files) {
192
+ let source: string
193
+ try {
194
+ source = readFileSync(filePath, "utf-8")
195
+ } catch {
196
+ continue
197
+ }
198
+ const fileResult = lintFile(filePath, source, allRules, config, cache)
199
+ if (options.fix) {
200
+ applyFixesToFile(fileResult, source)
201
+ }
202
+ if (options.quiet) {
203
+ fileResult.diagnostics = fileResult.diagnostics.filter((d) => d.severity === "error")
204
+ }
205
+ countDiagnostics(fileResult, results)
206
+ results.files.push(fileResult)
207
+ }
208
+
209
+ return results
210
+ }
211
+
212
+ /**
213
+ * List all available rules with their metadata.
214
+ *
215
+ * @example
216
+ * ```ts
217
+ * import { listRules } from "@pyreon/lint"
218
+ *
219
+ * for (const rule of listRules()) {
220
+ * console.log(`${rule.id} (${rule.severity}): ${rule.description}`)
221
+ * }
222
+ * ```
223
+ */
224
+ export function listRules(): RuleMeta[] {
225
+ return allRules.map((r) => r.meta)
226
+ }
@@ -0,0 +1,85 @@
1
+ import type { LintResult, Severity } from "./types"
2
+
3
+ // ANSI colors
4
+ const BOLD = "\x1b[1m"
5
+ const RED = "\x1b[31m"
6
+ const YELLOW = "\x1b[33m"
7
+ const BLUE = "\x1b[34m"
8
+ const DIM = "\x1b[2m"
9
+ const RESET = "\x1b[0m"
10
+
11
+ const SEVERITY_SYMBOL: Record<Severity, string> = {
12
+ error: `${RED}\u2716${RESET}`,
13
+ warn: `${YELLOW}\u26A0${RESET}`,
14
+ info: `${BLUE}\u2139${RESET}`,
15
+ off: "",
16
+ }
17
+
18
+ const SEVERITY_LABEL: Record<Severity, string> = {
19
+ error: `${RED}error${RESET}`,
20
+ warn: `${YELLOW}warning${RESET}`,
21
+ info: `${BLUE}info${RESET}`,
22
+ off: "",
23
+ }
24
+
25
+ /**
26
+ * Format results as human-readable colored text.
27
+ */
28
+ export function formatText(result: LintResult): string {
29
+ const lines: string[] = []
30
+
31
+ for (const file of result.files) {
32
+ if (file.diagnostics.length === 0) continue
33
+
34
+ lines.push("")
35
+ lines.push(`${BOLD}${file.filePath}${RESET}`)
36
+
37
+ for (const d of file.diagnostics) {
38
+ const loc = `${DIM}${d.loc.line}:${d.loc.column}${RESET}`
39
+ const severity = SEVERITY_LABEL[d.severity]
40
+ const ruleId = `${DIM}${d.ruleId}${RESET}`
41
+ lines.push(` ${loc} ${severity} ${d.message} ${ruleId}`)
42
+ }
43
+ }
44
+
45
+ const total = result.totalErrors + result.totalWarnings + result.totalInfos
46
+ if (total > 0) {
47
+ lines.push("")
48
+ const parts: string[] = []
49
+ if (result.totalErrors > 0)
50
+ parts.push(`${RED}${result.totalErrors} error${result.totalErrors === 1 ? "" : "s"}${RESET}`)
51
+ if (result.totalWarnings > 0)
52
+ parts.push(
53
+ `${YELLOW}${result.totalWarnings} warning${result.totalWarnings === 1 ? "" : "s"}${RESET}`,
54
+ )
55
+ if (result.totalInfos > 0) parts.push(`${BLUE}${result.totalInfos} info${RESET}`)
56
+ lines.push(`${SEVERITY_SYMBOL.error} ${parts.join(", ")}`)
57
+ lines.push("")
58
+ }
59
+
60
+ return lines.join("\n")
61
+ }
62
+
63
+ /**
64
+ * Format results as JSON.
65
+ */
66
+ export function formatJSON(result: LintResult): string {
67
+ return JSON.stringify(result, null, 2)
68
+ }
69
+
70
+ /**
71
+ * Format results as compact single-line-per-diagnostic output.
72
+ */
73
+ export function formatCompact(result: LintResult): string {
74
+ const lines: string[] = []
75
+
76
+ for (const file of result.files) {
77
+ for (const d of file.diagnostics) {
78
+ lines.push(
79
+ `${file.filePath}:${d.loc.line}:${d.loc.column}: ${d.severity} [${d.ruleId}] ${d.message}`,
80
+ )
81
+ }
82
+ }
83
+
84
+ return lines.join("\n")
85
+ }
@@ -0,0 +1,32 @@
1
+ import type { Rule, VisitorCallbacks } from "../../types"
2
+ import { getSpan, hasJSXAttribute } from "../../utils/ast"
3
+
4
+ export const dialogA11y: Rule = {
5
+ meta: {
6
+ id: "pyreon/dialog-a11y",
7
+ category: "accessibility",
8
+ description: "Warn when <dialog> is missing aria-label or aria-labelledby.",
9
+ severity: "warn",
10
+ fixable: false,
11
+ },
12
+ create(context) {
13
+ const callbacks: VisitorCallbacks = {
14
+ JSXOpeningElement(node: any) {
15
+ const name = node.name
16
+ if (!name || name.type !== "JSXIdentifier" || name.name !== "dialog") return
17
+
18
+ const hasLabel = hasJSXAttribute(node, "aria-label")
19
+ const hasLabelledBy = hasJSXAttribute(node, "aria-labelledby")
20
+
21
+ if (!hasLabel && !hasLabelledBy) {
22
+ context.report({
23
+ message:
24
+ "`<dialog>` missing `aria-label` or `aria-labelledby` — provide an accessible label for screen readers.",
25
+ span: getSpan(node),
26
+ })
27
+ }
28
+ },
29
+ }
30
+ return callbacks
31
+ },
32
+ }