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