@pyreon/lint 0.11.4 → 0.11.6
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/README.md +91 -91
- package/lib/analysis/cli.js.html +5406 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/cli.js +3290 -0
- package/lib/cli.js.map +1 -0
- package/lib/index.js +220 -29
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +30 -5
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +19 -19
- package/src/cache.ts +1 -1
- package/src/cli.ts +39 -28
- package/src/config/ignore.ts +23 -23
- package/src/config/loader.ts +8 -8
- package/src/config/presets.ts +11 -11
- package/src/index.ts +14 -12
- package/src/lint.ts +19 -25
- package/src/lsp/index.ts +225 -0
- package/src/reporter.ts +17 -17
- package/src/rules/accessibility/dialog-a11y.ts +10 -10
- package/src/rules/accessibility/overlay-a11y.ts +11 -11
- package/src/rules/accessibility/toast-a11y.ts +11 -11
- package/src/rules/architecture/dev-guard-warnings.ts +19 -19
- package/src/rules/architecture/no-circular-import.ts +16 -16
- package/src/rules/architecture/no-cross-layer-import.ts +35 -35
- package/src/rules/architecture/no-deep-import.ts +7 -7
- package/src/rules/architecture/no-error-without-prefix.ts +20 -20
- package/src/rules/form/no-submit-without-validation.ts +13 -13
- package/src/rules/form/no-unregistered-field.ts +12 -12
- package/src/rules/form/prefer-field-array.ts +11 -11
- package/src/rules/hooks/no-raw-addeventlistener.ts +9 -9
- package/src/rules/hooks/no-raw-localstorage.ts +11 -11
- package/src/rules/hooks/no-raw-setinterval.ts +11 -11
- package/src/rules/index.ts +60 -57
- package/src/rules/jsx/no-and-conditional.ts +8 -8
- package/src/rules/jsx/no-children-access.ts +12 -12
- package/src/rules/jsx/no-classname.ts +10 -10
- package/src/rules/jsx/no-htmlfor.ts +10 -10
- package/src/rules/jsx/no-index-as-by.ts +17 -17
- package/src/rules/jsx/no-map-in-jsx.ts +9 -9
- package/src/rules/jsx/no-missing-for-by.ts +9 -9
- package/src/rules/jsx/no-onchange.ts +12 -12
- package/src/rules/jsx/no-props-destructure.ts +11 -11
- package/src/rules/jsx/no-ternary-conditional.ts +8 -8
- package/src/rules/jsx/use-by-not-key.ts +12 -12
- package/src/rules/lifecycle/no-dom-in-setup.ts +18 -18
- package/src/rules/lifecycle/no-effect-in-mount.ts +11 -11
- package/src/rules/lifecycle/no-missing-cleanup.ts +19 -19
- package/src/rules/lifecycle/no-mount-in-effect.ts +11 -11
- package/src/rules/performance/no-eager-import.ts +7 -7
- package/src/rules/performance/no-effect-in-for.ts +10 -10
- package/src/rules/performance/no-large-for-without-by.ts +9 -9
- package/src/rules/performance/prefer-show-over-display.ts +16 -16
- package/src/rules/reactivity/no-bare-signal-in-jsx.ts +10 -10
- package/src/rules/reactivity/no-context-destructure.ts +45 -0
- package/src/rules/reactivity/no-effect-assignment.ts +16 -16
- package/src/rules/reactivity/no-nested-effect.ts +10 -10
- package/src/rules/reactivity/no-peek-in-tracked.ts +10 -10
- package/src/rules/reactivity/no-signal-in-loop.ts +13 -13
- package/src/rules/reactivity/no-signal-leak.ts +9 -9
- package/src/rules/reactivity/no-unbatched-updates.ts +12 -12
- package/src/rules/reactivity/prefer-computed.ts +13 -13
- package/src/rules/router/index.ts +4 -4
- package/src/rules/router/no-href-navigation.ts +14 -14
- package/src/rules/router/no-imperative-navigate-in-render.ts +19 -19
- package/src/rules/router/no-missing-fallback.ts +16 -16
- package/src/rules/router/prefer-use-is-active.ts +11 -11
- package/src/rules/ssr/no-mismatch-risk.ts +11 -11
- package/src/rules/ssr/no-window-in-ssr.ts +22 -22
- package/src/rules/ssr/prefer-request-context.ts +14 -14
- package/src/rules/store/no-duplicate-store-id.ts +9 -9
- package/src/rules/store/no-mutate-store-state.ts +11 -11
- package/src/rules/store/no-store-outside-provider.ts +15 -15
- package/src/rules/styling/no-dynamic-styled.ts +13 -13
- package/src/rules/styling/no-inline-style-object.ts +10 -10
- package/src/rules/styling/no-theme-outside-provider.ts +11 -11
- package/src/rules/styling/prefer-cx.ts +12 -12
- package/src/runner.ts +13 -14
- package/src/tests/lsp.test.ts +88 -0
- package/src/tests/runner.test.ts +325 -325
- package/src/types.ts +15 -15
- package/src/utils/ast.ts +50 -50
- package/src/utils/imports.ts +53 -53
- package/src/utils/index.ts +12 -3
- package/src/utils/source.ts +2 -2
- package/src/watcher.ts +19 -25
package/src/lint.ts
CHANGED
|
@@ -1,22 +1,16 @@
|
|
|
1
|
-
import { readdirSync, readFileSync, statSync, writeFileSync } from
|
|
2
|
-
import { join, resolve } from
|
|
3
|
-
import { AstCache } from
|
|
4
|
-
import { createIgnoreFilter } from
|
|
5
|
-
import { loadConfig, loadConfigFromPath } from
|
|
6
|
-
import { getPreset } from
|
|
7
|
-
import { allRules } from
|
|
8
|
-
import { applyFixes, lintFile } from
|
|
9
|
-
import type { LintConfig, LintFileResult, LintOptions, LintResult, RuleMeta } from
|
|
10
|
-
|
|
11
|
-
const JS_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mts", ".mjs"])
|
|
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
|
+
import { hasJsExtension } from './utils/index'
|
|
12
11
|
|
|
13
12
|
function isHiddenOrVendor(entry: string): boolean {
|
|
14
|
-
return entry.startsWith(
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function hasJsExtension(filePath: string): boolean {
|
|
18
|
-
const ext = filePath.slice(filePath.lastIndexOf("."))
|
|
19
|
-
return JS_EXTENSIONS.has(ext)
|
|
13
|
+
return entry.startsWith('.') || entry === 'node_modules' || entry === 'lib' || entry === 'dist'
|
|
20
14
|
}
|
|
21
15
|
|
|
22
16
|
function matchesPatterns(
|
|
@@ -96,10 +90,10 @@ function buildConfig(options: LintOptions): {
|
|
|
96
90
|
exclude: string[] | undefined
|
|
97
91
|
isIgnored: (filePath: string) => boolean
|
|
98
92
|
} {
|
|
99
|
-
const cwd = resolve(
|
|
93
|
+
const cwd = resolve('.')
|
|
100
94
|
const fileConfig = options.config ? loadConfigFromPath(options.config) : loadConfig(cwd)
|
|
101
95
|
|
|
102
|
-
const presetName = options.preset ?? fileConfig?.preset ??
|
|
96
|
+
const presetName = options.preset ?? fileConfig?.preset ?? 'recommended'
|
|
103
97
|
const config = getPreset(presetName)
|
|
104
98
|
|
|
105
99
|
// Merge config file rule overrides
|
|
@@ -152,16 +146,16 @@ function applyFixesToFile(fileResult: LintFileResult, source: string): void {
|
|
|
152
146
|
const fixable = fileResult.diagnostics.filter((d) => d.fix)
|
|
153
147
|
if (fixable.length === 0) return
|
|
154
148
|
const fixed = applyFixes(source, fileResult.diagnostics)
|
|
155
|
-
writeFileSync(fileResult.filePath, fixed,
|
|
149
|
+
writeFileSync(fileResult.filePath, fixed, 'utf-8')
|
|
156
150
|
fileResult.fixedSource = fixed
|
|
157
151
|
fileResult.diagnostics = fileResult.diagnostics.filter((d) => !d.fix)
|
|
158
152
|
}
|
|
159
153
|
|
|
160
154
|
function countDiagnostics(fileResult: LintFileResult, results: LintResult): void {
|
|
161
155
|
for (const d of fileResult.diagnostics) {
|
|
162
|
-
if (d.severity ===
|
|
163
|
-
else if (d.severity ===
|
|
164
|
-
else if (d.severity ===
|
|
156
|
+
if (d.severity === 'error') results.totalErrors++
|
|
157
|
+
else if (d.severity === 'warn') results.totalWarnings++
|
|
158
|
+
else if (d.severity === 'info') results.totalInfos++
|
|
165
159
|
}
|
|
166
160
|
}
|
|
167
161
|
|
|
@@ -191,7 +185,7 @@ export function lint(options: LintOptions): LintResult {
|
|
|
191
185
|
for (const filePath of files) {
|
|
192
186
|
let source: string
|
|
193
187
|
try {
|
|
194
|
-
source = readFileSync(filePath,
|
|
188
|
+
source = readFileSync(filePath, 'utf-8')
|
|
195
189
|
} catch {
|
|
196
190
|
continue
|
|
197
191
|
}
|
|
@@ -200,7 +194,7 @@ export function lint(options: LintOptions): LintResult {
|
|
|
200
194
|
applyFixesToFile(fileResult, source)
|
|
201
195
|
}
|
|
202
196
|
if (options.quiet) {
|
|
203
|
-
fileResult.diagnostics = fileResult.diagnostics.filter((d) => d.severity ===
|
|
197
|
+
fileResult.diagnostics = fileResult.diagnostics.filter((d) => d.severity === 'error')
|
|
204
198
|
}
|
|
205
199
|
countDiagnostics(fileResult, results)
|
|
206
200
|
results.files.push(fileResult)
|
package/src/lsp/index.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal LSP server for @pyreon/lint.
|
|
3
|
+
*
|
|
4
|
+
* Provides real-time Pyreon-specific diagnostics in editors that support
|
|
5
|
+
* the Language Server Protocol (VS Code, Neovim, etc.).
|
|
6
|
+
*
|
|
7
|
+
* Usage: pyreon-lint --lsp
|
|
8
|
+
*
|
|
9
|
+
* The server communicates via JSON-RPC over stdin/stdout following the
|
|
10
|
+
* LSP specification (https://microsoft.github.io/language-server-protocol/).
|
|
11
|
+
*
|
|
12
|
+
* Supported capabilities:
|
|
13
|
+
* - textDocument/didOpen — lint on open
|
|
14
|
+
* - textDocument/didSave — lint on save
|
|
15
|
+
* - textDocument/didChange — lint on change (debounced)
|
|
16
|
+
*
|
|
17
|
+
* @module
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { AstCache } from '../cache'
|
|
21
|
+
import { getPreset } from '../config/presets'
|
|
22
|
+
import { allRules } from '../rules/index'
|
|
23
|
+
import { lintFile } from '../runner'
|
|
24
|
+
import type { Diagnostic, LintConfig } from '../types'
|
|
25
|
+
|
|
26
|
+
const cache = new AstCache()
|
|
27
|
+
const config: LintConfig = getPreset('recommended')
|
|
28
|
+
|
|
29
|
+
// ─── JSON-RPC message types ────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
interface JsonRpcMessage {
|
|
32
|
+
jsonrpc: '2.0'
|
|
33
|
+
id?: number | string | undefined
|
|
34
|
+
method?: string | undefined
|
|
35
|
+
params?: any
|
|
36
|
+
result?: any
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── LSP Diagnostic conversion ─────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
interface LspDiagnostic {
|
|
42
|
+
range: { start: { line: number; character: number }; end: { line: number; character: number } }
|
|
43
|
+
severity: number
|
|
44
|
+
source: string
|
|
45
|
+
message: string
|
|
46
|
+
code: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function toLspDiagnostics(diagnostics: Diagnostic[]): LspDiagnostic[] {
|
|
50
|
+
return diagnostics.map((d) => ({
|
|
51
|
+
range: {
|
|
52
|
+
start: { line: d.loc.line - 1, character: d.loc.column - 1 },
|
|
53
|
+
end: { line: d.loc.line - 1, character: d.loc.column - 1 + (d.span.end - d.span.start) },
|
|
54
|
+
},
|
|
55
|
+
severity: d.severity === 'error' ? 1 : d.severity === 'warn' ? 2 : 3,
|
|
56
|
+
source: 'pyreon-lint',
|
|
57
|
+
message: d.message,
|
|
58
|
+
code: d.ruleId,
|
|
59
|
+
}))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Lint a document ───────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
function lintDocument(uri: string, text: string): LspDiagnostic[] {
|
|
65
|
+
try {
|
|
66
|
+
const filePath = uri.replace('file://', '')
|
|
67
|
+
const result = lintFile(filePath, text, allRules, config, cache)
|
|
68
|
+
return toLspDiagnostics(result.diagnostics)
|
|
69
|
+
} catch {
|
|
70
|
+
// Parse errors, unsupported file types — return empty diagnostics
|
|
71
|
+
return []
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── Debounce ──────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
const DEBOUNCE_MS = 150
|
|
78
|
+
const debounceTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
79
|
+
|
|
80
|
+
function debounceLint(uri: string, text: string): void {
|
|
81
|
+
const existing = debounceTimers.get(uri)
|
|
82
|
+
if (existing) clearTimeout(existing)
|
|
83
|
+
debounceTimers.set(
|
|
84
|
+
uri,
|
|
85
|
+
setTimeout(() => {
|
|
86
|
+
debounceTimers.delete(uri)
|
|
87
|
+
const diagnostics = lintDocument(uri, text)
|
|
88
|
+
sendNotification('textDocument/publishDiagnostics', { uri, diagnostics })
|
|
89
|
+
}, DEBOUNCE_MS),
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── Message handling ──────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
const openDocuments = new Map<string, string>()
|
|
96
|
+
|
|
97
|
+
function handleMessage(msg: JsonRpcMessage): JsonRpcMessage | null {
|
|
98
|
+
if (msg.method === 'initialize') {
|
|
99
|
+
return {
|
|
100
|
+
jsonrpc: '2.0',
|
|
101
|
+
id: msg.id,
|
|
102
|
+
result: {
|
|
103
|
+
capabilities: {
|
|
104
|
+
textDocumentSync: 1, // Full sync
|
|
105
|
+
diagnosticProvider: { interFileDependencies: false, workspaceDiagnostics: false },
|
|
106
|
+
},
|
|
107
|
+
serverInfo: { name: 'pyreon-lint', version: '0.11.5' },
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (msg.method === 'initialized') {
|
|
113
|
+
return null // no response needed
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (msg.method === 'textDocument/didOpen') {
|
|
117
|
+
const { uri, text } = msg.params.textDocument
|
|
118
|
+
openDocuments.set(uri, text)
|
|
119
|
+
const diagnostics = lintDocument(uri, text)
|
|
120
|
+
sendNotification('textDocument/publishDiagnostics', { uri, diagnostics })
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (msg.method === 'textDocument/didChange') {
|
|
125
|
+
const uri = msg.params.textDocument.uri
|
|
126
|
+
const text = msg.params.contentChanges[0]?.text
|
|
127
|
+
if (text != null) {
|
|
128
|
+
openDocuments.set(uri, text)
|
|
129
|
+
// Debounce: wait 150ms after last keystroke before linting
|
|
130
|
+
debounceLint(uri, text)
|
|
131
|
+
}
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (msg.method === 'textDocument/didSave') {
|
|
136
|
+
const uri = msg.params.textDocument.uri
|
|
137
|
+
const text = openDocuments.get(uri)
|
|
138
|
+
if (text) {
|
|
139
|
+
const diagnostics = lintDocument(uri, text)
|
|
140
|
+
sendNotification('textDocument/publishDiagnostics', { uri, diagnostics })
|
|
141
|
+
}
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (msg.method === 'textDocument/didClose') {
|
|
146
|
+
const uri = msg.params.textDocument.uri
|
|
147
|
+
openDocuments.delete(uri)
|
|
148
|
+
sendNotification('textDocument/publishDiagnostics', { uri, diagnostics: [] })
|
|
149
|
+
return null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (msg.method === 'shutdown') {
|
|
153
|
+
return { jsonrpc: '2.0', id: msg.id, result: null }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (msg.method === 'exit') {
|
|
157
|
+
process.exit(0)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Unknown method
|
|
161
|
+
if (msg.id != null) {
|
|
162
|
+
return {
|
|
163
|
+
jsonrpc: '2.0',
|
|
164
|
+
id: msg.id,
|
|
165
|
+
result: null,
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return null
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ─── JSON-RPC transport (stdin/stdout) ─────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
function sendMessage(msg: JsonRpcMessage) {
|
|
175
|
+
const body = JSON.stringify(msg)
|
|
176
|
+
const header = `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n`
|
|
177
|
+
process.stdout.write(header + body)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function sendNotification(method: string, params: any) {
|
|
181
|
+
sendMessage({ jsonrpc: '2.0', method, params })
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Start the LSP server. Reads JSON-RPC messages from stdin,
|
|
186
|
+
* processes them, and writes responses to stdout.
|
|
187
|
+
*/
|
|
188
|
+
export function startLspServer(): void {
|
|
189
|
+
let buffer = ''
|
|
190
|
+
|
|
191
|
+
process.stdin.setEncoding('utf-8')
|
|
192
|
+
process.stdin.on('data', (chunk: string) => {
|
|
193
|
+
buffer += chunk
|
|
194
|
+
|
|
195
|
+
// Parse Content-Length header + body
|
|
196
|
+
while (true) {
|
|
197
|
+
const headerEnd = buffer.indexOf('\r\n\r\n')
|
|
198
|
+
if (headerEnd === -1) break
|
|
199
|
+
|
|
200
|
+
const header = buffer.slice(0, headerEnd)
|
|
201
|
+
const match = header.match(/Content-Length:\s*(\d+)/i)
|
|
202
|
+
if (!match) {
|
|
203
|
+
buffer = buffer.slice(headerEnd + 4)
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const contentLength = Number.parseInt(match[1]!, 10)
|
|
208
|
+
const bodyStart = headerEnd + 4
|
|
209
|
+
if (buffer.length < bodyStart + contentLength) break
|
|
210
|
+
|
|
211
|
+
const body = buffer.slice(bodyStart, bodyStart + contentLength)
|
|
212
|
+
buffer = buffer.slice(bodyStart + contentLength)
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const msg = JSON.parse(body) as JsonRpcMessage
|
|
216
|
+
const response = handleMessage(msg)
|
|
217
|
+
if (response) sendMessage(response)
|
|
218
|
+
} catch {
|
|
219
|
+
// malformed JSON — ignore
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
process.stderr.write('[pyreon-lint] LSP server started\n')
|
|
225
|
+
}
|
package/src/reporter.ts
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
|
-
import type { LintResult, Severity } from
|
|
1
|
+
import type { LintResult, Severity } from './types'
|
|
2
2
|
|
|
3
3
|
// ANSI colors
|
|
4
|
-
const BOLD =
|
|
5
|
-
const RED =
|
|
6
|
-
const YELLOW =
|
|
7
|
-
const BLUE =
|
|
8
|
-
const DIM =
|
|
9
|
-
const RESET =
|
|
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
10
|
|
|
11
11
|
const SEVERITY_SYMBOL: Record<Severity, string> = {
|
|
12
12
|
error: `${RED}\u2716${RESET}`,
|
|
13
13
|
warn: `${YELLOW}\u26A0${RESET}`,
|
|
14
14
|
info: `${BLUE}\u2139${RESET}`,
|
|
15
|
-
off:
|
|
15
|
+
off: '',
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
const SEVERITY_LABEL: Record<Severity, string> = {
|
|
19
19
|
error: `${RED}error${RESET}`,
|
|
20
20
|
warn: `${YELLOW}warning${RESET}`,
|
|
21
21
|
info: `${BLUE}info${RESET}`,
|
|
22
|
-
off:
|
|
22
|
+
off: '',
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/**
|
|
@@ -31,7 +31,7 @@ export function formatText(result: LintResult): string {
|
|
|
31
31
|
for (const file of result.files) {
|
|
32
32
|
if (file.diagnostics.length === 0) continue
|
|
33
33
|
|
|
34
|
-
lines.push(
|
|
34
|
+
lines.push('')
|
|
35
35
|
lines.push(`${BOLD}${file.filePath}${RESET}`)
|
|
36
36
|
|
|
37
37
|
for (const d of file.diagnostics) {
|
|
@@ -44,20 +44,20 @@ export function formatText(result: LintResult): string {
|
|
|
44
44
|
|
|
45
45
|
const total = result.totalErrors + result.totalWarnings + result.totalInfos
|
|
46
46
|
if (total > 0) {
|
|
47
|
-
lines.push(
|
|
47
|
+
lines.push('')
|
|
48
48
|
const parts: string[] = []
|
|
49
49
|
if (result.totalErrors > 0)
|
|
50
|
-
parts.push(`${RED}${result.totalErrors} error${result.totalErrors === 1 ?
|
|
50
|
+
parts.push(`${RED}${result.totalErrors} error${result.totalErrors === 1 ? '' : 's'}${RESET}`)
|
|
51
51
|
if (result.totalWarnings > 0)
|
|
52
52
|
parts.push(
|
|
53
|
-
`${YELLOW}${result.totalWarnings} warning${result.totalWarnings === 1 ?
|
|
53
|
+
`${YELLOW}${result.totalWarnings} warning${result.totalWarnings === 1 ? '' : 's'}${RESET}`,
|
|
54
54
|
)
|
|
55
55
|
if (result.totalInfos > 0) parts.push(`${BLUE}${result.totalInfos} info${RESET}`)
|
|
56
|
-
lines.push(`${SEVERITY_SYMBOL.error} ${parts.join(
|
|
57
|
-
lines.push(
|
|
56
|
+
lines.push(`${SEVERITY_SYMBOL.error} ${parts.join(', ')}`)
|
|
57
|
+
lines.push('')
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
return lines.join(
|
|
60
|
+
return lines.join('\n')
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
/**
|
|
@@ -81,5 +81,5 @@ export function formatCompact(result: LintResult): string {
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
return lines.join(
|
|
84
|
+
return lines.join('\n')
|
|
85
85
|
}
|
|
@@ -1,27 +1,27 @@
|
|
|
1
|
-
import type { Rule, VisitorCallbacks } from
|
|
2
|
-
import { getSpan, hasJSXAttribute } from
|
|
1
|
+
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
|
+
import { getSpan, hasJSXAttribute } from '../../utils/ast'
|
|
3
3
|
|
|
4
4
|
export const dialogA11y: Rule = {
|
|
5
5
|
meta: {
|
|
6
|
-
id:
|
|
7
|
-
category:
|
|
8
|
-
description:
|
|
9
|
-
severity:
|
|
6
|
+
id: 'pyreon/dialog-a11y',
|
|
7
|
+
category: 'accessibility',
|
|
8
|
+
description: 'Warn when <dialog> is missing aria-label or aria-labelledby.',
|
|
9
|
+
severity: 'warn',
|
|
10
10
|
fixable: false,
|
|
11
11
|
},
|
|
12
12
|
create(context) {
|
|
13
13
|
const callbacks: VisitorCallbacks = {
|
|
14
14
|
JSXOpeningElement(node: any) {
|
|
15
15
|
const name = node.name
|
|
16
|
-
if (!name || name.type !==
|
|
16
|
+
if (!name || name.type !== 'JSXIdentifier' || name.name !== 'dialog') return
|
|
17
17
|
|
|
18
|
-
const hasLabel = hasJSXAttribute(node,
|
|
19
|
-
const hasLabelledBy = hasJSXAttribute(node,
|
|
18
|
+
const hasLabel = hasJSXAttribute(node, 'aria-label')
|
|
19
|
+
const hasLabelledBy = hasJSXAttribute(node, 'aria-labelledby')
|
|
20
20
|
|
|
21
21
|
if (!hasLabel && !hasLabelledBy) {
|
|
22
22
|
context.report({
|
|
23
23
|
message:
|
|
24
|
-
|
|
24
|
+
'`<dialog>` missing `aria-label` or `aria-labelledby` — provide an accessible label for screen readers.',
|
|
25
25
|
span: getSpan(node),
|
|
26
26
|
})
|
|
27
27
|
}
|
|
@@ -1,28 +1,28 @@
|
|
|
1
|
-
import type { Rule, VisitorCallbacks } from
|
|
2
|
-
import { getSpan, hasJSXAttribute } from
|
|
1
|
+
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
|
+
import { getSpan, hasJSXAttribute } from '../../utils/ast'
|
|
3
3
|
|
|
4
4
|
export const overlayA11y: Rule = {
|
|
5
5
|
meta: {
|
|
6
|
-
id:
|
|
7
|
-
category:
|
|
8
|
-
description:
|
|
9
|
-
severity:
|
|
6
|
+
id: 'pyreon/overlay-a11y',
|
|
7
|
+
category: 'accessibility',
|
|
8
|
+
description: 'Warn when <Overlay> is missing role, aria-label, or aria-labelledby.',
|
|
9
|
+
severity: 'warn',
|
|
10
10
|
fixable: false,
|
|
11
11
|
},
|
|
12
12
|
create(context) {
|
|
13
13
|
const callbacks: VisitorCallbacks = {
|
|
14
14
|
JSXOpeningElement(node: any) {
|
|
15
15
|
const name = node.name
|
|
16
|
-
if (!name || name.type !==
|
|
16
|
+
if (!name || name.type !== 'JSXIdentifier' || name.name !== 'Overlay') return
|
|
17
17
|
|
|
18
|
-
const hasRole = hasJSXAttribute(node,
|
|
19
|
-
const hasLabel = hasJSXAttribute(node,
|
|
20
|
-
const hasLabelledBy = hasJSXAttribute(node,
|
|
18
|
+
const hasRole = hasJSXAttribute(node, 'role')
|
|
19
|
+
const hasLabel = hasJSXAttribute(node, 'aria-label')
|
|
20
|
+
const hasLabelledBy = hasJSXAttribute(node, 'aria-labelledby')
|
|
21
21
|
|
|
22
22
|
if (!hasRole && !hasLabel && !hasLabelledBy) {
|
|
23
23
|
context.report({
|
|
24
24
|
message:
|
|
25
|
-
|
|
25
|
+
'`<Overlay>` missing `role`, `aria-label`, or `aria-labelledby` — provide accessibility attributes for screen readers.',
|
|
26
26
|
span: getSpan(node),
|
|
27
27
|
})
|
|
28
28
|
}
|
|
@@ -1,29 +1,29 @@
|
|
|
1
|
-
import type { Rule, VisitorCallbacks } from
|
|
2
|
-
import { getSpan, hasJSXAttribute } from
|
|
1
|
+
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
|
+
import { getSpan, hasJSXAttribute } from '../../utils/ast'
|
|
3
3
|
|
|
4
4
|
export const toastA11y: Rule = {
|
|
5
5
|
meta: {
|
|
6
|
-
id:
|
|
7
|
-
category:
|
|
8
|
-
description:
|
|
9
|
-
severity:
|
|
6
|
+
id: 'pyreon/toast-a11y',
|
|
7
|
+
category: 'accessibility',
|
|
8
|
+
description: 'Warn when toast-like components are missing role or aria-live attributes.',
|
|
9
|
+
severity: 'warn',
|
|
10
10
|
fixable: false,
|
|
11
11
|
},
|
|
12
12
|
create(context) {
|
|
13
13
|
const callbacks: VisitorCallbacks = {
|
|
14
14
|
JSXOpeningElement(node: any) {
|
|
15
15
|
const name = node.name
|
|
16
|
-
if (!name || name.type !==
|
|
16
|
+
if (!name || name.type !== 'JSXIdentifier') return
|
|
17
17
|
|
|
18
18
|
const tagName: string = name.name
|
|
19
19
|
// Skip non-PascalCase and the Toaster container itself
|
|
20
|
-
if (tagName ===
|
|
20
|
+
if (tagName === 'Toaster') return
|
|
21
21
|
const firstChar = tagName[0]
|
|
22
22
|
if (!firstChar || firstChar !== firstChar.toUpperCase()) return
|
|
23
|
-
if (!tagName.toLowerCase().includes(
|
|
23
|
+
if (!tagName.toLowerCase().includes('toast')) return
|
|
24
24
|
|
|
25
|
-
const hasRole = hasJSXAttribute(node,
|
|
26
|
-
const hasAriaLive = hasJSXAttribute(node,
|
|
25
|
+
const hasRole = hasJSXAttribute(node, 'role')
|
|
26
|
+
const hasAriaLive = hasJSXAttribute(node, 'aria-live')
|
|
27
27
|
|
|
28
28
|
if (!hasRole && !hasAriaLive) {
|
|
29
29
|
context.report({
|
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
import type { Rule, VisitorCallbacks } from
|
|
2
|
-
import { getSpan } from
|
|
1
|
+
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
|
+
import { getSpan } from '../../utils/ast'
|
|
3
3
|
|
|
4
4
|
export const devGuardWarnings: Rule = {
|
|
5
5
|
meta: {
|
|
6
|
-
id:
|
|
7
|
-
category:
|
|
8
|
-
description:
|
|
9
|
-
severity:
|
|
6
|
+
id: 'pyreon/dev-guard-warnings',
|
|
7
|
+
category: 'architecture',
|
|
8
|
+
description: 'Require console.warn/error calls to be wrapped in `if (__DEV__)` guards.',
|
|
9
|
+
severity: 'error',
|
|
10
10
|
fixable: false,
|
|
11
11
|
},
|
|
12
12
|
create(context) {
|
|
13
13
|
const filePath = context.getFilePath()
|
|
14
14
|
// Skip test and example files
|
|
15
15
|
if (
|
|
16
|
-
filePath.includes(
|
|
17
|
-
filePath.includes(
|
|
18
|
-
filePath.includes(
|
|
19
|
-
filePath.includes(
|
|
20
|
-
filePath.includes(
|
|
16
|
+
filePath.includes('/tests/') ||
|
|
17
|
+
filePath.includes('/test/') ||
|
|
18
|
+
filePath.includes('/examples/') ||
|
|
19
|
+
filePath.includes('.test.') ||
|
|
20
|
+
filePath.includes('.spec.')
|
|
21
21
|
) {
|
|
22
22
|
return {}
|
|
23
23
|
}
|
|
@@ -25,12 +25,12 @@ export const devGuardWarnings: Rule = {
|
|
|
25
25
|
let devGuardDepth = 0
|
|
26
26
|
const callbacks: VisitorCallbacks = {
|
|
27
27
|
IfStatement(node: any) {
|
|
28
|
-
if (node.test?.type ===
|
|
28
|
+
if (node.test?.type === 'Identifier' && node.test.name === '__DEV__') {
|
|
29
29
|
devGuardDepth++
|
|
30
30
|
}
|
|
31
31
|
},
|
|
32
|
-
|
|
33
|
-
if (node.test?.type ===
|
|
32
|
+
'IfStatement:exit'(node: any) {
|
|
33
|
+
if (node.test?.type === 'Identifier' && node.test.name === '__DEV__') {
|
|
34
34
|
devGuardDepth--
|
|
35
35
|
}
|
|
36
36
|
},
|
|
@@ -39,11 +39,11 @@ export const devGuardWarnings: Rule = {
|
|
|
39
39
|
|
|
40
40
|
const callee = node.callee
|
|
41
41
|
if (
|
|
42
|
-
callee?.type ===
|
|
43
|
-
callee.object?.type ===
|
|
44
|
-
callee.object.name ===
|
|
45
|
-
callee.property?.type ===
|
|
46
|
-
(callee.property.name ===
|
|
42
|
+
callee?.type === 'MemberExpression' &&
|
|
43
|
+
callee.object?.type === 'Identifier' &&
|
|
44
|
+
callee.object.name === 'console' &&
|
|
45
|
+
callee.property?.type === 'Identifier' &&
|
|
46
|
+
(callee.property.name === 'warn' || callee.property.name === 'error')
|
|
47
47
|
) {
|
|
48
48
|
context.report({
|
|
49
49
|
message: `\`console.${callee.property.name}()\` without \`__DEV__\` guard — dev warnings must be tree-shakeable in production. Wrap in \`if (__DEV__) { ... }\`.`,
|
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import type { Rule, VisitorCallbacks } from
|
|
2
|
-
import { getSpan } from
|
|
3
|
-
import { isPyreonImport } from
|
|
1
|
+
import type { Rule, VisitorCallbacks } from '../../types'
|
|
2
|
+
import { getSpan } from '../../utils/ast'
|
|
3
|
+
import { isPyreonImport } from '../../utils/imports'
|
|
4
4
|
|
|
5
5
|
const LAYER_ORDER: Record<string, number> = {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
6
|
+
'@pyreon/reactivity': 0,
|
|
7
|
+
'@pyreon/core': 1,
|
|
8
|
+
'@pyreon/compiler': 1,
|
|
9
|
+
'@pyreon/runtime-dom': 2,
|
|
10
|
+
'@pyreon/runtime-server': 2,
|
|
11
|
+
'@pyreon/router': 3,
|
|
12
|
+
'@pyreon/head': 4,
|
|
13
|
+
'@pyreon/server': 5,
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
function getLayer(source: string): number | null {
|
|
@@ -19,7 +19,7 @@ function getLayer(source: string): number | null {
|
|
|
19
19
|
|
|
20
20
|
function getFileLayer(filePath: string): number | null {
|
|
21
21
|
for (const [pkg, layer] of Object.entries(LAYER_ORDER)) {
|
|
22
|
-
const pkgName = pkg.replace(
|
|
22
|
+
const pkgName = pkg.replace('@pyreon/', '')
|
|
23
23
|
if (filePath.includes(`/packages/core/${pkgName}/`)) return layer
|
|
24
24
|
}
|
|
25
25
|
return null
|
|
@@ -27,10 +27,10 @@ function getFileLayer(filePath: string): number | null {
|
|
|
27
27
|
|
|
28
28
|
export const noCircularImport: Rule = {
|
|
29
29
|
meta: {
|
|
30
|
-
id:
|
|
31
|
-
category:
|
|
32
|
-
description:
|
|
33
|
-
severity:
|
|
30
|
+
id: 'pyreon/no-circular-import',
|
|
31
|
+
category: 'architecture',
|
|
32
|
+
description: 'Enforce package layer order to prevent circular imports between core packages.',
|
|
33
|
+
severity: 'error',
|
|
34
34
|
fixable: false,
|
|
35
35
|
},
|
|
36
36
|
create(context) {
|