@pyreon/lint 0.11.5 → 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 +1 -1
- package/lib/analysis/index.js.html +1 -1
- package/lib/cli.js +214 -1
- package/lib/cli.js.map +1 -1
- package/lib/index.js +207 -1
- 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 +15 -15
- package/src/cache.ts +1 -1
- package/src/cli.ts +38 -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 -19
- 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 -13
- 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 +5 -5
- package/src/utils/source.ts +2 -2
- package/src/watcher.ts +19 -19
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) {
|
|
@@ -1,60 +1,60 @@
|
|
|
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
|
-
type PackageCategory =
|
|
5
|
+
type PackageCategory = 'core' | 'fundamentals' | 'tools' | 'ui-system'
|
|
6
6
|
|
|
7
7
|
const CORE_PACKAGES = new Set([
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
8
|
+
'@pyreon/reactivity',
|
|
9
|
+
'@pyreon/core',
|
|
10
|
+
'@pyreon/compiler',
|
|
11
|
+
'@pyreon/runtime-dom',
|
|
12
|
+
'@pyreon/runtime-server',
|
|
13
|
+
'@pyreon/router',
|
|
14
|
+
'@pyreon/head',
|
|
15
|
+
'@pyreon/server',
|
|
16
16
|
])
|
|
17
17
|
|
|
18
18
|
const UI_PACKAGES = new Set([
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
19
|
+
'@pyreon/ui-core',
|
|
20
|
+
'@pyreon/styler',
|
|
21
|
+
'@pyreon/unistyle',
|
|
22
|
+
'@pyreon/elements',
|
|
23
|
+
'@pyreon/attrs',
|
|
24
|
+
'@pyreon/rocketstyle',
|
|
25
|
+
'@pyreon/coolgrid',
|
|
26
|
+
'@pyreon/kinetic',
|
|
27
|
+
'@pyreon/kinetic-presets',
|
|
28
|
+
'@pyreon/connector-document',
|
|
29
|
+
'@pyreon/document-primitives',
|
|
30
30
|
])
|
|
31
31
|
|
|
32
32
|
function getImportCategory(source: string): PackageCategory | null {
|
|
33
|
-
if (CORE_PACKAGES.has(source)) return
|
|
34
|
-
if (UI_PACKAGES.has(source)) return
|
|
33
|
+
if (CORE_PACKAGES.has(source)) return 'core'
|
|
34
|
+
if (UI_PACKAGES.has(source)) return 'ui-system'
|
|
35
35
|
return null
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
function getFileCategory(filePath: string): PackageCategory | null {
|
|
39
|
-
if (filePath.includes(
|
|
40
|
-
if (filePath.includes(
|
|
41
|
-
if (filePath.includes(
|
|
42
|
-
if (filePath.includes(
|
|
39
|
+
if (filePath.includes('/packages/core/')) return 'core'
|
|
40
|
+
if (filePath.includes('/packages/ui-system/')) return 'ui-system'
|
|
41
|
+
if (filePath.includes('/packages/fundamentals/')) return 'fundamentals'
|
|
42
|
+
if (filePath.includes('/packages/tools/')) return 'tools'
|
|
43
43
|
return null
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
export const noCrossLayerImport: Rule = {
|
|
47
47
|
meta: {
|
|
48
|
-
id:
|
|
49
|
-
category:
|
|
50
|
-
description:
|
|
51
|
-
severity:
|
|
48
|
+
id: 'pyreon/no-cross-layer-import',
|
|
49
|
+
category: 'architecture',
|
|
50
|
+
description: 'Prevent core packages from importing ui-system packages.',
|
|
51
|
+
severity: 'error',
|
|
52
52
|
fixable: false,
|
|
53
53
|
},
|
|
54
54
|
create(context) {
|
|
55
55
|
const filePath = context.getFilePath()
|
|
56
56
|
const fileCategory = getFileCategory(filePath)
|
|
57
|
-
if (fileCategory !==
|
|
57
|
+
if (fileCategory !== 'core') return {}
|
|
58
58
|
|
|
59
59
|
const callbacks: VisitorCallbacks = {
|
|
60
60
|
ImportDeclaration(node: any) {
|
|
@@ -62,7 +62,7 @@ export const noCrossLayerImport: Rule = {
|
|
|
62
62
|
if (!source || !isPyreonImport(source)) return
|
|
63
63
|
|
|
64
64
|
const importCategory = getImportCategory(source)
|
|
65
|
-
if (importCategory ===
|
|
65
|
+
if (importCategory === 'ui-system') {
|
|
66
66
|
context.report({
|
|
67
67
|
message: `Core package importing ui-system package \`${source}\` — core packages must not depend on ui-system.`,
|
|
68
68
|
span: getSpan(node),
|