@ttsc/lint 0.5.0-dev.20260429
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 +204 -0
- package/go-plugin/go.mod +38 -0
- package/go-plugin/lint/ast_helpers.go +170 -0
- package/go-plugin/lint/compile.go +393 -0
- package/go-plugin/lint/config.go +134 -0
- package/go-plugin/lint/engine.go +259 -0
- package/go-plugin/lint/host.go +190 -0
- package/go-plugin/lint/rules_arrays.go +78 -0
- package/go-plugin/lint/rules_console.go +37 -0
- package/go-plugin/lint/rules_debugger.go +29 -0
- package/go-plugin/lint/rules_dupes.go +179 -0
- package/go-plugin/lint/rules_empty.go +110 -0
- package/go-plugin/lint/rules_eval.go +57 -0
- package/go-plugin/lint/rules_finally.go +123 -0
- package/go-plugin/lint/rules_logic.go +368 -0
- package/go-plugin/lint/rules_loops.go +107 -0
- package/go-plugin/lint/rules_misc.go +69 -0
- package/go-plugin/lint/rules_problems.go +571 -0
- package/go-plugin/lint/rules_protos.go +42 -0
- package/go-plugin/lint/rules_self.go +89 -0
- package/go-plugin/lint/rules_strings.go +123 -0
- package/go-plugin/lint/rules_suggestions.go +1236 -0
- package/go-plugin/lint/rules_throw.go +31 -0
- package/go-plugin/lint/rules_ts.go +250 -0
- package/go-plugin/lint/rules_ts_extra.go +654 -0
- package/go-plugin/lint/rules_var.go +40 -0
- package/go-plugin/main.go +50 -0
- package/index.cjs +28 -0
- package/package.json +41 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
// Subcommand orchestration for the `@ttsc/lint` native binary.
|
|
2
|
+
//
|
|
3
|
+
// The plugin host shells out to this binary with one of three project
|
|
4
|
+
// commands (`check`, `build`, `transform`). Each shares the same setup:
|
|
5
|
+
// parse flags, bootstrap a Program + Checker (see host.go), run the lint
|
|
6
|
+
// engine alongside tsgo's typecheck diagnostics, and render through
|
|
7
|
+
// shim/diagnosticwriter so the output matches `tsgo --noEmit`.
|
|
8
|
+
//
|
|
9
|
+
// The split between this file and `engine.go` is deliberate: the engine
|
|
10
|
+
// is pure (rules + AST traversal), and this file owns every side effect
|
|
11
|
+
// (process flags, stderr/stdout, emit, exit codes).
|
|
12
|
+
package lint
|
|
13
|
+
|
|
14
|
+
import (
|
|
15
|
+
"context"
|
|
16
|
+
"errors"
|
|
17
|
+
"flag"
|
|
18
|
+
"fmt"
|
|
19
|
+
"io"
|
|
20
|
+
"os"
|
|
21
|
+
"path/filepath"
|
|
22
|
+
"strings"
|
|
23
|
+
|
|
24
|
+
shimast "github.com/microsoft/typescript-go/shim/ast"
|
|
25
|
+
shimcompiler "github.com/microsoft/typescript-go/shim/compiler"
|
|
26
|
+
shimdw "github.com/microsoft/typescript-go/shim/diagnosticwriter"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
// RunCheck implements `@ttsc/lint check` — typecheck + lint, no emit.
|
|
30
|
+
func RunCheck(args []string) int {
|
|
31
|
+
opts, err := parseSubcommandFlags("check", args)
|
|
32
|
+
if err != nil {
|
|
33
|
+
fmt.Fprintln(os.Stderr, err)
|
|
34
|
+
return 2
|
|
35
|
+
}
|
|
36
|
+
opts.noEmit = true
|
|
37
|
+
return runProject(opts)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// RunBuild implements `@ttsc/lint build` — same diagnostic flow as
|
|
41
|
+
// `check`, plus the tsgo emit pipeline when emit is requested.
|
|
42
|
+
func RunBuild(args []string) int {
|
|
43
|
+
opts, err := parseSubcommandFlags("build", args)
|
|
44
|
+
if err != nil {
|
|
45
|
+
fmt.Fprintln(os.Stderr, err)
|
|
46
|
+
return 2
|
|
47
|
+
}
|
|
48
|
+
return runProject(opts)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// RunTransform implements `@ttsc/lint transform --file=PATH`. Lint rules
|
|
52
|
+
// still run for the whole program (lint quality depends on context), but
|
|
53
|
+
// emit is restricted to the requested file's JS output.
|
|
54
|
+
func RunTransform(args []string) int {
|
|
55
|
+
fs := flag.NewFlagSet("transform", flag.ContinueOnError)
|
|
56
|
+
fs.SetOutput(os.Stderr)
|
|
57
|
+
file := fs.String("file", "", "absolute or cwd-relative path of the .ts file to transform")
|
|
58
|
+
out := fs.String("out", "", "write output JS to PATH (default: stdout)")
|
|
59
|
+
tsconfig := fs.String("tsconfig", "tsconfig.json", "tsconfig owning --file")
|
|
60
|
+
cwd := fs.String("cwd", "", "override the working directory")
|
|
61
|
+
rewriteMode := fs.String("rewrite-mode", "ttsc-lint", "native rewrite backend id (informational)")
|
|
62
|
+
pluginsJSON := fs.String("plugins-json", "", "ttsc plugin manifest JSON")
|
|
63
|
+
if err := fs.Parse(filterKnownFlags(args, map[string]bool{
|
|
64
|
+
"cwd": true,
|
|
65
|
+
"file": true,
|
|
66
|
+
"out": true,
|
|
67
|
+
"plugins-json": true,
|
|
68
|
+
"rewrite-mode": true,
|
|
69
|
+
"tsconfig": true,
|
|
70
|
+
})); err != nil {
|
|
71
|
+
return 2
|
|
72
|
+
}
|
|
73
|
+
_ = rewriteMode
|
|
74
|
+
if *file == "" {
|
|
75
|
+
fmt.Fprintln(os.Stderr, "@ttsc/lint transform: --file is required")
|
|
76
|
+
return 2
|
|
77
|
+
}
|
|
78
|
+
resolvedCwd, err := resolveCwd(*cwd)
|
|
79
|
+
if err != nil {
|
|
80
|
+
fmt.Fprintln(os.Stderr, err)
|
|
81
|
+
return 2
|
|
82
|
+
}
|
|
83
|
+
prog, parseDiags, err := loadProgram(resolvedCwd, *tsconfig, loadProgramOptions{
|
|
84
|
+
forceEmit: true,
|
|
85
|
+
})
|
|
86
|
+
if err != nil {
|
|
87
|
+
fmt.Fprintf(os.Stderr, "@ttsc/lint: %v\n", err)
|
|
88
|
+
return 2
|
|
89
|
+
}
|
|
90
|
+
if len(parseDiags) > 0 {
|
|
91
|
+
shimdw.FormatASTDiagnosticsWithColorAndContext(os.Stderr, parseDiags, resolvedCwd)
|
|
92
|
+
return 2
|
|
93
|
+
}
|
|
94
|
+
defer prog.close()
|
|
95
|
+
|
|
96
|
+
rules, err := loadRules(*pluginsJSON)
|
|
97
|
+
if err != nil {
|
|
98
|
+
fmt.Fprintln(os.Stderr, err)
|
|
99
|
+
return 2
|
|
100
|
+
}
|
|
101
|
+
engine := NewEngine(rules)
|
|
102
|
+
warnUnknownRules(os.Stderr, engine.UnknownRules())
|
|
103
|
+
|
|
104
|
+
astDiags, lintDiags := collectDiagnostics(prog, engine)
|
|
105
|
+
if errors := shimdw.FormatMixedDiagnostics(os.Stderr, astDiags, lintDiags, resolvedCwd); errors > 0 {
|
|
106
|
+
return 2
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
absFile := *file
|
|
110
|
+
if !filepath.IsAbs(absFile) {
|
|
111
|
+
absFile = filepath.Join(resolvedCwd, absFile)
|
|
112
|
+
}
|
|
113
|
+
target := prog.findSourceFile(absFile)
|
|
114
|
+
if target == nil {
|
|
115
|
+
fmt.Fprintf(os.Stderr, "@ttsc/lint transform: source file not in program: %s\n", absFile)
|
|
116
|
+
return 2
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
var captured string
|
|
120
|
+
capture := func(name, text string, _ *shimcompiler.WriteFileData) error {
|
|
121
|
+
if !isJavaScriptOutput(name) {
|
|
122
|
+
return nil
|
|
123
|
+
}
|
|
124
|
+
captured = text
|
|
125
|
+
return nil
|
|
126
|
+
}
|
|
127
|
+
result := prog.tsProgram.Emit(context.Background(), shimcompiler.EmitOptions{
|
|
128
|
+
TargetSourceFile: target,
|
|
129
|
+
WriteFile: shimcompiler.WriteFile(capture),
|
|
130
|
+
})
|
|
131
|
+
if result == nil {
|
|
132
|
+
fmt.Fprintln(os.Stderr, "@ttsc/lint transform: Emit returned nil")
|
|
133
|
+
return 3
|
|
134
|
+
}
|
|
135
|
+
if len(result.Diagnostics) > 0 {
|
|
136
|
+
shimdw.FormatASTDiagnosticsWithColorAndContext(os.Stderr, result.Diagnostics, resolvedCwd)
|
|
137
|
+
}
|
|
138
|
+
if captured == "" {
|
|
139
|
+
fmt.Fprintf(os.Stderr, "@ttsc/lint transform: no output produced for %s\n", absFile)
|
|
140
|
+
return 3
|
|
141
|
+
}
|
|
142
|
+
if *out == "" {
|
|
143
|
+
fmt.Fprint(os.Stdout, captured)
|
|
144
|
+
return 0
|
|
145
|
+
}
|
|
146
|
+
if err := os.MkdirAll(filepath.Dir(*out), 0o755); err != nil {
|
|
147
|
+
fmt.Fprintf(os.Stderr, "@ttsc/lint transform: mkdir: %v\n", err)
|
|
148
|
+
return 3
|
|
149
|
+
}
|
|
150
|
+
if err := os.WriteFile(*out, []byte(captured), 0o644); err != nil {
|
|
151
|
+
fmt.Fprintf(os.Stderr, "@ttsc/lint transform: write: %v\n", err)
|
|
152
|
+
return 3
|
|
153
|
+
}
|
|
154
|
+
return 0
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
type subcommandOpts struct {
|
|
158
|
+
cwd string
|
|
159
|
+
tsconfig string
|
|
160
|
+
pluginsJSON string
|
|
161
|
+
rewriteMode string
|
|
162
|
+
emit bool
|
|
163
|
+
noEmit bool
|
|
164
|
+
quiet bool
|
|
165
|
+
verbose bool
|
|
166
|
+
outDir string
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
func parseSubcommandFlags(name string, args []string) (*subcommandOpts, error) {
|
|
170
|
+
fs := flag.NewFlagSet(name, flag.ContinueOnError)
|
|
171
|
+
fs.SetOutput(os.Stderr)
|
|
172
|
+
cwd := fs.String("cwd", "", "")
|
|
173
|
+
tsconfig := fs.String("tsconfig", "tsconfig.json", "")
|
|
174
|
+
pluginsJSON := fs.String("plugins-json", "", "")
|
|
175
|
+
rewriteMode := fs.String("rewrite-mode", "ttsc-lint", "")
|
|
176
|
+
emit := fs.Bool("emit", false, "")
|
|
177
|
+
noEmit := fs.Bool("noEmit", false, "")
|
|
178
|
+
quiet := fs.Bool("quiet", false, "")
|
|
179
|
+
verbose := fs.Bool("verbose", false, "")
|
|
180
|
+
outDir := fs.String("outDir", "", "")
|
|
181
|
+
if err := fs.Parse(filterKnownFlags(args, map[string]bool{
|
|
182
|
+
"cwd": true,
|
|
183
|
+
"emit": false,
|
|
184
|
+
"noEmit": false,
|
|
185
|
+
"outDir": true,
|
|
186
|
+
"plugins-json": true,
|
|
187
|
+
"quiet": false,
|
|
188
|
+
"rewrite-mode": true,
|
|
189
|
+
"tsconfig": true,
|
|
190
|
+
"verbose": false,
|
|
191
|
+
})); err != nil {
|
|
192
|
+
return nil, err
|
|
193
|
+
}
|
|
194
|
+
if *emit && *noEmit {
|
|
195
|
+
return nil, errors.New("@ttsc/lint: --emit and --noEmit are mutually exclusive")
|
|
196
|
+
}
|
|
197
|
+
resolvedCwd, err := resolveCwd(*cwd)
|
|
198
|
+
if err != nil {
|
|
199
|
+
return nil, err
|
|
200
|
+
}
|
|
201
|
+
return &subcommandOpts{
|
|
202
|
+
cwd: resolvedCwd,
|
|
203
|
+
tsconfig: *tsconfig,
|
|
204
|
+
pluginsJSON: *pluginsJSON,
|
|
205
|
+
rewriteMode: *rewriteMode,
|
|
206
|
+
emit: *emit,
|
|
207
|
+
noEmit: *noEmit,
|
|
208
|
+
quiet: *quiet,
|
|
209
|
+
verbose: *verbose,
|
|
210
|
+
outDir: *outDir,
|
|
211
|
+
}, nil
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
func runProject(opts *subcommandOpts) int {
|
|
215
|
+
prog, parseDiags, err := loadProgram(opts.cwd, opts.tsconfig, loadProgramOptions{
|
|
216
|
+
forceEmit: opts.emit,
|
|
217
|
+
forceNoEmit: opts.noEmit,
|
|
218
|
+
outDir: opts.outDir,
|
|
219
|
+
})
|
|
220
|
+
if err != nil {
|
|
221
|
+
fmt.Fprintf(os.Stderr, "@ttsc/lint: %v\n", err)
|
|
222
|
+
return 2
|
|
223
|
+
}
|
|
224
|
+
if len(parseDiags) > 0 {
|
|
225
|
+
shimdw.FormatASTDiagnosticsWithColorAndContext(os.Stderr, parseDiags, opts.cwd)
|
|
226
|
+
return 2
|
|
227
|
+
}
|
|
228
|
+
defer prog.close()
|
|
229
|
+
|
|
230
|
+
rules, err := loadRules(opts.pluginsJSON)
|
|
231
|
+
if err != nil {
|
|
232
|
+
fmt.Fprintln(os.Stderr, err)
|
|
233
|
+
return 2
|
|
234
|
+
}
|
|
235
|
+
engine := NewEngine(rules)
|
|
236
|
+
warnUnknownRules(os.Stderr, engine.UnknownRules())
|
|
237
|
+
|
|
238
|
+
astDiags, lintDiags := collectDiagnostics(prog, engine)
|
|
239
|
+
if errCount := shimdw.FormatMixedDiagnostics(os.Stderr, astDiags, lintDiags, opts.cwd); errCount > 0 {
|
|
240
|
+
return 2
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if opts.noEmit || prog.parsed.ParsedConfig.CompilerOptions.NoEmit.IsTrue() {
|
|
244
|
+
return 0
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
result := prog.tsProgram.Emit(context.Background(), shimcompiler.EmitOptions{
|
|
248
|
+
WriteFile: shimcompiler.WriteFile(func(fileName, text string, data *shimcompiler.WriteFileData) error {
|
|
249
|
+
return defaultWriteFile(fileName, text)
|
|
250
|
+
}),
|
|
251
|
+
})
|
|
252
|
+
if result == nil {
|
|
253
|
+
fmt.Fprintln(os.Stderr, "@ttsc/lint: Emit returned nil")
|
|
254
|
+
return 3
|
|
255
|
+
}
|
|
256
|
+
if len(result.Diagnostics) > 0 {
|
|
257
|
+
errCount := shimdw.FormatMixedDiagnostics(os.Stderr, result.Diagnostics, nil, opts.cwd)
|
|
258
|
+
if errCount > 0 {
|
|
259
|
+
return 2
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if opts.verbose && result.EmittedFiles != nil {
|
|
263
|
+
fmt.Fprintf(os.Stdout, "@ttsc/lint: emitted=%d files\n", len(result.EmittedFiles))
|
|
264
|
+
for _, f := range result.EmittedFiles {
|
|
265
|
+
fmt.Fprintln(os.Stdout, " +", f)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return 0
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
func loadRules(pluginsJSON string) (RuleConfig, error) {
|
|
272
|
+
entries, err := ParsePlugins(pluginsJSON)
|
|
273
|
+
if err != nil {
|
|
274
|
+
return nil, err
|
|
275
|
+
}
|
|
276
|
+
entry, err := FindLintEntry(entries)
|
|
277
|
+
if err != nil {
|
|
278
|
+
return nil, err
|
|
279
|
+
}
|
|
280
|
+
if entry == nil {
|
|
281
|
+
return RuleConfig{}, nil
|
|
282
|
+
}
|
|
283
|
+
return ParseRules(entry.Config["rules"])
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
func warnUnknownRules(w io.Writer, unknown []string) {
|
|
287
|
+
for _, name := range unknown {
|
|
288
|
+
fmt.Fprintf(w, "@ttsc/lint: ignoring unknown rule %q\n", name)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
func filterKnownFlags(args []string, known map[string]bool) []string {
|
|
293
|
+
out := make([]string, 0, len(args))
|
|
294
|
+
for i := 0; i < len(args); i++ {
|
|
295
|
+
arg := args[i]
|
|
296
|
+
if !strings.HasPrefix(arg, "-") || arg == "-" {
|
|
297
|
+
out = append(out, arg)
|
|
298
|
+
continue
|
|
299
|
+
}
|
|
300
|
+
name := strings.TrimLeft(arg, "-")
|
|
301
|
+
hasValue := strings.Contains(name, "=")
|
|
302
|
+
if index := strings.Index(name, "="); index >= 0 {
|
|
303
|
+
name = name[:index]
|
|
304
|
+
}
|
|
305
|
+
needsValue, ok := known[name]
|
|
306
|
+
if !ok {
|
|
307
|
+
if !hasValue && i+1 < len(args) && !strings.HasPrefix(args[i+1], "-") {
|
|
308
|
+
i++
|
|
309
|
+
}
|
|
310
|
+
continue
|
|
311
|
+
}
|
|
312
|
+
out = append(out, arg)
|
|
313
|
+
if needsValue && !hasValue && i+1 < len(args) {
|
|
314
|
+
i++
|
|
315
|
+
out = append(out, args[i])
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return out
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// collectDiagnostics merges tsgo typecheck diagnostics with the lint
|
|
322
|
+
// engine's findings. The renderer takes the two slices and walks them in
|
|
323
|
+
// source order, so we don't need to interleave here.
|
|
324
|
+
func collectDiagnostics(prog *program, engine *Engine) ([]*shimast.Diagnostic, []*shimdw.LintDiagnostic) {
|
|
325
|
+
astDiags := prog.programDiagnostics()
|
|
326
|
+
files := prog.userSourceFiles()
|
|
327
|
+
findings := engine.Run(files, prog.checker)
|
|
328
|
+
lintDiags := make([]*shimdw.LintDiagnostic, 0, len(findings))
|
|
329
|
+
for _, finding := range findings {
|
|
330
|
+
category := shimdw.LintCategoryError
|
|
331
|
+
if finding.Severity == SeverityWarn {
|
|
332
|
+
category = shimdw.LintCategoryWarning
|
|
333
|
+
}
|
|
334
|
+
lintDiags = append(lintDiags, shimdw.NewLintDiagnostic(
|
|
335
|
+
finding.File,
|
|
336
|
+
finding.Pos,
|
|
337
|
+
finding.End,
|
|
338
|
+
ruleCode(finding.Rule),
|
|
339
|
+
category,
|
|
340
|
+
fmt.Sprintf("[%s] %s", finding.Rule, finding.Message),
|
|
341
|
+
))
|
|
342
|
+
}
|
|
343
|
+
return astDiags, lintDiags
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// RuleCode hashes a rule name into a stable, positive int32 so the
|
|
347
|
+
// renderer's banner (`TS9123`-style) is unique per rule. Codes start at
|
|
348
|
+
// 9000 to avoid colliding with tsgo's diagnostic codes (which top out
|
|
349
|
+
// well below that range). Public because tests pin the hash band.
|
|
350
|
+
func RuleCode(name string) int32 {
|
|
351
|
+
const prime = 16777619
|
|
352
|
+
var h uint32 = 2166136261
|
|
353
|
+
for i := 0; i < len(name); i++ {
|
|
354
|
+
h ^= uint32(name[i])
|
|
355
|
+
h *= prime
|
|
356
|
+
}
|
|
357
|
+
return int32(9000 + (h % 9000))
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ruleCode is the internal alias kept for callers in the same package
|
|
361
|
+
// that prefer the lowercase form.
|
|
362
|
+
func ruleCode(name string) int32 { return RuleCode(name) }
|
|
363
|
+
|
|
364
|
+
func resolveCwd(override string) (string, error) {
|
|
365
|
+
if override != "" {
|
|
366
|
+
abs, err := filepath.Abs(override)
|
|
367
|
+
if err != nil {
|
|
368
|
+
return "", fmt.Errorf("@ttsc/lint: --cwd: %w", err)
|
|
369
|
+
}
|
|
370
|
+
return abs, nil
|
|
371
|
+
}
|
|
372
|
+
wd, err := os.Getwd()
|
|
373
|
+
if err != nil {
|
|
374
|
+
return "", fmt.Errorf("@ttsc/lint: cwd: %w", err)
|
|
375
|
+
}
|
|
376
|
+
return wd, nil
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
func isJavaScriptOutput(name string) bool {
|
|
380
|
+
switch strings.ToLower(filepath.Ext(name)) {
|
|
381
|
+
case ".js", ".mjs", ".cjs":
|
|
382
|
+
return true
|
|
383
|
+
default:
|
|
384
|
+
return false
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
func defaultWriteFile(name string, text string) error {
|
|
389
|
+
if err := os.MkdirAll(filepath.Dir(name), 0o755); err != nil {
|
|
390
|
+
return err
|
|
391
|
+
}
|
|
392
|
+
return os.WriteFile(name, []byte(text), 0o644)
|
|
393
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
package lint
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"fmt"
|
|
6
|
+
"strings"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// Severity is the `error | warning | off` ladder.
|
|
10
|
+
type Severity int
|
|
11
|
+
|
|
12
|
+
const (
|
|
13
|
+
SeverityOff Severity = iota
|
|
14
|
+
SeverityWarn
|
|
15
|
+
SeverityError
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
func (s Severity) String() string {
|
|
19
|
+
switch s {
|
|
20
|
+
case SeverityError:
|
|
21
|
+
return "error"
|
|
22
|
+
case SeverityWarn:
|
|
23
|
+
return "warning"
|
|
24
|
+
case SeverityOff:
|
|
25
|
+
return "off"
|
|
26
|
+
}
|
|
27
|
+
return "unknown"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// PluginEntry mirrors the shape ttsc serializes into `--plugins-json`.
|
|
31
|
+
//
|
|
32
|
+
// `Config` carries arbitrary fields from the tsconfig plugin entry,
|
|
33
|
+
// including `rules` for `@ttsc/lint`. `Mode` and `Name` come from the
|
|
34
|
+
// native descriptor.
|
|
35
|
+
type PluginEntry struct {
|
|
36
|
+
Config map[string]any `json:"config"`
|
|
37
|
+
ContractVersion int `json:"contractVersion"`
|
|
38
|
+
Mode string `json:"mode"`
|
|
39
|
+
Name string `json:"name"`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ParsePlugins decodes the `--plugins-json` payload.
|
|
43
|
+
func ParsePlugins(text string) ([]PluginEntry, error) {
|
|
44
|
+
if strings.TrimSpace(text) == "" {
|
|
45
|
+
return nil, nil
|
|
46
|
+
}
|
|
47
|
+
var entries []PluginEntry
|
|
48
|
+
if err := json.Unmarshal([]byte(text), &entries); err != nil {
|
|
49
|
+
return nil, fmt.Errorf("@ttsc/lint: invalid --plugins-json: %w", err)
|
|
50
|
+
}
|
|
51
|
+
return entries, nil
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// FindLintEntry returns the lint entry only when it is the first active
|
|
55
|
+
// plugin. Linting after a source-transforming plugin would inspect mutated
|
|
56
|
+
// source, which is not a meaningful user-code lint result.
|
|
57
|
+
func FindLintEntry(entries []PluginEntry) (*PluginEntry, error) {
|
|
58
|
+
for i := range entries {
|
|
59
|
+
if entries[i].Mode == "ttsc-lint" {
|
|
60
|
+
if i != 0 {
|
|
61
|
+
return nil, fmt.Errorf("@ttsc/lint must be the first active compilerOptions.plugins entry")
|
|
62
|
+
}
|
|
63
|
+
return &entries[i], nil
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return nil, nil
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// RuleConfig captures the resolved per-rule severity. The map is keyed by
|
|
70
|
+
// rule name (e.g. "no-var").
|
|
71
|
+
type RuleConfig map[string]Severity
|
|
72
|
+
|
|
73
|
+
// ParseRules normalizes the `rules` map from a tsconfig plugin entry.
|
|
74
|
+
//
|
|
75
|
+
// Severity values:
|
|
76
|
+
// - `"off"` → SeverityOff
|
|
77
|
+
// - `"warning"` → SeverityWarn
|
|
78
|
+
// - `"error"` → SeverityError
|
|
79
|
+
//
|
|
80
|
+
// Anything else returns an error (no silent fallback — typos in a rule
|
|
81
|
+
// severity should be loud).
|
|
82
|
+
func ParseRules(raw any) (RuleConfig, error) {
|
|
83
|
+
if raw == nil {
|
|
84
|
+
return RuleConfig{}, nil
|
|
85
|
+
}
|
|
86
|
+
dict, ok := raw.(map[string]any)
|
|
87
|
+
if !ok {
|
|
88
|
+
return nil, fmt.Errorf("@ttsc/lint: \"rules\" must be an object, got %T", raw)
|
|
89
|
+
}
|
|
90
|
+
out := make(RuleConfig, len(dict))
|
|
91
|
+
for name, value := range dict {
|
|
92
|
+
sev, err := parseSeverity(value)
|
|
93
|
+
if err != nil {
|
|
94
|
+
return nil, fmt.Errorf("@ttsc/lint: rule %q: %w", name, err)
|
|
95
|
+
}
|
|
96
|
+
out[name] = sev
|
|
97
|
+
}
|
|
98
|
+
return out, nil
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
func parseSeverity(v any) (Severity, error) {
|
|
102
|
+
switch x := v.(type) {
|
|
103
|
+
case string:
|
|
104
|
+
switch x {
|
|
105
|
+
case "off":
|
|
106
|
+
return SeverityOff, nil
|
|
107
|
+
case "warning", "warn":
|
|
108
|
+
return SeverityWarn, nil
|
|
109
|
+
case "error":
|
|
110
|
+
return SeverityError, nil
|
|
111
|
+
}
|
|
112
|
+
return SeverityOff, fmt.Errorf("unknown severity %q (want off | warning | error)", x)
|
|
113
|
+
case float64:
|
|
114
|
+
switch x {
|
|
115
|
+
case 0:
|
|
116
|
+
return SeverityOff, nil
|
|
117
|
+
case 1:
|
|
118
|
+
return SeverityWarn, nil
|
|
119
|
+
case 2:
|
|
120
|
+
return SeverityError, nil
|
|
121
|
+
}
|
|
122
|
+
return SeverityOff, fmt.Errorf("unknown severity %v (want off | warning | error)", x)
|
|
123
|
+
}
|
|
124
|
+
return SeverityOff, fmt.Errorf("severity must be one of: off | warning | error, got %T", v)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Severity returns the configured level for a rule, defaulting to
|
|
128
|
+
// `SeverityOff`. Rules opt in explicitly — silent on missing entries.
|
|
129
|
+
func (c RuleConfig) Severity(name string) Severity {
|
|
130
|
+
if c == nil {
|
|
131
|
+
return SeverityOff
|
|
132
|
+
}
|
|
133
|
+
return c[name]
|
|
134
|
+
}
|