@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,59 @@
|
|
|
1
|
+
import type { Rule, VisitorCallbacks } from "../../types"
|
|
2
|
+
import { getSpan } from "../../utils/ast"
|
|
3
|
+
import { extractImportInfo } from "../../utils/imports"
|
|
4
|
+
|
|
5
|
+
export const noStoreOutsideProvider: Rule = {
|
|
6
|
+
meta: {
|
|
7
|
+
id: "pyreon/no-store-outside-provider",
|
|
8
|
+
category: "store",
|
|
9
|
+
description: "Warn when store hooks are used in SSR files without a provider import.",
|
|
10
|
+
severity: "warn",
|
|
11
|
+
fixable: false,
|
|
12
|
+
},
|
|
13
|
+
create(context) {
|
|
14
|
+
const filePath = context.getFilePath()
|
|
15
|
+
const isServerFile =
|
|
16
|
+
filePath.includes("server") ||
|
|
17
|
+
filePath.includes(".server.") ||
|
|
18
|
+
filePath.endsWith("server.ts") ||
|
|
19
|
+
filePath.endsWith("server.tsx")
|
|
20
|
+
|
|
21
|
+
if (!isServerFile) return {}
|
|
22
|
+
|
|
23
|
+
let hasProviderImport = false
|
|
24
|
+
const storeHookCalls: Array<{ name: string; span: { start: number; end: number } }> = []
|
|
25
|
+
|
|
26
|
+
const callbacks: VisitorCallbacks = {
|
|
27
|
+
ImportDeclaration(node: any) {
|
|
28
|
+
const info = extractImportInfo(node)
|
|
29
|
+
if (!info) return
|
|
30
|
+
if (
|
|
31
|
+
info.specifiers.some(
|
|
32
|
+
(s) =>
|
|
33
|
+
s.imported === "setStoreRegistryProvider" || s.imported === "runWithRequestContext",
|
|
34
|
+
)
|
|
35
|
+
) {
|
|
36
|
+
hasProviderImport = true
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
CallExpression(node: any) {
|
|
40
|
+
const callee = node.callee
|
|
41
|
+
if (!callee || callee.type !== "Identifier") return
|
|
42
|
+
const name: string = callee.name
|
|
43
|
+
if (name.endsWith("Store") && name.startsWith("use")) {
|
|
44
|
+
storeHookCalls.push({ name, span: getSpan(node) })
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"Program:exit"() {
|
|
48
|
+
if (hasProviderImport) return
|
|
49
|
+
for (const call of storeHookCalls) {
|
|
50
|
+
context.report({
|
|
51
|
+
message: `\`${call.name}()\` in a server file without a store registry provider — use \`runWithRequestContext()\` or \`setStoreRegistryProvider()\` for SSR isolation.`,
|
|
52
|
+
span: call.span,
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
return callbacks
|
|
58
|
+
},
|
|
59
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Rule, VisitorCallbacks } from "../../types"
|
|
2
|
+
import { getSpan, isCallTo } from "../../utils/ast"
|
|
3
|
+
|
|
4
|
+
export const noDynamicStyled: Rule = {
|
|
5
|
+
meta: {
|
|
6
|
+
id: "pyreon/no-dynamic-styled",
|
|
7
|
+
category: "styling",
|
|
8
|
+
description:
|
|
9
|
+
"Warn when styled() is called inside a function — it creates new CSS on every render.",
|
|
10
|
+
severity: "warn",
|
|
11
|
+
fixable: false,
|
|
12
|
+
},
|
|
13
|
+
create(context) {
|
|
14
|
+
let functionDepth = 0
|
|
15
|
+
const callbacks: VisitorCallbacks = {
|
|
16
|
+
FunctionDeclaration() {
|
|
17
|
+
functionDepth++
|
|
18
|
+
},
|
|
19
|
+
"FunctionDeclaration:exit"() {
|
|
20
|
+
functionDepth--
|
|
21
|
+
},
|
|
22
|
+
FunctionExpression() {
|
|
23
|
+
functionDepth++
|
|
24
|
+
},
|
|
25
|
+
"FunctionExpression:exit"() {
|
|
26
|
+
functionDepth--
|
|
27
|
+
},
|
|
28
|
+
ArrowFunctionExpression() {
|
|
29
|
+
functionDepth++
|
|
30
|
+
},
|
|
31
|
+
"ArrowFunctionExpression:exit"() {
|
|
32
|
+
functionDepth--
|
|
33
|
+
},
|
|
34
|
+
CallExpression(node: any) {
|
|
35
|
+
if (functionDepth === 0) return
|
|
36
|
+
if (isCallTo(node, "styled")) {
|
|
37
|
+
context.report({
|
|
38
|
+
message:
|
|
39
|
+
"`styled()` inside a function — this creates new CSS rules on every render. Move `styled()` to module scope.",
|
|
40
|
+
span: getSpan(node),
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
TaggedTemplateExpression(node: any) {
|
|
45
|
+
if (functionDepth === 0) return
|
|
46
|
+
const tag = node.tag
|
|
47
|
+
if (!tag) return
|
|
48
|
+
// styled('div')`...` — tag is a CallExpression of styled
|
|
49
|
+
if (tag.type === "CallExpression" && isCallTo(tag, "styled")) {
|
|
50
|
+
context.report({
|
|
51
|
+
message:
|
|
52
|
+
"`styled()` tagged template inside a function — this creates new CSS rules on every render. Move to module scope.",
|
|
53
|
+
span: getSpan(node),
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
return callbacks
|
|
59
|
+
},
|
|
60
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Rule, VisitorCallbacks } from "../../types"
|
|
2
|
+
import { getSpan } from "../../utils/ast"
|
|
3
|
+
|
|
4
|
+
export const noInlineStyleObject: Rule = {
|
|
5
|
+
meta: {
|
|
6
|
+
id: "pyreon/no-inline-style-object",
|
|
7
|
+
category: "styling",
|
|
8
|
+
description: "Warn against inline style objects in JSX — prefer styled() or css``.",
|
|
9
|
+
severity: "warn",
|
|
10
|
+
fixable: false,
|
|
11
|
+
},
|
|
12
|
+
create(context) {
|
|
13
|
+
const callbacks: VisitorCallbacks = {
|
|
14
|
+
JSXAttribute(node: any) {
|
|
15
|
+
if (node.name?.type !== "JSXIdentifier" || node.name.name !== "style") return
|
|
16
|
+
const value = node.value
|
|
17
|
+
if (!value || value.type !== "JSXExpressionContainer") return
|
|
18
|
+
const expr = value.expression
|
|
19
|
+
if (expr?.type === "ObjectExpression") {
|
|
20
|
+
context.report({
|
|
21
|
+
message:
|
|
22
|
+
"Inline style object in JSX — consider using `styled()` or `css\\`...\\`` for better performance and caching.",
|
|
23
|
+
span: getSpan(node),
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
return callbacks
|
|
29
|
+
},
|
|
30
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Rule, VisitorCallbacks } from "../../types"
|
|
2
|
+
import { getSpan, isCallTo } from "../../utils/ast"
|
|
3
|
+
import { extractImportInfo } from "../../utils/imports"
|
|
4
|
+
|
|
5
|
+
export const noThemeOutsideProvider: Rule = {
|
|
6
|
+
meta: {
|
|
7
|
+
id: "pyreon/no-theme-outside-provider",
|
|
8
|
+
category: "styling",
|
|
9
|
+
description: "Warn when useTheme() is used without PyreonUI or ThemeProvider in the same file.",
|
|
10
|
+
severity: "warn",
|
|
11
|
+
fixable: false,
|
|
12
|
+
},
|
|
13
|
+
create(context) {
|
|
14
|
+
let hasProviderImport = false
|
|
15
|
+
const themeCalls: Array<{ span: { start: number; end: number } }> = []
|
|
16
|
+
|
|
17
|
+
const callbacks: VisitorCallbacks = {
|
|
18
|
+
ImportDeclaration(node: any) {
|
|
19
|
+
const info = extractImportInfo(node)
|
|
20
|
+
if (!info) return
|
|
21
|
+
if (
|
|
22
|
+
info.specifiers.some((s) => s.imported === "PyreonUI" || s.imported === "ThemeProvider")
|
|
23
|
+
) {
|
|
24
|
+
hasProviderImport = true
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
CallExpression(node: any) {
|
|
28
|
+
if (isCallTo(node, "useTheme")) {
|
|
29
|
+
themeCalls.push({ span: getSpan(node) })
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"Program:exit"() {
|
|
33
|
+
if (hasProviderImport) return
|
|
34
|
+
for (const call of themeCalls) {
|
|
35
|
+
context.report({
|
|
36
|
+
message:
|
|
37
|
+
"`useTheme()` without a `PyreonUI` or `ThemeProvider` import — the theme context may not be available.",
|
|
38
|
+
span: call.span,
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
return callbacks
|
|
44
|
+
},
|
|
45
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Rule, VisitorCallbacks } from "../../types"
|
|
2
|
+
import { getSpan } from "../../utils/ast"
|
|
3
|
+
|
|
4
|
+
export const preferCx: Rule = {
|
|
5
|
+
meta: {
|
|
6
|
+
id: "pyreon/prefer-cx",
|
|
7
|
+
category: "styling",
|
|
8
|
+
description:
|
|
9
|
+
"Suggest cx() for class composition instead of string concatenation or template literals.",
|
|
10
|
+
severity: "info",
|
|
11
|
+
fixable: false,
|
|
12
|
+
},
|
|
13
|
+
create(context) {
|
|
14
|
+
const callbacks: VisitorCallbacks = {
|
|
15
|
+
JSXAttribute(node: any) {
|
|
16
|
+
if (node.name?.type !== "JSXIdentifier" || node.name.name !== "class") return
|
|
17
|
+
const value = node.value
|
|
18
|
+
if (!value || value.type !== "JSXExpressionContainer") return
|
|
19
|
+
const expr = value.expression
|
|
20
|
+
if (!expr) return
|
|
21
|
+
|
|
22
|
+
// String concatenation: "foo " + bar
|
|
23
|
+
if (expr.type === "BinaryExpression" && expr.operator === "+") {
|
|
24
|
+
context.report({
|
|
25
|
+
message:
|
|
26
|
+
"String concatenation in `class` attribute — use `cx()` for cleaner class composition.",
|
|
27
|
+
span: getSpan(expr),
|
|
28
|
+
})
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Template literal: `foo ${bar}`
|
|
33
|
+
if (expr.type === "TemplateLiteral" && expr.expressions?.length > 0) {
|
|
34
|
+
context.report({
|
|
35
|
+
message:
|
|
36
|
+
"Template literal in `class` attribute — use `cx()` for cleaner class composition.",
|
|
37
|
+
span: getSpan(expr),
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
return callbacks
|
|
43
|
+
},
|
|
44
|
+
}
|
package/src/runner.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { parseSync, Visitor } from "oxc-parser"
|
|
2
|
+
import type { AstCache } from "./cache"
|
|
3
|
+
import type {
|
|
4
|
+
Diagnostic,
|
|
5
|
+
LintConfig,
|
|
6
|
+
LintFileResult,
|
|
7
|
+
Rule,
|
|
8
|
+
RuleContext,
|
|
9
|
+
Severity,
|
|
10
|
+
VisitorCallbacks,
|
|
11
|
+
} from "./types"
|
|
12
|
+
import { LineIndex } from "./utils/source"
|
|
13
|
+
|
|
14
|
+
const JS_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mts", ".mjs"])
|
|
15
|
+
|
|
16
|
+
function getExtension(filePath: string): string {
|
|
17
|
+
const lastDot = filePath.lastIndexOf(".")
|
|
18
|
+
return lastDot === -1 ? "" : filePath.slice(lastDot)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type OxcLang = "jsx" | "tsx" | "ts" | "js" | "dts"
|
|
22
|
+
|
|
23
|
+
function getLang(ext: string): OxcLang {
|
|
24
|
+
if (ext === ".tsx" || ext === ".jsx") return "tsx"
|
|
25
|
+
if (ext === ".ts" || ext === ".mts") return "ts"
|
|
26
|
+
return "js"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createRuleContext(
|
|
30
|
+
rule: Rule,
|
|
31
|
+
severity: Severity,
|
|
32
|
+
diagnostics: Diagnostic[],
|
|
33
|
+
lineIndex: LineIndex,
|
|
34
|
+
sourceText: string,
|
|
35
|
+
filePath: string,
|
|
36
|
+
): RuleContext {
|
|
37
|
+
return {
|
|
38
|
+
report(partial) {
|
|
39
|
+
diagnostics.push({
|
|
40
|
+
ruleId: rule.meta.id,
|
|
41
|
+
severity,
|
|
42
|
+
message: partial.message,
|
|
43
|
+
span: partial.span,
|
|
44
|
+
loc: lineIndex.locate(partial.span.start),
|
|
45
|
+
fix: partial.fix,
|
|
46
|
+
})
|
|
47
|
+
},
|
|
48
|
+
getSourceText() {
|
|
49
|
+
return sourceText
|
|
50
|
+
},
|
|
51
|
+
getFilePath() {
|
|
52
|
+
return filePath
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function mergeCallbacks(allCallbacks: VisitorCallbacks[]): Record<string, (node: any) => void> {
|
|
58
|
+
const callbacksByKey: Record<string, Array<(node: any) => void>> = {}
|
|
59
|
+
|
|
60
|
+
for (const callbacks of allCallbacks) {
|
|
61
|
+
for (const [key, fn] of Object.entries(callbacks)) {
|
|
62
|
+
const existing = callbacksByKey[key]
|
|
63
|
+
if (existing) {
|
|
64
|
+
existing.push(fn as (node: any) => void)
|
|
65
|
+
} else {
|
|
66
|
+
callbacksByKey[key] = [fn as (node: any) => void]
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const merged: Record<string, (node: any) => void> = {}
|
|
72
|
+
for (const [key, fns] of Object.entries(callbacksByKey)) {
|
|
73
|
+
const first = fns[0]
|
|
74
|
+
if (fns.length === 1 && first) {
|
|
75
|
+
merged[key] = first
|
|
76
|
+
} else {
|
|
77
|
+
merged[key] = (node: any) => {
|
|
78
|
+
for (const fn of fns) fn(node)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return merged
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Lint a single file and return diagnostics.
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* ```ts
|
|
90
|
+
* const result = lintFile("app.tsx", source, allRules, getPreset("recommended"))
|
|
91
|
+
* for (const d of result.diagnostics) console.log(d.message)
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
export function lintFile(
|
|
95
|
+
filePath: string,
|
|
96
|
+
sourceText: string,
|
|
97
|
+
rules: Rule[],
|
|
98
|
+
config: LintConfig,
|
|
99
|
+
cache?: AstCache | undefined,
|
|
100
|
+
): LintFileResult {
|
|
101
|
+
const ext = getExtension(filePath)
|
|
102
|
+
if (!JS_EXTENSIONS.has(ext)) {
|
|
103
|
+
return { filePath, diagnostics: [] }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Try cache first
|
|
107
|
+
let lineIndex: LineIndex
|
|
108
|
+
let program: any
|
|
109
|
+
const cached = cache?.get(sourceText)
|
|
110
|
+
if (cached) {
|
|
111
|
+
lineIndex = cached.lineIndex
|
|
112
|
+
program = cached.program
|
|
113
|
+
} else {
|
|
114
|
+
lineIndex = new LineIndex(sourceText)
|
|
115
|
+
try {
|
|
116
|
+
const result = parseSync(filePath, sourceText, {
|
|
117
|
+
sourceType: "module",
|
|
118
|
+
lang: getLang(ext),
|
|
119
|
+
})
|
|
120
|
+
program = result.program
|
|
121
|
+
} catch {
|
|
122
|
+
return { filePath, diagnostics: [] }
|
|
123
|
+
}
|
|
124
|
+
cache?.set(sourceText, { program, lineIndex })
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const diagnostics: Diagnostic[] = []
|
|
128
|
+
|
|
129
|
+
// Filter to enabled rules and create visitor callbacks
|
|
130
|
+
const allCallbacks: VisitorCallbacks[] = []
|
|
131
|
+
for (const rule of rules) {
|
|
132
|
+
const severity = config.rules[rule.meta.id]
|
|
133
|
+
if (severity === undefined || severity === "off") continue
|
|
134
|
+
const ctx = createRuleContext(rule, severity, diagnostics, lineIndex, sourceText, filePath)
|
|
135
|
+
allCallbacks.push(rule.create(ctx))
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Walk the AST
|
|
139
|
+
const visitor = new Visitor(mergeCallbacks(allCallbacks))
|
|
140
|
+
visitor.visit(program)
|
|
141
|
+
|
|
142
|
+
diagnostics.sort((a, b) => a.span.start - b.span.start)
|
|
143
|
+
return { filePath, diagnostics }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Apply all auto-fixes to a source text.
|
|
148
|
+
* Fixes are applied in reverse order to maintain correct offsets.
|
|
149
|
+
*/
|
|
150
|
+
export function applyFixes(sourceText: string, diagnostics: Diagnostic[]): string {
|
|
151
|
+
const fixable = diagnostics.filter((d) => d.fix !== undefined)
|
|
152
|
+
if (fixable.length === 0) return sourceText
|
|
153
|
+
|
|
154
|
+
// Sort by start position descending (apply from end to start)
|
|
155
|
+
const sorted = [...fixable].sort((a, b) => {
|
|
156
|
+
const aFix = a.fix
|
|
157
|
+
const bFix = b.fix
|
|
158
|
+
if (!aFix || !bFix) return 0
|
|
159
|
+
return bFix.span.start - aFix.span.start
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
let result = sourceText
|
|
163
|
+
for (const diag of sorted) {
|
|
164
|
+
const fix = diag.fix
|
|
165
|
+
if (!fix) continue
|
|
166
|
+
result = result.slice(0, fix.span.start) + fix.replacement + result.slice(fix.span.end)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return result
|
|
170
|
+
}
|