@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.
@@ -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
+ }