@ttsc/lint 0.10.2 → 0.11.0-dev.20260517

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.
Files changed (113) hide show
  1. package/README.md +172 -33
  2. package/lib/defineConfig.d.ts +55 -0
  3. package/lib/defineConfig.js +39 -0
  4. package/lib/defineConfig.js.map +1 -0
  5. package/lib/index.d.ts +25 -1
  6. package/lib/index.js +622 -2
  7. package/lib/index.js.map +1 -1
  8. package/lib/structures/ITtscLintPlugin.d.ts +53 -0
  9. package/lib/structures/ITtscLintPlugin.js +3 -0
  10. package/lib/structures/ITtscLintPlugin.js.map +1 -0
  11. package/lib/structures/ITtscLintPluginConfig.d.ts +82 -5
  12. package/lib/structures/ITtscLintPluginMeta.d.ts +12 -0
  13. package/lib/structures/ITtscLintPluginMeta.js +3 -0
  14. package/lib/structures/ITtscLintPluginMeta.js.map +1 -0
  15. package/lib/structures/PluginRuleNames.d.ts +14 -0
  16. package/lib/structures/PluginRuleNames.js +3 -0
  17. package/lib/structures/PluginRuleNames.js.map +1 -0
  18. package/lib/structures/TtscLintConfig.d.ts +24 -8
  19. package/lib/structures/TtscLintConfigEntry.d.ts +39 -0
  20. package/lib/structures/TtscLintConfigEntry.js +3 -0
  21. package/lib/structures/TtscLintConfigEntry.js.map +1 -0
  22. package/lib/structures/TtscLintFormatConfig.d.ts +154 -0
  23. package/lib/structures/TtscLintFormatConfig.js +3 -0
  24. package/lib/structures/TtscLintFormatConfig.js.map +1 -0
  25. package/lib/structures/TtscLintPlugins.d.ts +9 -0
  26. package/lib/structures/TtscLintPlugins.js +3 -0
  27. package/lib/structures/TtscLintPlugins.js.map +1 -0
  28. package/lib/structures/TtscLintRule.d.ts +1 -1
  29. package/lib/structures/TtscLintRuleEntry.d.ts +22 -0
  30. package/lib/structures/TtscLintRuleEntry.js +3 -0
  31. package/lib/structures/TtscLintRuleEntry.js.map +1 -0
  32. package/lib/structures/TtscLintRuleMap.d.ts +44 -0
  33. package/lib/structures/TtscLintRuleMap.js +3 -0
  34. package/lib/structures/TtscLintRuleMap.js.map +1 -0
  35. package/lib/structures/TtscLintRuleOptions.d.ts +149 -0
  36. package/lib/structures/TtscLintRuleOptions.js +3 -0
  37. package/lib/structures/TtscLintRuleOptions.js.map +1 -0
  38. package/lib/structures/defaultFormat.d.ts +31 -0
  39. package/lib/structures/defaultFormat.js +35 -0
  40. package/lib/structures/defaultFormat.js.map +1 -0
  41. package/lib/structures/index.d.ts +10 -0
  42. package/lib/structures/index.js +10 -0
  43. package/lib/structures/index.js.map +1 -1
  44. package/{plugin → linthost}/ast_helpers.go +92 -1
  45. package/{plugin → linthost}/compile.go +1 -1
  46. package/{plugin → linthost}/config.go +583 -66
  47. package/linthost/config_format.go +360 -0
  48. package/linthost/contrib_adapter.go +134 -0
  49. package/{plugin → linthost}/directives.go +1 -1
  50. package/linthost/dispatch.go +62 -0
  51. package/{plugin → linthost}/engine.go +156 -11
  52. package/{plugin → linthost}/eslint_runtime.go +76 -2
  53. package/linthost/fix.go +249 -0
  54. package/linthost/format.go +107 -0
  55. package/{plugin → linthost}/host.go +1 -1
  56. package/linthost/print_dispatch.go +118 -0
  57. package/linthost/print_doc.go +168 -0
  58. package/linthost/print_engine.go +343 -0
  59. package/linthost/print_nodes_array.go +46 -0
  60. package/linthost/print_nodes_call.go +180 -0
  61. package/linthost/print_nodes_imports.go +171 -0
  62. package/linthost/print_nodes_list.go +64 -0
  63. package/linthost/print_nodes_object.go +51 -0
  64. package/{plugin → linthost}/rules_arrays.go +1 -1
  65. package/{plugin → linthost}/rules_console.go +1 -1
  66. package/{plugin → linthost}/rules_debugger.go +1 -1
  67. package/{plugin → linthost}/rules_dupes.go +1 -1
  68. package/{plugin → linthost}/rules_empty.go +1 -1
  69. package/linthost/rules_escape.go +238 -0
  70. package/{plugin → linthost}/rules_eval.go +1 -1
  71. package/{plugin → linthost}/rules_finally.go +1 -1
  72. package/linthost/rules_format_jsdoc.go +230 -0
  73. package/linthost/rules_format_print_width.go +332 -0
  74. package/linthost/rules_format_quotes.go +219 -0
  75. package/linthost/rules_format_semi.go +190 -0
  76. package/linthost/rules_format_sort_imports.go +462 -0
  77. package/linthost/rules_format_trailing_comma.go +460 -0
  78. package/{plugin → linthost}/rules_gap.go +101 -6
  79. package/linthost/rules_imports.go +101 -0
  80. package/{plugin → linthost}/rules_logic.go +55 -3
  81. package/{plugin → linthost}/rules_loops.go +1 -1
  82. package/{plugin → linthost}/rules_misc.go +1 -1
  83. package/linthost/rules_params.go +64 -0
  84. package/{plugin → linthost}/rules_problems.go +10 -2
  85. package/linthost/rules_promise.go +111 -0
  86. package/{plugin → linthost}/rules_protos.go +1 -1
  87. package/{plugin → linthost}/rules_self.go +1 -1
  88. package/{plugin → linthost}/rules_strings.go +1 -1
  89. package/{plugin → linthost}/rules_suggestions.go +51 -9
  90. package/{plugin → linthost}/rules_throw.go +1 -1
  91. package/{plugin → linthost}/rules_ts.go +12 -2
  92. package/{plugin → linthost}/rules_ts_extra.go +90 -4
  93. package/{plugin → linthost}/rules_var.go +36 -5
  94. package/package.json +8 -6
  95. package/plugin/main.go +14 -27
  96. package/rule/astutil/astutil.go +148 -0
  97. package/rule/rule.go +291 -0
  98. package/src/defineConfig.ts +69 -0
  99. package/src/index.ts +807 -3
  100. package/src/structures/ITtscLintPlugin.ts +56 -0
  101. package/src/structures/ITtscLintPluginConfig.ts +86 -5
  102. package/src/structures/ITtscLintPluginMeta.ts +14 -0
  103. package/src/structures/PluginRuleNames.ts +19 -0
  104. package/src/structures/TtscLintConfig.ts +27 -8
  105. package/src/structures/TtscLintConfigEntry.ts +50 -0
  106. package/src/structures/TtscLintFormatConfig.ts +169 -0
  107. package/src/structures/TtscLintPlugins.ts +10 -0
  108. package/src/structures/TtscLintRule.ts +14 -1
  109. package/src/structures/TtscLintRuleEntry.ts +28 -0
  110. package/src/structures/TtscLintRuleMap.ts +52 -0
  111. package/src/structures/TtscLintRuleOptions.ts +162 -0
  112. package/src/structures/defaultFormat.ts +33 -0
  113. 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 main
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 Rule
53
- collect func(*Finding)
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.Check(ctx, node)
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.Check(ctx, file.AsNode())
362
+ runRuleCheck(rule, ctx, file.AsNode(), collect)
267
363
  }
268
364
  }
269
365
 
270
366
  statements := file.Statements
271
- if statements == nil {
272
- return collected
273
- }
274
- for _, stmt := range statements.Nodes {
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 main
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
- cmd := exec.Command(node, "-e", externalESLintRunnerScript, cwd, configPath, fileListJSON)
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) => ({
@@ -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
+ }