@ttsc/lint 0.8.0-dev.20260505 → 0.8.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/README.md +91 -18
- package/lib/config.d.ts +3 -0
- package/lib/config.js +3 -0
- package/lib/config.js.map +1 -0
- package/lib/structures/ITtscLintPluginConfig.d.ts +3 -2
- package/lib/structures/TtscLintConfig.d.ts +2 -3
- package/package.json +6 -2
- package/plugin/compile.go +27 -10
- package/plugin/config.go +760 -29
- package/plugin/directives.go +296 -0
- package/plugin/engine.go +29 -9
- package/plugin/eslint_runtime.go +246 -0
- package/src/config.ts +3 -0
- package/src/structures/ITtscLintPluginConfig.ts +3 -2
- package/src/structures/TtscLintConfig.ts +2 -3
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strings"
|
|
5
|
+
|
|
6
|
+
shimast "github.com/microsoft/typescript-go/shim/ast"
|
|
7
|
+
shimscanner "github.com/microsoft/typescript-go/shim/scanner"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
type lintDirectiveKind int
|
|
11
|
+
|
|
12
|
+
const (
|
|
13
|
+
lintDirectiveDisable lintDirectiveKind = iota
|
|
14
|
+
lintDirectiveEnable
|
|
15
|
+
lintDirectiveDisableLine
|
|
16
|
+
lintDirectiveDisableNextLine
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
type lintDirective struct {
|
|
20
|
+
kind lintDirectiveKind
|
|
21
|
+
rules lintDirectiveRules
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type lintDirectiveRules struct {
|
|
25
|
+
all bool
|
|
26
|
+
rules map[string]struct{}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type lintDirectiveEvent struct {
|
|
30
|
+
pos int
|
|
31
|
+
rules lintDirectiveRules
|
|
32
|
+
on bool
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type lintInlineDirectives struct {
|
|
36
|
+
lines map[int][]lintDirectiveRules
|
|
37
|
+
events []lintDirectiveEvent
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type lintDisableState struct {
|
|
41
|
+
all bool
|
|
42
|
+
rules map[string]struct{}
|
|
43
|
+
enabledInAll map[string]struct{}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func filterInlineDisabledFindings(file *shimast.SourceFile, findings []*Finding) []*Finding {
|
|
47
|
+
if len(findings) == 0 || file == nil {
|
|
48
|
+
return findings
|
|
49
|
+
}
|
|
50
|
+
directives := parseLintInlineDirectives(file)
|
|
51
|
+
if directives.empty() {
|
|
52
|
+
return findings
|
|
53
|
+
}
|
|
54
|
+
filtered := findings[:0]
|
|
55
|
+
for _, finding := range findings {
|
|
56
|
+
if finding == nil || !directives.suppresses(file, finding) {
|
|
57
|
+
filtered = append(filtered, finding)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return filtered
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
func parseLintInlineDirectives(file *shimast.SourceFile) *lintInlineDirectives {
|
|
64
|
+
directives := &lintInlineDirectives{
|
|
65
|
+
lines: make(map[int][]lintDirectiveRules),
|
|
66
|
+
}
|
|
67
|
+
scanner := shimscanner.NewScanner()
|
|
68
|
+
scanner.SetText(file.Text())
|
|
69
|
+
scanner.SetSkipTrivia(false)
|
|
70
|
+
|
|
71
|
+
scan:
|
|
72
|
+
for {
|
|
73
|
+
kind := scanner.Scan()
|
|
74
|
+
switch kind {
|
|
75
|
+
case shimast.KindEndOfFile:
|
|
76
|
+
break scan
|
|
77
|
+
case shimast.KindSingleLineCommentTrivia, shimast.KindMultiLineCommentTrivia:
|
|
78
|
+
default:
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
directive, ok := parseLintDirectiveComment(scanner.TokenText())
|
|
82
|
+
if !ok {
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
start := scanner.TokenStart()
|
|
86
|
+
end := scanner.TokenEnd()
|
|
87
|
+
startLine := shimscanner.GetECMALineOfPosition(file, start)
|
|
88
|
+
endLine := startLine
|
|
89
|
+
if end > start {
|
|
90
|
+
endLine = shimscanner.GetECMALineOfPosition(file, end-1)
|
|
91
|
+
}
|
|
92
|
+
switch directive.kind {
|
|
93
|
+
case lintDirectiveDisableLine:
|
|
94
|
+
directives.lines[startLine] = append(directives.lines[startLine], directive.rules)
|
|
95
|
+
case lintDirectiveDisableNextLine:
|
|
96
|
+
directives.lines[endLine+1] = append(directives.lines[endLine+1], directive.rules)
|
|
97
|
+
case lintDirectiveDisable:
|
|
98
|
+
directives.events = append(directives.events, lintDirectiveEvent{
|
|
99
|
+
pos: start,
|
|
100
|
+
rules: directive.rules,
|
|
101
|
+
on: true,
|
|
102
|
+
})
|
|
103
|
+
case lintDirectiveEnable:
|
|
104
|
+
directives.events = append(directives.events, lintDirectiveEvent{
|
|
105
|
+
pos: start,
|
|
106
|
+
rules: directive.rules,
|
|
107
|
+
on: false,
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return directives
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
func (d *lintInlineDirectives) empty() bool {
|
|
115
|
+
return d == nil || (len(d.lines) == 0 && len(d.events) == 0)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
func (d *lintInlineDirectives) suppresses(file *shimast.SourceFile, finding *Finding) bool {
|
|
119
|
+
if d == nil || finding == nil || file == nil {
|
|
120
|
+
return false
|
|
121
|
+
}
|
|
122
|
+
line := shimscanner.GetECMALineOfPosition(file, finding.Pos)
|
|
123
|
+
for _, rules := range d.lines[line] {
|
|
124
|
+
if rules.matches(finding.Rule) {
|
|
125
|
+
return true
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
var state lintDisableState
|
|
129
|
+
for _, event := range d.events {
|
|
130
|
+
if event.pos > finding.Pos {
|
|
131
|
+
break
|
|
132
|
+
}
|
|
133
|
+
state.apply(event)
|
|
134
|
+
}
|
|
135
|
+
return state.matches(finding.Rule)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
func (s *lintDisableState) apply(event lintDirectiveEvent) {
|
|
139
|
+
if event.on {
|
|
140
|
+
if event.rules.all {
|
|
141
|
+
s.all = true
|
|
142
|
+
s.enabledInAll = nil
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
if s.rules == nil {
|
|
146
|
+
s.rules = make(map[string]struct{}, len(event.rules.rules))
|
|
147
|
+
}
|
|
148
|
+
for rule := range event.rules.rules {
|
|
149
|
+
s.rules[rule] = struct{}{}
|
|
150
|
+
delete(s.enabledInAll, rule)
|
|
151
|
+
}
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if event.rules.all {
|
|
156
|
+
s.all = false
|
|
157
|
+
s.rules = nil
|
|
158
|
+
s.enabledInAll = nil
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
for rule := range event.rules.rules {
|
|
162
|
+
delete(s.rules, rule)
|
|
163
|
+
if s.all {
|
|
164
|
+
if s.enabledInAll == nil {
|
|
165
|
+
s.enabledInAll = make(map[string]struct{}, len(event.rules.rules))
|
|
166
|
+
}
|
|
167
|
+
s.enabledInAll[rule] = struct{}{}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
func (s lintDisableState) matches(rule string) bool {
|
|
173
|
+
normalized := normalizeDirectiveRuleName(rule)
|
|
174
|
+
if _, ok := s.rules[normalized]; ok {
|
|
175
|
+
return true
|
|
176
|
+
}
|
|
177
|
+
if !s.all {
|
|
178
|
+
return false
|
|
179
|
+
}
|
|
180
|
+
_, enabled := s.enabledInAll[normalized]
|
|
181
|
+
return !enabled
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
func (r lintDirectiveRules) matches(rule string) bool {
|
|
185
|
+
if r.all {
|
|
186
|
+
return true
|
|
187
|
+
}
|
|
188
|
+
_, ok := r.rules[normalizeDirectiveRuleName(rule)]
|
|
189
|
+
return ok
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
func parseLintDirectiveComment(raw string) (lintDirective, bool) {
|
|
193
|
+
text := stripCommentDelimiters(raw)
|
|
194
|
+
if directive, ok := parseLintDirectiveLine(text); ok {
|
|
195
|
+
return directive, true
|
|
196
|
+
}
|
|
197
|
+
return lintDirective{}, false
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
func stripCommentDelimiters(raw string) string {
|
|
201
|
+
switch {
|
|
202
|
+
case strings.HasPrefix(raw, "//"):
|
|
203
|
+
return strings.TrimSpace(raw[2:])
|
|
204
|
+
case strings.HasPrefix(raw, "/*"):
|
|
205
|
+
text := raw[2:]
|
|
206
|
+
if strings.HasSuffix(text, "*/") {
|
|
207
|
+
text = text[:len(text)-2]
|
|
208
|
+
}
|
|
209
|
+
text = strings.TrimSpace(text)
|
|
210
|
+
if strings.HasPrefix(text, "*") {
|
|
211
|
+
text = strings.TrimSpace(text[1:])
|
|
212
|
+
}
|
|
213
|
+
return text
|
|
214
|
+
default:
|
|
215
|
+
return strings.TrimSpace(raw)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
func parseLintDirectiveLine(text string) (lintDirective, bool) {
|
|
220
|
+
for _, prefix := range []string{"eslint", "lint"} {
|
|
221
|
+
for _, form := range []struct {
|
|
222
|
+
suffix string
|
|
223
|
+
kind lintDirectiveKind
|
|
224
|
+
}{
|
|
225
|
+
{"disable-next-line", lintDirectiveDisableNextLine},
|
|
226
|
+
{"disable-line", lintDirectiveDisableLine},
|
|
227
|
+
{"disable", lintDirectiveDisable},
|
|
228
|
+
{"enable", lintDirectiveEnable},
|
|
229
|
+
} {
|
|
230
|
+
marker := prefix + "-" + form.suffix
|
|
231
|
+
payload, ok := directivePayload(text, marker)
|
|
232
|
+
if !ok {
|
|
233
|
+
continue
|
|
234
|
+
}
|
|
235
|
+
return lintDirective{
|
|
236
|
+
kind: form.kind,
|
|
237
|
+
rules: parseDirectiveRules(payload),
|
|
238
|
+
}, true
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return lintDirective{}, false
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
func directivePayload(text, marker string) (string, bool) {
|
|
245
|
+
if !strings.HasPrefix(text, marker) {
|
|
246
|
+
return "", false
|
|
247
|
+
}
|
|
248
|
+
rest := text[len(marker):]
|
|
249
|
+
if rest != "" && rest[0] != ' ' && rest[0] != '\t' && rest[0] != '\r' && rest[0] != '\n' {
|
|
250
|
+
return "", false
|
|
251
|
+
}
|
|
252
|
+
return strings.TrimSpace(rest), true
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
func parseDirectiveRules(payload string) lintDirectiveRules {
|
|
256
|
+
payload = stripDirectiveDescription(payload)
|
|
257
|
+
payload = strings.ReplaceAll(payload, ",", " ")
|
|
258
|
+
fields := strings.Fields(payload)
|
|
259
|
+
rules := make(map[string]struct{}, len(fields))
|
|
260
|
+
for _, field := range fields {
|
|
261
|
+
if strings.HasPrefix(field, "--") {
|
|
262
|
+
break
|
|
263
|
+
}
|
|
264
|
+
name := normalizeDirectiveRuleName(field)
|
|
265
|
+
if name != "" {
|
|
266
|
+
rules[name] = struct{}{}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if len(rules) == 0 {
|
|
270
|
+
return lintDirectiveRules{all: true}
|
|
271
|
+
}
|
|
272
|
+
return lintDirectiveRules{rules: rules}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
func stripDirectiveDescription(payload string) string {
|
|
276
|
+
for i := 0; i < len(payload)-1; i++ {
|
|
277
|
+
if payload[i] != '-' || payload[i+1] != '-' {
|
|
278
|
+
continue
|
|
279
|
+
}
|
|
280
|
+
prevOK := i == 0 || payload[i-1] == ' ' || payload[i-1] == '\t'
|
|
281
|
+
next := i + 2
|
|
282
|
+
nextOK := next >= len(payload) || payload[next] == ' ' || payload[next] == '\t'
|
|
283
|
+
if prevOK && nextOK {
|
|
284
|
+
return strings.TrimSpace(payload[:i])
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return payload
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
func normalizeDirectiveRuleName(name string) string {
|
|
291
|
+
name = strings.TrimSpace(name)
|
|
292
|
+
name = strings.TrimPrefix(name, "@typescript-eslint/")
|
|
293
|
+
name = strings.TrimPrefix(name, "typescript-eslint/")
|
|
294
|
+
name = strings.TrimPrefix(name, "eslint/")
|
|
295
|
+
return name
|
|
296
|
+
}
|
package/plugin/engine.go
CHANGED
|
@@ -145,7 +145,7 @@ func AllRuleNames() []string {
|
|
|
145
145
|
// Engine binds a rule configuration to a Program and walks the AST once
|
|
146
146
|
// per source file, dispatching each visited node to its interested rules.
|
|
147
147
|
type Engine struct {
|
|
148
|
-
config
|
|
148
|
+
config RuleResolver
|
|
149
149
|
rules map[shimast.Kind][]Rule
|
|
150
150
|
enabled map[string]Severity
|
|
151
151
|
unknown []string
|
|
@@ -156,21 +156,28 @@ type Engine struct {
|
|
|
156
156
|
// an unknown rule are recorded so the caller can surface them as a
|
|
157
157
|
// configuration warning rather than a silent typo.
|
|
158
158
|
func NewEngine(config RuleConfig) *Engine {
|
|
159
|
+
return NewEngineWithResolver(config)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// NewEngineWithResolver returns an engine configured by a resolver that can
|
|
163
|
+
// vary rule severities per file.
|
|
164
|
+
func NewEngineWithResolver(config RuleResolver) *Engine {
|
|
165
|
+
if config == nil {
|
|
166
|
+
config = RuleConfig{}
|
|
167
|
+
}
|
|
159
168
|
eng := &Engine{
|
|
160
169
|
config: config,
|
|
161
170
|
rules: make(map[shimast.Kind][]Rule),
|
|
162
171
|
enabled: make(map[string]Severity),
|
|
163
172
|
}
|
|
164
|
-
|
|
173
|
+
displaySeverities := config.EnabledRuleConfig()
|
|
174
|
+
for _, name := range config.ActiveRuleNames() {
|
|
165
175
|
rule, ok := registered.rules[name]
|
|
166
176
|
if !ok {
|
|
167
177
|
eng.unknown = append(eng.unknown, name)
|
|
168
178
|
continue
|
|
169
179
|
}
|
|
170
|
-
|
|
171
|
-
continue
|
|
172
|
-
}
|
|
173
|
-
eng.enabled[name] = sev
|
|
180
|
+
eng.enabled[name] = displaySeverities.Severity(name)
|
|
174
181
|
for _, kind := range rule.Visits() {
|
|
175
182
|
eng.rules[kind] = append(eng.rules[kind], rule)
|
|
176
183
|
}
|
|
@@ -206,6 +213,11 @@ func (e *Engine) Run(files []*shimast.SourceFile, checker *shimchecker.Checker)
|
|
|
206
213
|
func (e *Engine) runFile(file *shimast.SourceFile, checker *shimchecker.Checker) []*Finding {
|
|
207
214
|
var collected []*Finding
|
|
208
215
|
collect := func(f *Finding) { collected = append(collected, f) }
|
|
216
|
+
resolved := e.config.ResolveRules(file.FileName())
|
|
217
|
+
if resolved.Ignored {
|
|
218
|
+
return collected
|
|
219
|
+
}
|
|
220
|
+
fileRules := resolved.Rules
|
|
209
221
|
|
|
210
222
|
var walk func(node *shimast.Node)
|
|
211
223
|
walk = func(node *shimast.Node) {
|
|
@@ -214,10 +226,14 @@ func (e *Engine) runFile(file *shimast.SourceFile, checker *shimchecker.Checker)
|
|
|
214
226
|
}
|
|
215
227
|
if rules, ok := e.rules[node.Kind]; ok {
|
|
216
228
|
for _, rule := range rules {
|
|
229
|
+
severity := fileRules.Severity(rule.Name())
|
|
230
|
+
if severity == SeverityOff {
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
217
233
|
ctx := &Context{
|
|
218
234
|
File: file,
|
|
219
235
|
Checker: checker,
|
|
220
|
-
Severity:
|
|
236
|
+
Severity: severity,
|
|
221
237
|
rule: rule,
|
|
222
238
|
collect: collect,
|
|
223
239
|
}
|
|
@@ -236,10 +252,14 @@ func (e *Engine) runFile(file *shimast.SourceFile, checker *shimchecker.Checker)
|
|
|
236
252
|
// SourceFile).
|
|
237
253
|
if rules, ok := e.rules[shimast.KindSourceFile]; ok {
|
|
238
254
|
for _, rule := range rules {
|
|
255
|
+
severity := fileRules.Severity(rule.Name())
|
|
256
|
+
if severity == SeverityOff {
|
|
257
|
+
continue
|
|
258
|
+
}
|
|
239
259
|
ctx := &Context{
|
|
240
260
|
File: file,
|
|
241
261
|
Checker: checker,
|
|
242
|
-
Severity:
|
|
262
|
+
Severity: severity,
|
|
243
263
|
rule: rule,
|
|
244
264
|
collect: collect,
|
|
245
265
|
}
|
|
@@ -254,5 +274,5 @@ func (e *Engine) runFile(file *shimast.SourceFile, checker *shimchecker.Checker)
|
|
|
254
274
|
for _, stmt := range statements.Nodes {
|
|
255
275
|
walk(stmt)
|
|
256
276
|
}
|
|
257
|
-
return collected
|
|
277
|
+
return filterInlineDisabledFindings(file, collected)
|
|
258
278
|
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
package main
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"fmt"
|
|
6
|
+
"os"
|
|
7
|
+
"os/exec"
|
|
8
|
+
"path/filepath"
|
|
9
|
+
"strings"
|
|
10
|
+
"unicode/utf8"
|
|
11
|
+
|
|
12
|
+
shimast "github.com/microsoft/typescript-go/shim/ast"
|
|
13
|
+
shimdw "github.com/microsoft/typescript-go/shim/diagnosticwriter"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
type eslintRuntimeProvider interface {
|
|
17
|
+
ExternalConfigPath() string
|
|
18
|
+
WantsESLintRuntime() bool
|
|
19
|
+
RequiresESLintRuntime() bool
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type eslintRuntimeOutput struct {
|
|
23
|
+
Missing bool `json:"missing"`
|
|
24
|
+
Results []eslintRuntimeFile `json:"results"`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type eslintRuntimeFile struct {
|
|
28
|
+
FilePath string `json:"filePath"`
|
|
29
|
+
Messages []eslintRuntimeMessage `json:"messages"`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type eslintRuntimeMessage struct {
|
|
33
|
+
RuleID string `json:"ruleId"`
|
|
34
|
+
Severity int `json:"severity"`
|
|
35
|
+
Message string `json:"message"`
|
|
36
|
+
Line int `json:"line"`
|
|
37
|
+
Column int `json:"column"`
|
|
38
|
+
EndLine int `json:"endLine"`
|
|
39
|
+
EndColumn int `json:"endColumn"`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func runExternalESLintDiagnostics(
|
|
43
|
+
resolver RuleResolver,
|
|
44
|
+
cwd string,
|
|
45
|
+
files []*shimast.SourceFile,
|
|
46
|
+
) ([]*shimdw.LintDiagnostic, bool, error) {
|
|
47
|
+
provider, ok := resolver.(eslintRuntimeProvider)
|
|
48
|
+
if !ok || !provider.WantsESLintRuntime() {
|
|
49
|
+
return nil, false, nil
|
|
50
|
+
}
|
|
51
|
+
configPath := provider.ExternalConfigPath()
|
|
52
|
+
if configPath == "" {
|
|
53
|
+
return nil, false, nil
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
fileNames := make([]string, 0, len(files))
|
|
57
|
+
byPath := make(map[string]*shimast.SourceFile, len(files))
|
|
58
|
+
for _, file := range files {
|
|
59
|
+
if file == nil || file.IsDeclarationFile {
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
name := file.FileName()
|
|
63
|
+
if !filepath.IsAbs(name) {
|
|
64
|
+
name = filepath.Join(cwd, name)
|
|
65
|
+
}
|
|
66
|
+
if abs, err := filepath.Abs(name); err == nil {
|
|
67
|
+
name = abs
|
|
68
|
+
}
|
|
69
|
+
fileNames = append(fileNames, name)
|
|
70
|
+
byPath[filepath.ToSlash(name)] = file
|
|
71
|
+
}
|
|
72
|
+
if len(fileNames) == 0 {
|
|
73
|
+
return nil, true, nil
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
payload, err := json.Marshal(fileNames)
|
|
77
|
+
if err != nil {
|
|
78
|
+
return nil, false, fmt.Errorf("@ttsc/lint: encode ESLint file list: %w", err)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
output, err := runExternalESLint(cwd, configPath, string(payload))
|
|
82
|
+
if err != nil {
|
|
83
|
+
return nil, false, err
|
|
84
|
+
}
|
|
85
|
+
if output.Missing {
|
|
86
|
+
if provider.RequiresESLintRuntime() {
|
|
87
|
+
return nil, false, fmt.Errorf("@ttsc/lint: ESLint runtime is required by %s; install eslint in the project or replace runtime-only config features", configPath)
|
|
88
|
+
}
|
|
89
|
+
return nil, false, nil
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
diagnostics := make([]*shimdw.LintDiagnostic, 0)
|
|
93
|
+
for _, result := range output.Results {
|
|
94
|
+
file := byPath[filepath.ToSlash(result.FilePath)]
|
|
95
|
+
if file == nil {
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
for _, msg := range result.Messages {
|
|
99
|
+
if msg.Severity == 0 {
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
ruleID := strings.TrimSpace(msg.RuleID)
|
|
103
|
+
if ruleID == "" {
|
|
104
|
+
ruleID = "eslint"
|
|
105
|
+
}
|
|
106
|
+
category := shimdw.LintCategoryWarning
|
|
107
|
+
if msg.Severity >= 2 {
|
|
108
|
+
category = shimdw.LintCategoryError
|
|
109
|
+
}
|
|
110
|
+
pos := positionOfESLintLocation(file.Text(), msg.Line, msg.Column)
|
|
111
|
+
end := positionOfESLintLocation(file.Text(), msg.EndLine, msg.EndColumn)
|
|
112
|
+
if end <= pos {
|
|
113
|
+
end = pos + 1
|
|
114
|
+
}
|
|
115
|
+
diagnostics = append(diagnostics, shimdw.NewLintDiagnostic(
|
|
116
|
+
file,
|
|
117
|
+
pos,
|
|
118
|
+
end,
|
|
119
|
+
ruleCode(ruleID),
|
|
120
|
+
category,
|
|
121
|
+
fmt.Sprintf("[%s] %s", ruleID, msg.Message),
|
|
122
|
+
))
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return diagnostics, true, nil
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
func runExternalESLint(cwd, configPath, fileListJSON string) (*eslintRuntimeOutput, error) {
|
|
129
|
+
node := os.Getenv("TTSC_NODE_BINARY")
|
|
130
|
+
if node == "" {
|
|
131
|
+
node = "node"
|
|
132
|
+
}
|
|
133
|
+
cmd := exec.Command(node, "-e", externalESLintRunnerScript, cwd, configPath, fileListJSON)
|
|
134
|
+
cmd.Env = nodeConfigLoaderEnv(configPath)
|
|
135
|
+
cmd.Dir = cwd
|
|
136
|
+
raw, err := cmd.Output()
|
|
137
|
+
if err != nil {
|
|
138
|
+
stderr := ""
|
|
139
|
+
if exit, ok := err.(*exec.ExitError); ok {
|
|
140
|
+
stderr = strings.TrimSpace(string(exit.Stderr))
|
|
141
|
+
}
|
|
142
|
+
if stderr != "" {
|
|
143
|
+
return nil, fmt.Errorf("@ttsc/lint: run ESLint config %s: %s", configPath, stderr)
|
|
144
|
+
}
|
|
145
|
+
return nil, fmt.Errorf("@ttsc/lint: run ESLint config %s: %w", configPath, err)
|
|
146
|
+
}
|
|
147
|
+
var output eslintRuntimeOutput
|
|
148
|
+
if err := json.Unmarshal(raw, &output); err != nil {
|
|
149
|
+
return nil, fmt.Errorf("@ttsc/lint: parse ESLint output for %s: %w", configPath, err)
|
|
150
|
+
}
|
|
151
|
+
return &output, nil
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
func positionOfESLintLocation(text string, line, column int) int {
|
|
155
|
+
if line <= 0 {
|
|
156
|
+
line = 1
|
|
157
|
+
}
|
|
158
|
+
if column <= 0 {
|
|
159
|
+
column = 1
|
|
160
|
+
}
|
|
161
|
+
lineStart := 0
|
|
162
|
+
currentLine := 1
|
|
163
|
+
for i := 0; i < len(text) && currentLine < line; {
|
|
164
|
+
r, size := utf8.DecodeRuneInString(text[i:])
|
|
165
|
+
if r == '\n' {
|
|
166
|
+
currentLine++
|
|
167
|
+
lineStart = i + size
|
|
168
|
+
}
|
|
169
|
+
i += size
|
|
170
|
+
}
|
|
171
|
+
targetUTF16 := column - 1
|
|
172
|
+
seenUTF16 := 0
|
|
173
|
+
for i := lineStart; i < len(text); {
|
|
174
|
+
if seenUTF16 >= targetUTF16 {
|
|
175
|
+
return i
|
|
176
|
+
}
|
|
177
|
+
r, size := utf8.DecodeRuneInString(text[i:])
|
|
178
|
+
if r == '\n' || r == '\r' {
|
|
179
|
+
return i
|
|
180
|
+
}
|
|
181
|
+
if r > 0xFFFF {
|
|
182
|
+
seenUTF16 += 2
|
|
183
|
+
} else {
|
|
184
|
+
seenUTF16++
|
|
185
|
+
}
|
|
186
|
+
i += size
|
|
187
|
+
}
|
|
188
|
+
return len(text)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const externalESLintRunnerScript = `
|
|
192
|
+
const { createRequire } = require("node:module");
|
|
193
|
+
const path = require("node:path");
|
|
194
|
+
|
|
195
|
+
(async () => {
|
|
196
|
+
const cwd = process.argv[1];
|
|
197
|
+
const configPath = process.argv[2];
|
|
198
|
+
const files = JSON.parse(process.argv[3]);
|
|
199
|
+
const requireFromProject = createRequire(path.join(cwd, "package.json"));
|
|
200
|
+
let eslintPath;
|
|
201
|
+
try {
|
|
202
|
+
eslintPath = requireFromProject.resolve("eslint");
|
|
203
|
+
} catch (error) {
|
|
204
|
+
if (error && error.code === "MODULE_NOT_FOUND") {
|
|
205
|
+
process.stdout.write(JSON.stringify({ missing: true, results: [] }));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
throw error;
|
|
209
|
+
}
|
|
210
|
+
const eslintModule = requireFromProject(eslintPath);
|
|
211
|
+
|
|
212
|
+
const ESLintCtor = typeof eslintModule.loadESLint === "function"
|
|
213
|
+
? await eslintModule.loadESLint({ useFlatConfig: true })
|
|
214
|
+
: eslintModule.ESLint ?? eslintModule.default?.ESLint ?? eslintModule.default;
|
|
215
|
+
|
|
216
|
+
if (typeof ESLintCtor !== "function") {
|
|
217
|
+
throw new Error("installed eslint package does not export ESLint or loadESLint");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const eslint = new ESLintCtor({
|
|
221
|
+
cwd,
|
|
222
|
+
overrideConfigFile: configPath,
|
|
223
|
+
ignore: true,
|
|
224
|
+
warnIgnored: false,
|
|
225
|
+
});
|
|
226
|
+
const results = await eslint.lintFiles(files);
|
|
227
|
+
process.stdout.write(JSON.stringify({
|
|
228
|
+
missing: false,
|
|
229
|
+
results: results.map((result) => ({
|
|
230
|
+
filePath: result.filePath,
|
|
231
|
+
messages: result.messages.map((message) => ({
|
|
232
|
+
ruleId: message.ruleId || "eslint",
|
|
233
|
+
severity: message.severity,
|
|
234
|
+
message: message.message,
|
|
235
|
+
line: message.line || 1,
|
|
236
|
+
column: message.column || 1,
|
|
237
|
+
endLine: message.endLine || message.line || 1,
|
|
238
|
+
endColumn: message.endColumn || message.column || 1,
|
|
239
|
+
})),
|
|
240
|
+
})),
|
|
241
|
+
}));
|
|
242
|
+
})().catch((error) => {
|
|
243
|
+
process.stderr.write(error && error.stack ? error.stack : String(error));
|
|
244
|
+
process.exit(1);
|
|
245
|
+
});
|
|
246
|
+
`
|
package/src/config.ts
ADDED
|
@@ -5,10 +5,11 @@ import type { TtscLintConfig } from "./TtscLintConfig";
|
|
|
5
5
|
/** `compilerOptions.plugins[]` entry shape consumed by `@ttsc/lint`. */
|
|
6
6
|
export interface ITtscLintPluginConfig extends ITtscProjectPluginConfig {
|
|
7
7
|
/**
|
|
8
|
-
* Inline rule map or path to a
|
|
8
|
+
* Inline rule map or path to a standalone lint config file.
|
|
9
9
|
*
|
|
10
10
|
* Inline maps are passed to the native sidecar through `--plugins-json`.
|
|
11
|
-
* String values are resolved by the sidecar from the
|
|
11
|
+
* String values are resolved by the sidecar from the owning tsconfig
|
|
12
|
+
* directory and may point at JSON, JavaScript, or TypeScript config files.
|
|
12
13
|
*/
|
|
13
14
|
config?: string | TtscLintConfig;
|
|
14
15
|
}
|
|
@@ -4,9 +4,8 @@ import type { TtscLintSeverity } from "./TtscLintSeverity";
|
|
|
4
4
|
/**
|
|
5
5
|
* Inline rule map accepted by the `@ttsc/lint` tsconfig plugin entry.
|
|
6
6
|
*
|
|
7
|
-
* Each property key is a native lint rule name. Omitted rules
|
|
8
|
-
*
|
|
9
|
-
* project.
|
|
7
|
+
* Each property key is a native lint rule name. Omitted rules are disabled,
|
|
8
|
+
* while present rules enable or disable that rule for the current project.
|
|
10
9
|
*/
|
|
11
10
|
export type TtscLintConfig = {
|
|
12
11
|
[P in TtscLintRule]?: TtscLintSeverity;
|