@ttsc/lint 0.10.2 → 0.11.0-dev.20260518
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 +172 -33
- package/lib/defineConfig.d.ts +55 -0
- package/lib/defineConfig.js +39 -0
- package/lib/defineConfig.js.map +1 -0
- package/lib/index.d.ts +25 -1
- package/lib/index.js +622 -2
- package/lib/index.js.map +1 -1
- package/lib/structures/ITtscLintPlugin.d.ts +53 -0
- package/lib/structures/ITtscLintPlugin.js +3 -0
- package/lib/structures/ITtscLintPlugin.js.map +1 -0
- package/lib/structures/ITtscLintPluginConfig.d.ts +82 -5
- package/lib/structures/ITtscLintPluginMeta.d.ts +12 -0
- package/lib/structures/ITtscLintPluginMeta.js +3 -0
- package/lib/structures/ITtscLintPluginMeta.js.map +1 -0
- package/lib/structures/PluginRuleNames.d.ts +14 -0
- package/lib/structures/PluginRuleNames.js +3 -0
- package/lib/structures/PluginRuleNames.js.map +1 -0
- package/lib/structures/TtscLintConfig.d.ts +24 -8
- package/lib/structures/TtscLintConfigEntry.d.ts +39 -0
- package/lib/structures/TtscLintConfigEntry.js +3 -0
- package/lib/structures/TtscLintConfigEntry.js.map +1 -0
- package/lib/structures/TtscLintFormatConfig.d.ts +154 -0
- package/lib/structures/TtscLintFormatConfig.js +3 -0
- package/lib/structures/TtscLintFormatConfig.js.map +1 -0
- package/lib/structures/TtscLintPlugins.d.ts +9 -0
- package/lib/structures/TtscLintPlugins.js +3 -0
- package/lib/structures/TtscLintPlugins.js.map +1 -0
- package/lib/structures/TtscLintRule.d.ts +1 -1
- package/lib/structures/TtscLintRuleEntry.d.ts +22 -0
- package/lib/structures/TtscLintRuleEntry.js +3 -0
- package/lib/structures/TtscLintRuleEntry.js.map +1 -0
- package/lib/structures/TtscLintRuleMap.d.ts +44 -0
- package/lib/structures/TtscLintRuleMap.js +3 -0
- package/lib/structures/TtscLintRuleMap.js.map +1 -0
- package/lib/structures/TtscLintRuleOptions.d.ts +149 -0
- package/lib/structures/TtscLintRuleOptions.js +3 -0
- package/lib/structures/TtscLintRuleOptions.js.map +1 -0
- package/lib/structures/defaultFormat.d.ts +31 -0
- package/lib/structures/defaultFormat.js +35 -0
- package/lib/structures/defaultFormat.js.map +1 -0
- package/lib/structures/index.d.ts +10 -0
- package/lib/structures/index.js +10 -0
- package/lib/structures/index.js.map +1 -1
- package/{plugin → linthost}/ast_helpers.go +92 -1
- package/{plugin → linthost}/compile.go +1 -1
- package/{plugin → linthost}/config.go +583 -66
- package/linthost/config_format.go +360 -0
- package/linthost/contrib_adapter.go +134 -0
- package/{plugin → linthost}/directives.go +1 -1
- package/linthost/dispatch.go +65 -0
- package/{plugin → linthost}/engine.go +156 -11
- package/{plugin → linthost}/eslint_runtime.go +76 -2
- package/linthost/fix.go +249 -0
- package/linthost/format.go +107 -0
- package/{plugin → linthost}/host.go +1 -1
- package/linthost/print_dispatch.go +118 -0
- package/linthost/print_doc.go +168 -0
- package/linthost/print_engine.go +343 -0
- package/linthost/print_nodes_array.go +46 -0
- package/linthost/print_nodes_call.go +180 -0
- package/linthost/print_nodes_imports.go +171 -0
- package/linthost/print_nodes_list.go +64 -0
- package/linthost/print_nodes_object.go +51 -0
- package/{plugin → linthost}/rules_arrays.go +1 -1
- package/{plugin → linthost}/rules_console.go +1 -1
- package/{plugin → linthost}/rules_debugger.go +1 -1
- package/{plugin → linthost}/rules_dupes.go +1 -1
- package/{plugin → linthost}/rules_empty.go +1 -1
- package/linthost/rules_escape.go +238 -0
- package/{plugin → linthost}/rules_eval.go +1 -1
- package/{plugin → linthost}/rules_finally.go +1 -1
- package/linthost/rules_format_jsdoc.go +230 -0
- package/linthost/rules_format_print_width.go +332 -0
- package/linthost/rules_format_quotes.go +219 -0
- package/linthost/rules_format_semi.go +190 -0
- package/linthost/rules_format_sort_imports.go +462 -0
- package/linthost/rules_format_trailing_comma.go +460 -0
- package/{plugin → linthost}/rules_gap.go +101 -6
- package/linthost/rules_imports.go +101 -0
- package/{plugin → linthost}/rules_logic.go +55 -3
- package/{plugin → linthost}/rules_loops.go +1 -1
- package/{plugin → linthost}/rules_misc.go +1 -1
- package/linthost/rules_params.go +64 -0
- package/{plugin → linthost}/rules_problems.go +10 -2
- package/linthost/rules_promise.go +111 -0
- package/{plugin → linthost}/rules_protos.go +1 -1
- package/{plugin → linthost}/rules_self.go +1 -1
- package/{plugin → linthost}/rules_strings.go +1 -1
- package/{plugin → linthost}/rules_suggestions.go +51 -9
- package/{plugin → linthost}/rules_throw.go +1 -1
- package/{plugin → linthost}/rules_ts.go +12 -2
- package/{plugin → linthost}/rules_ts_extra.go +90 -4
- package/{plugin → linthost}/rules_var.go +36 -5
- package/package.json +8 -6
- package/plugin/main.go +14 -27
- package/rule/astutil/astutil.go +148 -0
- package/rule/rule.go +291 -0
- package/src/defineConfig.ts +69 -0
- package/src/index.ts +807 -3
- package/src/structures/ITtscLintPlugin.ts +56 -0
- package/src/structures/ITtscLintPluginConfig.ts +86 -5
- package/src/structures/ITtscLintPluginMeta.ts +14 -0
- package/src/structures/PluginRuleNames.ts +19 -0
- package/src/structures/TtscLintConfig.ts +27 -8
- package/src/structures/TtscLintConfigEntry.ts +50 -0
- package/src/structures/TtscLintFormatConfig.ts +169 -0
- package/src/structures/TtscLintPlugins.ts +10 -0
- package/src/structures/TtscLintRule.ts +14 -1
- package/src/structures/TtscLintRuleEntry.ts +28 -0
- package/src/structures/TtscLintRuleMap.ts +52 -0
- package/src/structures/TtscLintRuleOptions.ts +162 -0
- package/src/structures/defaultFormat.ts +33 -0
- package/src/structures/index.ts +10 -0
|
@@ -15,9 +15,12 @@
|
|
|
15
15
|
// and may not retain references to the previous file. This keeps the
|
|
16
16
|
// engine concurrent-friendly even though the v0 implementation runs
|
|
17
17
|
// serially.
|
|
18
|
-
package
|
|
18
|
+
package linthost
|
|
19
19
|
|
|
20
20
|
import (
|
|
21
|
+
"encoding/json"
|
|
22
|
+
"fmt"
|
|
23
|
+
"os"
|
|
21
24
|
"sort"
|
|
22
25
|
|
|
23
26
|
shimast "github.com/microsoft/typescript-go/shim/ast"
|
|
@@ -43,18 +46,66 @@ type Rule interface {
|
|
|
43
46
|
Check(ctx *Context, node *shimast.Node)
|
|
44
47
|
}
|
|
45
48
|
|
|
49
|
+
// FormatRule is an optional marker interface that tags a Rule as a
|
|
50
|
+
// formatter. `ttsc fix` is the run-everything entry point — it applies
|
|
51
|
+
// edits from BOTH lint-class rules and FormatRule rules. `ttsc format`
|
|
52
|
+
// is the format-only convenience: it filters to FormatRule findings so
|
|
53
|
+
// lint-class rewrites are skipped. The marker exists so the format
|
|
54
|
+
// filter can pick the right half; fix needs no filter.
|
|
55
|
+
//
|
|
56
|
+
// FormatRule.IsFormat must return true unconditionally — the method
|
|
57
|
+
// exists as a structural marker, not a runtime toggle. Returning false
|
|
58
|
+
// is treated by the engine as "not a format rule" and is equivalent to
|
|
59
|
+
// not implementing the interface at all.
|
|
60
|
+
type FormatRule interface {
|
|
61
|
+
Rule
|
|
62
|
+
IsFormat() bool
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// isFormatRule reports whether `r` opts into the format category.
|
|
66
|
+
func isFormatRule(r Rule) bool {
|
|
67
|
+
fr, ok := r.(FormatRule)
|
|
68
|
+
return ok && fr.IsFormat()
|
|
69
|
+
}
|
|
70
|
+
|
|
46
71
|
// Context is the per-(file, rule) handle the engine passes to `Check`.
|
|
72
|
+
//
|
|
73
|
+
// `Options` is the raw JSON blob the user wrote in their rule
|
|
74
|
+
// configuration's second tuple slot (`["warning", { ... }]`). It is nil
|
|
75
|
+
// when the rule was configured with a bare severity literal. Rules that
|
|
76
|
+
// accept options decode the blob into their own struct via
|
|
77
|
+
// `(*Context).DecodeOptions` and fall back to defaults on nil.
|
|
47
78
|
type Context struct {
|
|
48
79
|
File *shimast.SourceFile
|
|
49
80
|
Checker *shimchecker.Checker
|
|
50
81
|
Severity Severity
|
|
82
|
+
Options json.RawMessage
|
|
51
83
|
|
|
52
|
-
rule
|
|
53
|
-
|
|
84
|
+
rule Rule
|
|
85
|
+
isFormat bool
|
|
86
|
+
collect func(*Finding)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// DecodeOptions unmarshals the rule's options blob into `out`. Returns
|
|
90
|
+
// nil with no side effect when the rule was configured with severity
|
|
91
|
+
// alone, so callers can write
|
|
92
|
+
//
|
|
93
|
+
// var opts myRuleOptions
|
|
94
|
+
// ctx.DecodeOptions(&opts)
|
|
95
|
+
// // opts now holds either the user's settings or the zero value.
|
|
96
|
+
func (c *Context) DecodeOptions(out interface{}) error {
|
|
97
|
+
if c == nil || len(c.Options) == 0 {
|
|
98
|
+
return nil
|
|
99
|
+
}
|
|
100
|
+
return json.Unmarshal(c.Options, out)
|
|
54
101
|
}
|
|
55
102
|
|
|
56
103
|
// Finding is one rule-emitted diagnostic before it gets converted into a
|
|
57
|
-
// driver Diagnostic.
|
|
104
|
+
// driver Diagnostic. `IsFormat` mirrors the dispatching rule's category
|
|
105
|
+
// so the `format` subcommand's filter can route findings without
|
|
106
|
+
// re-querying the registry. The `fix` subcommand applies findings from
|
|
107
|
+
// both categories — no filter — because `ttsc fix` is the
|
|
108
|
+
// run-everything entry point.
|
|
58
109
|
type Finding struct {
|
|
59
110
|
Rule string
|
|
60
111
|
Severity Severity
|
|
@@ -62,6 +113,17 @@ type Finding struct {
|
|
|
62
113
|
Pos int
|
|
63
114
|
End int
|
|
64
115
|
Message string
|
|
116
|
+
Fix []TextEdit
|
|
117
|
+
IsFormat bool
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// TextEdit is one byte-range replacement offered by an autofixable finding.
|
|
121
|
+
// Positions use the same byte offsets as shim AST nodes and must point inside
|
|
122
|
+
// the finding's source file.
|
|
123
|
+
type TextEdit struct {
|
|
124
|
+
Pos int
|
|
125
|
+
End int
|
|
126
|
+
Text string
|
|
65
127
|
}
|
|
66
128
|
|
|
67
129
|
// Report records a finding at the given node's source range. The pos is
|
|
@@ -71,6 +133,11 @@ type Finding struct {
|
|
|
71
133
|
// configured severity is `off` (defensive — the engine already filters
|
|
72
134
|
// by severity before calling Check, but Report is the final gate).
|
|
73
135
|
func (c *Context) Report(node *shimast.Node, message string) {
|
|
136
|
+
c.ReportFix(node, message)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ReportFix records a node-scoped finding with optional autofix edits.
|
|
140
|
+
func (c *Context) ReportFix(node *shimast.Node, message string, edits ...TextEdit) {
|
|
74
141
|
if c.Severity == SeverityOff || node == nil {
|
|
75
142
|
return
|
|
76
143
|
}
|
|
@@ -85,6 +152,8 @@ func (c *Context) Report(node *shimast.Node, message string) {
|
|
|
85
152
|
Pos: pos,
|
|
86
153
|
End: node.End(),
|
|
87
154
|
Message: message,
|
|
155
|
+
Fix: cloneTextEdits(edits),
|
|
156
|
+
IsFormat: c.isFormat,
|
|
88
157
|
})
|
|
89
158
|
}
|
|
90
159
|
|
|
@@ -92,6 +161,11 @@ func (c *Context) Report(node *shimast.Node, message string) {
|
|
|
92
161
|
// current file. Use this when the rule wants to highlight a sub-token of
|
|
93
162
|
// a node (e.g. an operator inside a BinaryExpression).
|
|
94
163
|
func (c *Context) ReportRange(pos, end int, message string) {
|
|
164
|
+
c.ReportRangeFix(pos, end, message)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ReportRangeFix records an explicit-range finding with optional autofix edits.
|
|
168
|
+
func (c *Context) ReportRangeFix(pos, end int, message string, edits ...TextEdit) {
|
|
95
169
|
if c.Severity == SeverityOff || c.File == nil {
|
|
96
170
|
return
|
|
97
171
|
}
|
|
@@ -105,9 +179,20 @@ func (c *Context) ReportRange(pos, end int, message string) {
|
|
|
105
179
|
Pos: pos,
|
|
106
180
|
End: end,
|
|
107
181
|
Message: message,
|
|
182
|
+
Fix: cloneTextEdits(edits),
|
|
183
|
+
IsFormat: c.isFormat,
|
|
108
184
|
})
|
|
109
185
|
}
|
|
110
186
|
|
|
187
|
+
func cloneTextEdits(edits []TextEdit) []TextEdit {
|
|
188
|
+
if len(edits) == 0 {
|
|
189
|
+
return nil
|
|
190
|
+
}
|
|
191
|
+
out := make([]TextEdit, len(edits))
|
|
192
|
+
copy(out, edits)
|
|
193
|
+
return out
|
|
194
|
+
}
|
|
195
|
+
|
|
111
196
|
// registry stores the package-global rule list keyed by name. Tests can
|
|
112
197
|
// also reach into it via `LookupRule`.
|
|
113
198
|
type registry struct {
|
|
@@ -178,7 +263,14 @@ func NewEngineWithResolver(config RuleResolver) *Engine {
|
|
|
178
263
|
continue
|
|
179
264
|
}
|
|
180
265
|
eng.enabled[name] = displaySeverities.Severity(name)
|
|
266
|
+
// Dedup kinds per rule so a contributor that accidentally lists the
|
|
267
|
+
// same Kind twice in `Visits()` doesn't end up firing twice per node.
|
|
268
|
+
seen := make(map[shimast.Kind]struct{})
|
|
181
269
|
for _, kind := range rule.Visits() {
|
|
270
|
+
if _, dup := seen[kind]; dup {
|
|
271
|
+
continue
|
|
272
|
+
}
|
|
273
|
+
seen[kind] = struct{}{}
|
|
182
274
|
eng.rules[kind] = append(eng.rules[kind], rule)
|
|
183
275
|
}
|
|
184
276
|
}
|
|
@@ -234,10 +326,12 @@ func (e *Engine) runFile(file *shimast.SourceFile, checker *shimchecker.Checker)
|
|
|
234
326
|
File: file,
|
|
235
327
|
Checker: checker,
|
|
236
328
|
Severity: severity,
|
|
329
|
+
Options: e.config.RuleOptions(rule.Name()),
|
|
237
330
|
rule: rule,
|
|
331
|
+
isFormat: isFormatRule(rule),
|
|
238
332
|
collect: collect,
|
|
239
333
|
}
|
|
240
|
-
rule
|
|
334
|
+
runRuleCheck(rule, ctx, node, collect)
|
|
241
335
|
}
|
|
242
336
|
}
|
|
243
337
|
node.ForEachChild(func(child *shimast.Node) bool {
|
|
@@ -260,19 +354,70 @@ func (e *Engine) runFile(file *shimast.SourceFile, checker *shimchecker.Checker)
|
|
|
260
354
|
File: file,
|
|
261
355
|
Checker: checker,
|
|
262
356
|
Severity: severity,
|
|
357
|
+
Options: e.config.RuleOptions(rule.Name()),
|
|
263
358
|
rule: rule,
|
|
359
|
+
isFormat: isFormatRule(rule),
|
|
264
360
|
collect: collect,
|
|
265
361
|
}
|
|
266
|
-
rule
|
|
362
|
+
runRuleCheck(rule, ctx, file.AsNode(), collect)
|
|
267
363
|
}
|
|
268
364
|
}
|
|
269
365
|
|
|
270
366
|
statements := file.Statements
|
|
271
|
-
if statements
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
walk(stmt)
|
|
367
|
+
if statements != nil {
|
|
368
|
+
for _, stmt := range statements.Nodes {
|
|
369
|
+
walk(stmt)
|
|
370
|
+
}
|
|
276
371
|
}
|
|
372
|
+
// Apply inline-disable filtering even for files with no statement
|
|
373
|
+
// list. A SourceFile-level rule that fires on a `// ttsc-lint-disable`
|
|
374
|
+
// comment must still honor the directive; early-returning before
|
|
375
|
+
// the filter would silently leak those findings into the diagnostic
|
|
376
|
+
// stream.
|
|
277
377
|
return filterInlineDisabledFindings(file, collected)
|
|
278
378
|
}
|
|
379
|
+
|
|
380
|
+
// runRuleCheck invokes a rule's `Check` with a `recover()` barrier so a
|
|
381
|
+
// panicking rule does not abort the entire `ttsc fix` / `ttsc check`
|
|
382
|
+
// run. Built-in rules are not expected to panic, but contributor rules
|
|
383
|
+
// crossing into the public `rule.Context` adapter can be authored by
|
|
384
|
+
// anyone; protecting the engine is the only way to bound the blast
|
|
385
|
+
// radius of one bad rule. The recovered panic is surfaced as a
|
|
386
|
+
// SeverityError finding tagged with the rule's name so the user sees
|
|
387
|
+
// the failure in the normal diagnostic stream.
|
|
388
|
+
func runRuleCheck(rule Rule, ctx *Context, node *shimast.Node, collect func(*Finding)) {
|
|
389
|
+
defer func() {
|
|
390
|
+
r := recover()
|
|
391
|
+
if r == nil {
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
if ctx == nil || ctx.File == nil {
|
|
395
|
+
// Without source context there is nowhere to anchor the
|
|
396
|
+
// diagnostic. Surface to stderr so the panic is at least
|
|
397
|
+
// visible to the operator.
|
|
398
|
+
fmt.Fprintf(os.Stderr, "@ttsc/lint: rule %q panicked: %v\n", rule.Name(), r)
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
pos := 0
|
|
402
|
+
end := 1
|
|
403
|
+
if node != nil {
|
|
404
|
+
pos = node.Pos()
|
|
405
|
+
end = node.End()
|
|
406
|
+
}
|
|
407
|
+
if end <= pos {
|
|
408
|
+
end = pos + 1
|
|
409
|
+
}
|
|
410
|
+
collect(&Finding{
|
|
411
|
+
Rule: rule.Name(),
|
|
412
|
+
Severity: SeverityError,
|
|
413
|
+
Pos: pos,
|
|
414
|
+
End: end,
|
|
415
|
+
Message: fmt.Sprintf(
|
|
416
|
+
"Rule %q panicked while checking this node: %v. Report this to the rule's author; ttsc skipped the rule on this file.",
|
|
417
|
+
rule.Name(), r,
|
|
418
|
+
),
|
|
419
|
+
File: ctx.File,
|
|
420
|
+
})
|
|
421
|
+
}()
|
|
422
|
+
rule.Check(ctx, node)
|
|
423
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
package
|
|
1
|
+
package linthost
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"encoding/json"
|
|
@@ -21,6 +21,7 @@ type eslintRuntimeProvider interface {
|
|
|
21
21
|
|
|
22
22
|
type eslintRuntimeOutput struct {
|
|
23
23
|
Missing bool `json:"missing"`
|
|
24
|
+
Fixed int `json:"fixed"`
|
|
24
25
|
Results []eslintRuntimeFile `json:"results"`
|
|
25
26
|
}
|
|
26
27
|
|
|
@@ -126,11 +127,69 @@ func runExternalESLintDiagnostics(
|
|
|
126
127
|
}
|
|
127
128
|
|
|
128
129
|
func runExternalESLint(cwd, configPath, fileListJSON string) (*eslintRuntimeOutput, error) {
|
|
130
|
+
return runExternalESLintWithMode(cwd, configPath, fileListJSON, false)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
func runExternalESLintFixes(
|
|
134
|
+
resolver RuleResolver,
|
|
135
|
+
cwd string,
|
|
136
|
+
files []*shimast.SourceFile,
|
|
137
|
+
) (int, error) {
|
|
138
|
+
provider, ok := resolver.(eslintRuntimeProvider)
|
|
139
|
+
if !ok || !provider.WantsESLintRuntime() {
|
|
140
|
+
return 0, nil
|
|
141
|
+
}
|
|
142
|
+
configPath := provider.ExternalConfigPath()
|
|
143
|
+
if configPath == "" {
|
|
144
|
+
return 0, nil
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
fileNames := make([]string, 0, len(files))
|
|
148
|
+
for _, file := range files {
|
|
149
|
+
if file == nil || file.IsDeclarationFile {
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
name := file.FileName()
|
|
153
|
+
if !filepath.IsAbs(name) {
|
|
154
|
+
name = filepath.Join(cwd, name)
|
|
155
|
+
}
|
|
156
|
+
if abs, err := filepath.Abs(name); err == nil {
|
|
157
|
+
name = abs
|
|
158
|
+
}
|
|
159
|
+
fileNames = append(fileNames, name)
|
|
160
|
+
}
|
|
161
|
+
if len(fileNames) == 0 {
|
|
162
|
+
return 0, nil
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
payload, err := json.Marshal(fileNames)
|
|
166
|
+
if err != nil {
|
|
167
|
+
return 0, fmt.Errorf("@ttsc/lint: encode ESLint file list: %w", err)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
output, err := runExternalESLintWithMode(cwd, configPath, string(payload), true)
|
|
171
|
+
if err != nil {
|
|
172
|
+
return 0, err
|
|
173
|
+
}
|
|
174
|
+
if output.Missing {
|
|
175
|
+
if provider.RequiresESLintRuntime() {
|
|
176
|
+
return 0, fmt.Errorf("@ttsc/lint: ESLint runtime is required by %s; install eslint in the project or replace runtime-only config features", configPath)
|
|
177
|
+
}
|
|
178
|
+
return 0, nil
|
|
179
|
+
}
|
|
180
|
+
return output.Fixed, nil
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
func runExternalESLintWithMode(cwd, configPath, fileListJSON string, fix bool) (*eslintRuntimeOutput, error) {
|
|
129
184
|
node := os.Getenv("TTSC_NODE_BINARY")
|
|
130
185
|
if node == "" {
|
|
131
186
|
node = "node"
|
|
132
187
|
}
|
|
133
|
-
|
|
188
|
+
mode := "check"
|
|
189
|
+
if fix {
|
|
190
|
+
mode = "fix"
|
|
191
|
+
}
|
|
192
|
+
cmd := exec.Command(node, "-e", externalESLintRunnerScript, cwd, configPath, fileListJSON, mode)
|
|
134
193
|
cmd.Env = nodeConfigLoaderEnv(configPath)
|
|
135
194
|
cmd.Dir = cwd
|
|
136
195
|
raw, err := cmd.Output()
|
|
@@ -196,6 +255,7 @@ const path = require("node:path");
|
|
|
196
255
|
const cwd = process.argv[1];
|
|
197
256
|
const configPath = process.argv[2];
|
|
198
257
|
const files = JSON.parse(process.argv[3]);
|
|
258
|
+
const shouldFix = process.argv[4] === "fix";
|
|
199
259
|
const requireFromProject = createRequire(path.join(cwd, "package.json"));
|
|
200
260
|
let eslintPath;
|
|
201
261
|
try {
|
|
@@ -220,12 +280,26 @@ const path = require("node:path");
|
|
|
220
280
|
const eslint = new ESLintCtor({
|
|
221
281
|
cwd,
|
|
222
282
|
overrideConfigFile: configPath,
|
|
283
|
+
fix: shouldFix,
|
|
223
284
|
ignore: true,
|
|
224
285
|
warnIgnored: false,
|
|
225
286
|
});
|
|
226
287
|
const results = await eslint.lintFiles(files);
|
|
288
|
+
if (shouldFix) {
|
|
289
|
+
const outputFixes =
|
|
290
|
+
ESLintCtor.outputFixes ||
|
|
291
|
+
eslintModule.ESLint?.outputFixes ||
|
|
292
|
+
eslintModule.default?.ESLint?.outputFixes;
|
|
293
|
+
if (typeof outputFixes !== "function") {
|
|
294
|
+
throw new Error("installed eslint package does not expose ESLint.outputFixes");
|
|
295
|
+
}
|
|
296
|
+
await outputFixes.call(ESLintCtor, results);
|
|
297
|
+
}
|
|
227
298
|
process.stdout.write(JSON.stringify({
|
|
228
299
|
missing: false,
|
|
300
|
+
fixed: shouldFix
|
|
301
|
+
? results.filter((result) => typeof result.output === "string").length
|
|
302
|
+
: 0,
|
|
229
303
|
results: results.map((result) => ({
|
|
230
304
|
filePath: result.filePath,
|
|
231
305
|
messages: result.messages.map((message) => ({
|
package/linthost/fix.go
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
package linthost
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"os"
|
|
6
|
+
"path/filepath"
|
|
7
|
+
"sort"
|
|
8
|
+
|
|
9
|
+
shimdw "github.com/microsoft/typescript-go/shim/diagnosticwriter"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// maxFixPasses bounds the native cascade after the one-shot ESLint runtime
|
|
13
|
+
// pass. Real-world cascades (no-var → prefer-const → eqeqeq …) settle in a
|
|
14
|
+
// handful of passes; the cap exists so a buggy rule that re-reports its own
|
|
15
|
+
// edit cannot loop forever.
|
|
16
|
+
const maxFixPasses = 10
|
|
17
|
+
|
|
18
|
+
// RunFix implements `@ttsc/lint fix` — apply autofixes, then report any
|
|
19
|
+
// remaining type or lint diagnostics without emitting JavaScript.
|
|
20
|
+
func RunFix(args []string) int {
|
|
21
|
+
opts, err := parseSubcommandFlags("fix", args)
|
|
22
|
+
if err != nil {
|
|
23
|
+
fmt.Fprintln(os.Stderr, err)
|
|
24
|
+
return 2
|
|
25
|
+
}
|
|
26
|
+
if opts.emit {
|
|
27
|
+
fmt.Fprintln(os.Stderr, "@ttsc/lint fix: --emit is not supported")
|
|
28
|
+
return 2
|
|
29
|
+
}
|
|
30
|
+
opts.noEmit = true
|
|
31
|
+
return runFix(opts)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func runFix(opts *subcommandOpts) int {
|
|
35
|
+
rules, err := loadRules(opts.pluginsJSON, opts.cwd, opts.tsconfig)
|
|
36
|
+
if err != nil {
|
|
37
|
+
fmt.Fprintln(os.Stderr, err)
|
|
38
|
+
return 2
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
prog, code := loadFixProgram(opts)
|
|
42
|
+
if code != 0 {
|
|
43
|
+
return code
|
|
44
|
+
}
|
|
45
|
+
defer func() {
|
|
46
|
+
if prog != nil {
|
|
47
|
+
prog.close()
|
|
48
|
+
}
|
|
49
|
+
}()
|
|
50
|
+
|
|
51
|
+
totalFixes := 0
|
|
52
|
+
if fixed, err := runExternalESLintFixes(rules, opts.cwd, prog.userSourceFiles()); err != nil {
|
|
53
|
+
fmt.Fprintln(os.Stderr, err)
|
|
54
|
+
return 2
|
|
55
|
+
} else if fixed > 0 {
|
|
56
|
+
totalFixes += fixed
|
|
57
|
+
prog, code = reloadFixProgram(prog, opts)
|
|
58
|
+
if code != 0 {
|
|
59
|
+
return code
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// `ttsc fix` applies edits from BOTH lint-class rules and
|
|
64
|
+
// format-class rules. The dual `ttsc format` subcommand exists for
|
|
65
|
+
// the format-only path; fix is the "run everything" entry point so
|
|
66
|
+
// users don't have to chain two invocations. The engine emits both
|
|
67
|
+
// kinds of findings in one pass — no filtering needed here.
|
|
68
|
+
cascadeConverged := false
|
|
69
|
+
for pass := 0; pass < maxFixPasses; pass++ {
|
|
70
|
+
engine := NewEngineWithResolver(rules)
|
|
71
|
+
findings := engine.Run(prog.userSourceFiles(), prog.checker)
|
|
72
|
+
fixed, err := applyFindingFixes(opts.cwd, findings)
|
|
73
|
+
if err != nil {
|
|
74
|
+
fmt.Fprintln(os.Stderr, err)
|
|
75
|
+
return 3
|
|
76
|
+
}
|
|
77
|
+
if fixed == 0 {
|
|
78
|
+
cascadeConverged = true
|
|
79
|
+
break
|
|
80
|
+
}
|
|
81
|
+
totalFixes += fixed
|
|
82
|
+
prog, code = reloadFixProgram(prog, opts)
|
|
83
|
+
if code != 0 {
|
|
84
|
+
return code
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if !cascadeConverged {
|
|
88
|
+
// A non-converged exit means at least one rule kept emitting
|
|
89
|
+
// edits on every pass — typically a buggy fixer that doesn't
|
|
90
|
+
// settle the diagnostic it produces. The remaining findings still
|
|
91
|
+
// surface below as ordinary diagnostics, and the exit code below
|
|
92
|
+
// is bumped to 2 so a CI gate like `ttsc fix && echo ok` does not
|
|
93
|
+
// silently accept the buggy-fixer state.
|
|
94
|
+
fmt.Fprintf(os.Stderr,
|
|
95
|
+
"@ttsc/lint: fix cascade did not converge after %d passes; remaining diagnostics are reported below\n",
|
|
96
|
+
maxFixPasses)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
engine := NewEngineWithResolver(rules)
|
|
100
|
+
astDiags, lintDiags, externalRan, err := collectDiagnostics(prog, engine)
|
|
101
|
+
if err != nil {
|
|
102
|
+
fmt.Fprintln(os.Stderr, err)
|
|
103
|
+
return 2
|
|
104
|
+
}
|
|
105
|
+
if !externalRan {
|
|
106
|
+
warnUnknownRules(os.Stderr, engine.UnknownRules())
|
|
107
|
+
}
|
|
108
|
+
errCount := shimdw.FormatMixedDiagnostics(os.Stderr, astDiags, lintDiags, opts.cwd)
|
|
109
|
+
if errCount > 0 {
|
|
110
|
+
return 2
|
|
111
|
+
}
|
|
112
|
+
if !cascadeConverged {
|
|
113
|
+
// Diagnostics may all be warnings (or empty) yet the cascade did
|
|
114
|
+
// not settle — surface the failure as exit 2 so the warning above
|
|
115
|
+
// is not lost in a shell `&& echo ok` pipeline.
|
|
116
|
+
return 2
|
|
117
|
+
}
|
|
118
|
+
if opts.verbose && totalFixes > 0 {
|
|
119
|
+
fmt.Fprintf(os.Stdout, "@ttsc/lint: fixed=%d edits\n", totalFixes)
|
|
120
|
+
}
|
|
121
|
+
return 0
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
func loadFixProgram(opts *subcommandOpts) (*program, int) {
|
|
125
|
+
prog, parseDiags, err := loadProgram(opts.cwd, opts.tsconfig, loadProgramOptions{
|
|
126
|
+
forceNoEmit: true,
|
|
127
|
+
outDir: opts.outDir,
|
|
128
|
+
})
|
|
129
|
+
if err != nil {
|
|
130
|
+
fmt.Fprintf(os.Stderr, "@ttsc/lint: %v\n", err)
|
|
131
|
+
return nil, 2
|
|
132
|
+
}
|
|
133
|
+
if len(parseDiags) > 0 {
|
|
134
|
+
shimdw.FormatASTDiagnosticsWithColorAndContext(os.Stderr, parseDiags, opts.cwd)
|
|
135
|
+
return nil, 2
|
|
136
|
+
}
|
|
137
|
+
return prog, 0
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
func reloadFixProgram(current *program, opts *subcommandOpts) (*program, int) {
|
|
141
|
+
if current != nil {
|
|
142
|
+
current.close()
|
|
143
|
+
}
|
|
144
|
+
return loadFixProgram(opts)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
type fileFixes struct {
|
|
148
|
+
path string
|
|
149
|
+
text string
|
|
150
|
+
edits []TextEdit
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
func applyFindingFixes(cwd string, findings []*Finding) (int, error) {
|
|
154
|
+
byFile := map[string]*fileFixes{}
|
|
155
|
+
for _, finding := range findings {
|
|
156
|
+
if finding == nil || finding.File == nil || len(finding.Fix) == 0 {
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
path := finding.File.FileName()
|
|
160
|
+
if path == "" {
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
if !filepath.IsAbs(path) {
|
|
164
|
+
path = filepath.Join(cwd, path)
|
|
165
|
+
}
|
|
166
|
+
if abs, err := filepath.Abs(path); err == nil {
|
|
167
|
+
path = abs
|
|
168
|
+
}
|
|
169
|
+
bucket := byFile[path]
|
|
170
|
+
if bucket == nil {
|
|
171
|
+
bucket = &fileFixes{path: path, text: finding.File.Text()}
|
|
172
|
+
byFile[path] = bucket
|
|
173
|
+
}
|
|
174
|
+
bucket.edits = append(bucket.edits, finding.Fix...)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
paths := make([]string, 0, len(byFile))
|
|
178
|
+
for p := range byFile {
|
|
179
|
+
paths = append(paths, p)
|
|
180
|
+
}
|
|
181
|
+
sort.Strings(paths)
|
|
182
|
+
total := 0
|
|
183
|
+
for _, p := range paths {
|
|
184
|
+
bucket := byFile[p]
|
|
185
|
+
fixed, err := applyTextEditsToFile(bucket.path, bucket.text, bucket.edits)
|
|
186
|
+
if err != nil {
|
|
187
|
+
return total, err
|
|
188
|
+
}
|
|
189
|
+
total += fixed
|
|
190
|
+
}
|
|
191
|
+
return total, nil
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
func applyTextEditsToFile(path, source string, edits []TextEdit) (int, error) {
|
|
195
|
+
selected := selectTextEdits(len(source), edits)
|
|
196
|
+
if len(selected) == 0 {
|
|
197
|
+
return 0, nil
|
|
198
|
+
}
|
|
199
|
+
next := source
|
|
200
|
+
for i := len(selected) - 1; i >= 0; i-- {
|
|
201
|
+
edit := selected[i]
|
|
202
|
+
next = next[:edit.Pos] + edit.Text + next[edit.End:]
|
|
203
|
+
}
|
|
204
|
+
if next == source {
|
|
205
|
+
return 0, nil
|
|
206
|
+
}
|
|
207
|
+
if err := os.WriteFile(path, []byte(next), 0o644); err != nil {
|
|
208
|
+
return 0, fmt.Errorf("@ttsc/lint fix: write %s: %w", path, err)
|
|
209
|
+
}
|
|
210
|
+
return len(selected), nil
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
func selectTextEdits(sourceLen int, edits []TextEdit) []TextEdit {
|
|
214
|
+
if len(edits) == 0 {
|
|
215
|
+
return nil
|
|
216
|
+
}
|
|
217
|
+
sorted := make([]TextEdit, 0, len(edits))
|
|
218
|
+
seen := map[TextEdit]struct{}{}
|
|
219
|
+
for _, edit := range edits {
|
|
220
|
+
if edit.Pos < 0 || edit.End < edit.Pos || edit.End > sourceLen {
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
223
|
+
if _, exists := seen[edit]; exists {
|
|
224
|
+
continue
|
|
225
|
+
}
|
|
226
|
+
seen[edit] = struct{}{}
|
|
227
|
+
sorted = append(sorted, edit)
|
|
228
|
+
}
|
|
229
|
+
sort.SliceStable(sorted, func(i, j int) bool {
|
|
230
|
+
if sorted[i].Pos != sorted[j].Pos {
|
|
231
|
+
return sorted[i].Pos < sorted[j].Pos
|
|
232
|
+
}
|
|
233
|
+
if sorted[i].End != sorted[j].End {
|
|
234
|
+
return sorted[i].End < sorted[j].End
|
|
235
|
+
}
|
|
236
|
+
return sorted[i].Text < sorted[j].Text
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
selected := make([]TextEdit, 0, len(sorted))
|
|
240
|
+
lastEnd := -1
|
|
241
|
+
for _, edit := range sorted {
|
|
242
|
+
if edit.Pos < lastEnd {
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
245
|
+
selected = append(selected, edit)
|
|
246
|
+
lastEnd = edit.End
|
|
247
|
+
}
|
|
248
|
+
return selected
|
|
249
|
+
}
|