@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.
- package/LICENSE +21 -0
- package/README.md +214 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +2955 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +260 -0
- package/lib/types/index.d.ts.map +1 -0
- package/package.json +56 -0
- package/src/cache.ts +51 -0
- package/src/cli.ts +199 -0
- package/src/config/ignore.ts +159 -0
- package/src/config/loader.ts +72 -0
- package/src/config/presets.ts +62 -0
- package/src/index.ts +40 -0
- package/src/lint.ts +226 -0
- package/src/reporter.ts +85 -0
- package/src/rules/accessibility/dialog-a11y.ts +32 -0
- package/src/rules/accessibility/overlay-a11y.ts +33 -0
- package/src/rules/accessibility/toast-a11y.ts +38 -0
- package/src/rules/architecture/dev-guard-warnings.ts +57 -0
- package/src/rules/architecture/no-circular-import.ts +59 -0
- package/src/rules/architecture/no-cross-layer-import.ts +75 -0
- package/src/rules/architecture/no-deep-import.ts +32 -0
- package/src/rules/architecture/no-error-without-prefix.ts +75 -0
- package/src/rules/form/no-submit-without-validation.ts +45 -0
- package/src/rules/form/no-unregistered-field.ts +45 -0
- package/src/rules/form/prefer-field-array.ts +41 -0
- package/src/rules/hooks/no-raw-addeventlistener.ts +28 -0
- package/src/rules/hooks/no-raw-localstorage.ts +35 -0
- package/src/rules/hooks/no-raw-setinterval.ts +41 -0
- package/src/rules/index.ts +208 -0
- package/src/rules/jsx/no-and-conditional.ts +32 -0
- package/src/rules/jsx/no-children-access.ts +44 -0
- package/src/rules/jsx/no-classname.ts +27 -0
- package/src/rules/jsx/no-htmlfor.ts +27 -0
- package/src/rules/jsx/no-index-as-by.ts +70 -0
- package/src/rules/jsx/no-map-in-jsx.ts +43 -0
- package/src/rules/jsx/no-missing-for-by.ts +27 -0
- package/src/rules/jsx/no-onchange.ts +46 -0
- package/src/rules/jsx/no-props-destructure.ts +64 -0
- package/src/rules/jsx/no-ternary-conditional.ts +32 -0
- package/src/rules/jsx/use-by-not-key.ts +33 -0
- package/src/rules/lifecycle/no-dom-in-setup.ts +53 -0
- package/src/rules/lifecycle/no-effect-in-mount.ts +36 -0
- package/src/rules/lifecycle/no-missing-cleanup.ts +80 -0
- package/src/rules/lifecycle/no-mount-in-effect.ts +35 -0
- package/src/rules/performance/no-eager-import.ts +28 -0
- package/src/rules/performance/no-effect-in-for.ts +41 -0
- package/src/rules/performance/no-large-for-without-by.ts +28 -0
- package/src/rules/performance/prefer-show-over-display.ts +47 -0
- package/src/rules/reactivity/no-bare-signal-in-jsx.ts +56 -0
- package/src/rules/reactivity/no-effect-assignment.ts +65 -0
- package/src/rules/reactivity/no-nested-effect.ts +33 -0
- package/src/rules/reactivity/no-peek-in-tracked.ts +35 -0
- package/src/rules/reactivity/no-signal-in-loop.ts +59 -0
- package/src/rules/reactivity/no-signal-leak.ts +58 -0
- package/src/rules/reactivity/no-unbatched-updates.ts +77 -0
- package/src/rules/reactivity/prefer-computed.ts +56 -0
- package/src/rules/router/index.ts +4 -0
- package/src/rules/router/no-href-navigation.ts +51 -0
- package/src/rules/router/no-imperative-navigate-in-render.ts +83 -0
- package/src/rules/router/no-missing-fallback.ts +87 -0
- package/src/rules/router/prefer-use-is-active.ts +45 -0
- package/src/rules/ssr/no-mismatch-risk.ts +47 -0
- package/src/rules/ssr/no-window-in-ssr.ts +76 -0
- package/src/rules/ssr/prefer-request-context.ts +56 -0
- package/src/rules/store/no-duplicate-store-id.ts +43 -0
- package/src/rules/store/no-mutate-store-state.ts +37 -0
- package/src/rules/store/no-store-outside-provider.ts +59 -0
- package/src/rules/styling/no-dynamic-styled.ts +60 -0
- package/src/rules/styling/no-inline-style-object.ts +30 -0
- package/src/rules/styling/no-theme-outside-provider.ts +45 -0
- package/src/rules/styling/prefer-cx.ts +44 -0
- package/src/runner.ts +170 -0
- package/src/tests/runner.test.ts +1043 -0
- package/src/types.ts +125 -0
- package/src/utils/ast.ts +192 -0
- package/src/utils/imports.ts +122 -0
- package/src/utils/index.ts +39 -0
- package/src/utils/source.ts +36 -0
- 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
|
+
}
|
package/src/reporter.ts
ADDED
|
@@ -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
|
+
}
|