@ttsc/lint 0.12.3 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/lib/defaultFormat.d.ts +10 -11
  2. package/lib/defaultFormat.js +10 -11
  3. package/lib/defaultFormat.js.map +1 -1
  4. package/lib/index.js +233 -135
  5. package/lib/index.js.map +1 -1
  6. package/lib/structures/ITtscLintConfig.d.ts +2 -2
  7. package/lib/structures/ITtscLintFormatConfig.d.ts +53 -55
  8. package/lib/structures/ITtscLintPluginConfig.d.ts +10 -77
  9. package/lib/structures/ITtscLintPluginMeta.d.ts +9 -1
  10. package/lib/structures/TtscLintRule.d.ts +10 -1
  11. package/lib/structures/TtscLintRuleMap.d.ts +2 -2
  12. package/lib/structures/TtscLintRuleOptions.d.ts +14 -0
  13. package/linthost/ast_helpers.go +80 -11
  14. package/linthost/compile.go +116 -112
  15. package/linthost/config.go +647 -687
  16. package/linthost/config_format.go +272 -247
  17. package/linthost/contrib_adapter.go +7 -0
  18. package/linthost/directives.go +116 -0
  19. package/linthost/dispatch.go +33 -33
  20. package/linthost/engine.go +156 -43
  21. package/linthost/fix.go +47 -27
  22. package/linthost/flags_gen.go +31 -0
  23. package/linthost/format.go +119 -3
  24. package/linthost/host.go +115 -5
  25. package/linthost/print_dispatch.go +128 -28
  26. package/linthost/print_doc.go +23 -0
  27. package/linthost/print_engine.go +168 -4
  28. package/linthost/print_nodes_array.go +17 -7
  29. package/linthost/print_nodes_call.go +138 -20
  30. package/linthost/print_nodes_function.go +353 -0
  31. package/linthost/print_nodes_imports.go +46 -29
  32. package/linthost/print_nodes_list.go +86 -5
  33. package/linthost/print_nodes_object.go +56 -11
  34. package/linthost/rules_arrays.go +5 -2
  35. package/linthost/rules_debugger.go +3 -2
  36. package/linthost/rules_dupes.go +7 -4
  37. package/linthost/rules_empty.go +3 -2
  38. package/linthost/rules_escape.go +35 -3
  39. package/linthost/rules_eval.go +3 -0
  40. package/linthost/rules_finally.go +11 -0
  41. package/linthost/rules_format_jsdoc.go +7 -0
  42. package/linthost/rules_format_print_width.go +270 -15
  43. package/linthost/rules_format_quotes.go +3 -0
  44. package/linthost/rules_format_sort_imports.go +4 -0
  45. package/linthost/rules_gap.go +75 -3
  46. package/linthost/rules_imports.go +5 -7
  47. package/linthost/rules_logic.go +75 -5
  48. package/linthost/rules_loops.go +4 -0
  49. package/linthost/rules_misc.go +4 -2
  50. package/linthost/rules_params.go +7 -11
  51. package/linthost/rules_problems.go +89 -22
  52. package/linthost/rules_promise.go +15 -0
  53. package/linthost/rules_protos.go +3 -2
  54. package/linthost/rules_self.go +6 -0
  55. package/linthost/rules_strings.go +10 -0
  56. package/linthost/rules_suggestions.go +174 -9
  57. package/linthost/rules_throw.go +2 -0
  58. package/linthost/rules_ts.go +13 -0
  59. package/linthost/rules_ts_extra.go +25 -8
  60. package/linthost/rules_var.go +15 -2
  61. package/package.json +3 -3
  62. package/plugin/main.go +4 -4
  63. package/rule/astutil/astutil.go +12 -0
  64. package/rule/rule.go +123 -123
  65. package/src/defaultFormat.ts +10 -11
  66. package/src/index.ts +257 -168
  67. package/src/structures/ITtscLintConfig.ts +2 -2
  68. package/src/structures/ITtscLintFormatConfig.ts +53 -55
  69. package/src/structures/ITtscLintPluginConfig.ts +10 -83
  70. package/src/structures/ITtscLintPluginMeta.ts +9 -1
  71. package/src/structures/TtscLintRule.ts +10 -1
  72. package/src/structures/TtscLintRuleMap.ts +17 -18
  73. package/src/structures/TtscLintRuleOptions.ts +15 -0
  74. package/linthost/eslint_runtime.go +0 -320
package/linthost/fix.go CHANGED
@@ -1,3 +1,10 @@
1
+ // Autofix orchestration for the `@ttsc/lint fix` subcommand.
2
+ //
3
+ // RunFix drives the fix cascade: it repeatedly runs the native lint engine
4
+ // and applies any emitted TextEdit suggestions until no more fixable
5
+ // findings remain or maxFixPasses is reached. After the cascade settles, it
6
+ // runs a final diagnostic pass so remaining issues are surfaced in the
7
+ // normal error stream.
1
8
  package linthost
2
9
 
3
10
  import (
@@ -9,10 +16,9 @@ import (
9
16
  shimdw "github.com/microsoft/typescript-go/shim/diagnosticwriter"
10
17
  )
11
18
 
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.
19
+ // maxFixPasses bounds the native fix cascade. Real-world cascades (no-var
20
+ // prefer-const → eqeqeq …) settle in a handful of passes; the cap exists so
21
+ // a buggy rule that re-reports its own edit cannot loop forever.
16
22
  const maxFixPasses = 10
17
23
 
18
24
  // RunFix implements `@ttsc/lint fix` — apply autofixes, then report any
@@ -37,8 +43,11 @@ func runFix(opts *subcommandOpts) int {
37
43
  fmt.Fprintln(os.Stderr, err)
38
44
  return 2
39
45
  }
46
+ engine := NewEngineWithResolver(rules)
47
+ engine.SetSerial(opts.singleThreaded)
48
+ needsRuleChecker := engine.NeedsTypeChecker()
40
49
 
41
- prog, code := loadFixProgram(opts)
50
+ prog, code := loadFixProgram(opts, needsRuleChecker)
42
51
  if code != 0 {
43
52
  return code
44
53
  }
@@ -49,16 +58,6 @@ func runFix(opts *subcommandOpts) int {
49
58
  }()
50
59
 
51
60
  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
61
 
63
62
  // `ttsc fix` applies edits from BOTH lint-class rules and
64
63
  // format-class rules. The dual `ttsc format` subcommand exists for
@@ -67,7 +66,6 @@ func runFix(opts *subcommandOpts) int {
67
66
  // kinds of findings in one pass — no filtering needed here.
68
67
  cascadeConverged := false
69
68
  for pass := 0; pass < maxFixPasses; pass++ {
70
- engine := NewEngineWithResolver(rules)
71
69
  findings := engine.Run(prog.userSourceFiles(), prog.checker)
72
70
  fixed, err := applyFindingFixes(opts.cwd, findings)
73
71
  if err != nil {
@@ -79,7 +77,7 @@ func runFix(opts *subcommandOpts) int {
79
77
  break
80
78
  }
81
79
  totalFixes += fixed
82
- prog, code = reloadFixProgram(prog, opts)
80
+ prog, code = reloadFixProgram(prog, opts, needsRuleChecker)
83
81
  if code != 0 {
84
82
  return code
85
83
  }
@@ -96,15 +94,12 @@ func runFix(opts *subcommandOpts) int {
96
94
  maxFixPasses)
97
95
  }
98
96
 
99
- engine := NewEngineWithResolver(rules)
100
- astDiags, lintDiags, externalRan, err := collectDiagnostics(prog, engine)
97
+ astDiags, lintDiags, err := collectDiagnostics(prog, engine)
101
98
  if err != nil {
102
99
  fmt.Fprintln(os.Stderr, err)
103
100
  return 2
104
101
  }
105
- if !externalRan {
106
- warnUnknownRules(os.Stderr, engine.UnknownRules())
107
- }
102
+ warnUnknownRules(os.Stderr, engine.UnknownRules())
108
103
  errCount := shimdw.FormatMixedDiagnostics(os.Stderr, astDiags, lintDiags, opts.cwd)
109
104
  if errCount > 0 {
110
105
  return 2
@@ -121,10 +116,16 @@ func runFix(opts *subcommandOpts) int {
121
116
  return 0
122
117
  }
123
118
 
124
- func loadFixProgram(opts *subcommandOpts) (*program, int) {
119
+ // loadFixProgram loads the TypeScript program for a fix/format pass with
120
+ // NoEmit forced on. Returns (nil, 2) when loading or config parsing fails.
121
+ func loadFixProgram(opts *subcommandOpts, needsRuleChecker bool) (*program, int) {
125
122
  prog, parseDiags, err := loadProgram(opts.cwd, opts.tsconfig, loadProgramOptions{
126
- forceNoEmit: true,
127
- outDir: opts.outDir,
123
+ forceNoEmit: true,
124
+ outDir: opts.outDir,
125
+ needsRuleChecker: needsRuleChecker,
126
+ singleThreaded: opts.singleThreaded,
127
+ checkers: opts.checkers,
128
+ tsgoArgs: opts.tsgoArgs,
128
129
  })
129
130
  if err != nil {
130
131
  fmt.Fprintf(os.Stderr, "@ttsc/lint: %v\n", err)
@@ -137,19 +138,29 @@ func loadFixProgram(opts *subcommandOpts) (*program, int) {
137
138
  return prog, 0
138
139
  }
139
140
 
140
- func reloadFixProgram(current *program, opts *subcommandOpts) (*program, int) {
141
+ // reloadFixProgram closes `current` and loads a fresh program from disk.
142
+ // Used between cascade passes so the engine sees edits applied in the
143
+ // previous pass rather than stale in-memory AST nodes.
144
+ func reloadFixProgram(current *program, opts *subcommandOpts, needsRuleChecker bool) (*program, int) {
141
145
  if current != nil {
142
146
  current.close()
143
147
  }
144
- return loadFixProgram(opts)
148
+ return loadFixProgram(opts, needsRuleChecker)
145
149
  }
146
150
 
151
+ // fileFixes groups all pending TextEdit suggestions for a single file.
152
+ // `text` is the source content at the time the findings were collected;
153
+ // byte offsets in `edits` are relative to this snapshot.
147
154
  type fileFixes struct {
148
155
  path string
149
156
  text string
150
157
  edits []TextEdit
151
158
  }
152
159
 
160
+ // applyFindingFixes groups all fixable findings by file, resolves each
161
+ // file path to an absolute form, then applies the edit batches in
162
+ // deterministic order (sorted by path). Returns the total number of edits
163
+ // written to disk.
153
164
  func applyFindingFixes(cwd string, findings []*Finding) (int, error) {
154
165
  byFile := map[string]*fileFixes{}
155
166
  for _, finding := range findings {
@@ -191,6 +202,10 @@ func applyFindingFixes(cwd string, findings []*Finding) (int, error) {
191
202
  return total, nil
192
203
  }
193
204
 
205
+ // applyTextEditsToFile selects the non-overlapping edits from `edits`, applies
206
+ // them to `source` in reverse order (right-to-left) to preserve earlier
207
+ // offsets, and writes the result to `path`. Returns the number of edits
208
+ // applied, or 0 when no edits survive selection.
194
209
  func applyTextEditsToFile(path, source string, edits []TextEdit) (int, error) {
195
210
  selected := selectTextEdits(len(source), edits)
196
211
  if len(selected) == 0 {
@@ -210,6 +225,11 @@ func applyTextEditsToFile(path, source string, edits []TextEdit) (int, error) {
210
225
  return len(selected), nil
211
226
  }
212
227
 
228
+ // selectTextEdits filters and sorts `edits` into a non-overlapping
229
+ // application sequence. Out-of-bounds edits and exact duplicates are
230
+ // removed first; the remainder is sorted by start position then end
231
+ // position (left to right). A greedy scan then keeps the earliest-starting
232
+ // edit and drops any that overlap with it, producing a disjoint set.
213
233
  func selectTextEdits(sourceLen int, edits []TextEdit) []TextEdit {
214
234
  if len(edits) == 0 {
215
235
  return nil
@@ -0,0 +1,31 @@
1
+ // Code generated by packages/ttsc/scripts/gen-flags.mts. DO NOT EDIT.
2
+ //
3
+ // Source of truth: packages/ttsc/src/flags/schema.ts.
4
+ // Regenerate with: pnpm format
5
+ // Verify in CI with: node packages/ttsc/scripts/check-flags.cjs
6
+
7
+ package linthost
8
+
9
+ // LintFlagAllowList is the allow-list of CLI flags this Go layer accepts. The map's
10
+ // value is true when the flag carries a separate value token (--flag VALUE
11
+ // or --flag=VALUE) and false when the flag is boolean (--flag).
12
+ //
13
+ // Generated from packages/ttsc/src/flags/schema.ts. Edit the schema, not
14
+ // this file.
15
+ var LintFlagAllowList = map[string]bool{
16
+ "checkers": true,
17
+ "cwd": true,
18
+ "emit": false,
19
+ "file": true,
20
+ "noEmit": false,
21
+ "out": true,
22
+ "outDir": true,
23
+ "p": true,
24
+ "plugins-json": true,
25
+ "project": true,
26
+ "quiet": false,
27
+ "singleThreaded": false,
28
+ "tsconfig": true,
29
+ "tsgo-args": true,
30
+ "verbose": false,
31
+ }
@@ -33,14 +33,20 @@ func RunFormat(args []string) int {
33
33
  return runFormat(opts)
34
34
  }
35
35
 
36
+ // runFormat is the internal implementation of RunFormat. It drives the
37
+ // cascade loop and applies format-rule edits until convergence.
36
38
  func runFormat(opts *subcommandOpts) int {
37
39
  rules, err := loadRules(opts.pluginsJSON, opts.cwd, opts.tsconfig)
38
40
  if err != nil {
39
41
  fmt.Fprintln(os.Stderr, err)
40
42
  return 2
41
43
  }
44
+ resolver := formatCommandResolver{inner: rules}
45
+ engine := NewEngineWithResolver(resolver)
46
+ engine.SetSerial(opts.singleThreaded)
47
+ needsRuleChecker := engine.NeedsTypeChecker()
42
48
 
43
- prog, code := loadFixProgram(opts)
49
+ prog, code := loadFixProgram(opts, needsRuleChecker)
44
50
  if code != 0 {
45
51
  return code
46
52
  }
@@ -53,7 +59,6 @@ func runFormat(opts *subcommandOpts) int {
53
59
  totalFixes := 0
54
60
  cascadeConverged := false
55
61
  for pass := 0; pass < maxFormatPasses; pass++ {
56
- engine := NewEngineWithResolver(formatCommandResolver{inner: rules})
57
62
  findings := engine.Run(prog.userSourceFiles(), prog.checker)
58
63
  fixed, err := applyFindingFixes(opts.cwd, filterFormatFindings(findings))
59
64
  if err != nil {
@@ -65,7 +70,7 @@ func runFormat(opts *subcommandOpts) int {
65
70
  break
66
71
  }
67
72
  totalFixes += fixed
68
- prog, code = reloadFixProgram(prog, opts)
73
+ prog, code = reloadFixProgram(prog, opts, needsRuleChecker)
69
74
  if code != 0 {
70
75
  return code
71
76
  }
@@ -89,15 +94,45 @@ func runFormat(opts *subcommandOpts) int {
89
94
  return 0
90
95
  }
91
96
 
97
+ // formatCommandResolver wraps a RuleResolver and ensures every format-class
98
+ // rule referenced in the loaded plugin options is activated at warn severity,
99
+ // even if the user's config omitted it. This lets `ttsc format` format files
100
+ // without requiring explicit rule declarations in the project config.
92
101
  type formatCommandResolver struct {
93
102
  inner RuleResolver
94
103
  }
95
104
 
105
+ // ResolveRules implements RuleResolver. It delegates to the inner resolver
106
+ // and then upgrades format-rule entries from off to warn so they are applied
107
+ // even when the project config omits them.
108
+ //
109
+ // A user-authored entry whose `ignores` list matches `fileName` is honored
110
+ // here even when the entry also carries a `rules` block: the engine already
111
+ // skips that entry's rule contributions via `ConfigEntry.matchesFile`, so the
112
+ // format command must skip its blanket format-rule upgrade as well. Without
113
+ // this guard, `ttsc format` would rewrite files that the user explicitly
114
+ // asked the lint config to ignore — the engine's lint walk would skip the
115
+ // file but the formatter would still touch it.
116
+ //
117
+ // The symmetric guard applies to `files`: if every non-IgnoreOnly entry
118
+ // carries a `files` filter and `fileName` matches none of them, the engine
119
+ // would not run any rules on the file (`ConfigEntry.matchesFile` returns
120
+ // false for every entry). The format command must skip its blanket
121
+ // format-rule upgrade for the same reason — otherwise `ttsc format` would
122
+ // rewrite files that fall outside every entry's scope, e.g. a `.json`
123
+ // resolved into the program via `resolveJsonModule` when the only entry
124
+ // targets `src/**/*.ts`.
96
125
  func (r formatCommandResolver) ResolveRules(fileName string) ResolvedRuleConfig {
97
126
  resolved := r.inner.ResolveRules(fileName)
98
127
  if resolved.Ignored {
99
128
  return resolved
100
129
  }
130
+ if r.fileIsIgnoredByEntry(fileName) {
131
+ return resolved
132
+ }
133
+ if !r.fileMatchesAnyEntry(fileName) {
134
+ return resolved
135
+ }
101
136
  if resolved.Rules == nil {
102
137
  resolved.Rules = RuleConfig{}
103
138
  }
@@ -109,6 +144,75 @@ func (r formatCommandResolver) ResolveRules(fileName string) ResolvedRuleConfig
109
144
  return resolved
110
145
  }
111
146
 
147
+ // fileIsIgnoredByEntry reports whether any non-IgnoreOnly entry in the inner
148
+ // ConfigStore has an `ignores` glob that matches `fileName`. IgnoreOnly
149
+ // entries are already handled by ResolvedRuleConfig.Ignored — they are
150
+ // checked first by ConfigStore.ResolveRules and short-circuit the walk. This
151
+ // helper covers the complementary case: an entry that carries both `rules`
152
+ // and `ignores`, where the engine skips the entry's rule contributions via
153
+ // `ConfigEntry.matchesFile` but the format command must learn the same fact
154
+ // independently because its job is to upgrade format rules to `warn`, not to
155
+ // read the engine's resolved severity map.
156
+ func (r formatCommandResolver) fileIsIgnoredByEntry(fileName string) bool {
157
+ store, ok := r.inner.(*ConfigStore)
158
+ if !ok || store == nil {
159
+ return false
160
+ }
161
+ for _, entry := range store.entries {
162
+ if entry.IgnoreOnly {
163
+ continue
164
+ }
165
+ if entry.matchesIgnores(fileName) {
166
+ return true
167
+ }
168
+ }
169
+ return false
170
+ }
171
+
172
+ // fileMatchesAnyEntry reports whether `fileName` falls inside the `files`
173
+ // scope of at least one non-IgnoreOnly entry. An entry without an explicit
174
+ // `files` list matches every file by definition (eslint flat-config
175
+ // semantics), so a config with any unrestricted non-IgnoreOnly entry returns
176
+ // true for every file. The format command treats a `false` result the same
177
+ // way the engine does: no entry contributes rules, so the blanket
178
+ // format-rule upgrade must be skipped.
179
+ //
180
+ // Base cases:
181
+ //
182
+ // - Empty entries slice: returns `true`. A store with no entries cannot
183
+ // skip a file by scope (the engine has nothing to walk), so format
184
+ // mode must be allowed to apply its default upgrade. Matching engine
185
+ // behavior at `ConfigStore.ResolveRules` for the same input
186
+ // (`Ignored = false`, empty rule map).
187
+ // - All entries are IgnoreOnly: returns `false`. No non-IgnoreOnly
188
+ // entry contributes a `files` scope, so the file is out-of-scope by
189
+ // construction.
190
+ // - Inner resolver is not a *ConfigStore: returns `true` (conservative
191
+ // default). The `files` concept is store-specific; an in-process
192
+ // custom resolver that does not surface a scope cannot have its
193
+ // rule-eligibility reasoned about from the outside, so the format
194
+ // upgrade applies the same as it does for an entry without `files`.
195
+ func (r formatCommandResolver) fileMatchesAnyEntry(fileName string) bool {
196
+ store, ok := r.inner.(*ConfigStore)
197
+ if !ok || store == nil {
198
+ return true
199
+ }
200
+ if len(store.entries) == 0 {
201
+ return true
202
+ }
203
+ for _, entry := range store.entries {
204
+ if entry.IgnoreOnly {
205
+ continue
206
+ }
207
+ if entry.matchesFile(fileName) {
208
+ return true
209
+ }
210
+ }
211
+ return false
212
+ }
213
+
214
+ // ActiveRuleNames implements RuleResolver. Returns the union of the inner
215
+ // resolver's active rules and every format-option rule that is registered.
112
216
  func (r formatCommandResolver) ActiveRuleNames() []string {
113
217
  active := map[string]struct{}{}
114
218
  for _, name := range r.inner.ActiveRuleNames() {
@@ -120,6 +224,8 @@ func (r formatCommandResolver) ActiveRuleNames() []string {
120
224
  return sortedKeys(active)
121
225
  }
122
226
 
227
+ // EnabledRuleConfig implements RuleResolver. Merges the inner config with
228
+ // the format-option rules so callers see the full active set.
123
229
  func (r formatCommandResolver) EnabledRuleConfig() RuleConfig {
124
230
  enabled := r.inner.EnabledRuleConfig()
125
231
  if enabled == nil {
@@ -133,10 +239,14 @@ func (r formatCommandResolver) EnabledRuleConfig() RuleConfig {
133
239
  return enabled
134
240
  }
135
241
 
242
+ // RuleOptions implements RuleResolver by delegating directly to the inner resolver.
136
243
  func (r formatCommandResolver) RuleOptions(name string) json.RawMessage {
137
244
  return r.inner.RuleOptions(name)
138
245
  }
139
246
 
247
+ // formatOptionRuleNames returns the sorted list of rule names from the inner
248
+ // resolver's options that are registered as format rules. These are the rules
249
+ // that formatCommandResolver promotes from off to warn.
140
250
  func (r formatCommandResolver) formatOptionRuleNames() []string {
141
251
  options := resolverOptions(r.inner)
142
252
  if len(options) == 0 {
@@ -152,6 +262,9 @@ func (r formatCommandResolver) formatOptionRuleNames() []string {
152
262
  return names
153
263
  }
154
264
 
265
+ // resolverOptions extracts the raw options map from a resolver whose concrete
266
+ // type exposes one. Returns nil for resolver types that don't carry per-rule
267
+ // options (e.g. bare RuleConfig).
155
268
  func resolverOptions(resolver RuleResolver) RuleOptionsMap {
156
269
  switch r := resolver.(type) {
157
270
  case InlineRuleResolver:
@@ -163,11 +276,14 @@ func resolverOptions(resolver RuleResolver) RuleOptionsMap {
163
276
  }
164
277
  }
165
278
 
279
+ // isRegisteredFormatRule reports whether `name` is both registered in the
280
+ // global rule registry and tagged as a format rule via the FormatRule marker.
166
281
  func isRegisteredFormatRule(name string) bool {
167
282
  rule, ok := registered.rules[name]
168
283
  return ok && isFormatRule(rule)
169
284
  }
170
285
 
286
+ // sortedKeys returns the sorted slice of keys from a string-keyed set.
171
287
  func sortedKeys(input map[string]struct{}) []string {
172
288
  names := make([]string, 0, len(input))
173
289
  for name := range input {
package/linthost/host.go CHANGED
@@ -39,10 +39,25 @@ type loadProgramOptions struct {
39
39
  forceEmit bool
40
40
  forceNoEmit bool
41
41
  outDir string
42
+ // needsRuleChecker asks loadProgram to pin the checker pool and acquire the
43
+ // checker that type-aware lint rules receive through Context.Checker.
44
+ needsRuleChecker bool
45
+ // singleThreaded mirrors `tsgo --singleThreaded`: one checker, serial
46
+ // parse/check/emit.
47
+ singleThreaded bool
48
+ // checkers mirrors `tsgo --checkers`: type-checker pool size. Zero leaves
49
+ // TypeScript-Go's default; ignored when singleThreaded is set.
50
+ checkers int
51
+ // tsgoArgs carries tsgo CLI flags the `ttsc` launcher forwarded (`--strict`,
52
+ // `--target es2020`, …). They are parsed through TypeScript-Go's own
53
+ // command-line parser into a CompilerOptions overlay that wins over the
54
+ // tsconfig, exactly as tsgo's CLI merges them.
55
+ tsgoArgs []string
42
56
  }
43
57
 
44
- // loadProgram parses the given tsconfig, builds a Program, and acquires a
45
- // type checker. Mirrors the canonical bootstrap pattern from
58
+ // loadProgram parses the given tsconfig and builds a Program. When
59
+ // needsRuleChecker is set, it also acquires a type checker for lint rules.
60
+ // Mirrors the canonical bootstrap pattern from
46
61
  // `03-tsgo.md` — the only ttsc-specific bit is that `forceEmit`/
47
62
  // `forceNoEmit`/`outDir` overrides are merged into the parsed config
48
63
  // before the program is created so `--noEmit` and friends behave like
@@ -63,9 +78,14 @@ func loadProgram(cwd, tsconfigPath string, options loadProgramOptions) (*program
63
78
  fs := bundled.WrapFS(cachedvfs.From(osvfs.FS()))
64
79
  host := shimcompiler.NewCompilerHost(cwd, fs, bundled.LibPath(), nil, nil)
65
80
 
81
+ cliOptions, cliDiags := parseTsgoArgs(options.tsgoArgs, host)
82
+ if len(cliDiags) > 0 {
83
+ return nil, cliDiags, nil
84
+ }
85
+
66
86
  parsed, parseDiags := tsoptions.GetParsedCommandLineOfConfigFile(
67
87
  resolved,
68
- &shimcore.CompilerOptions{},
88
+ cliOptions,
69
89
  nil,
70
90
  host,
71
91
  nil,
@@ -88,17 +108,34 @@ func loadProgram(cwd, tsconfigPath string, options loadProgramOptions) (*program
88
108
  if options.outDir != "" {
89
109
  overrideOutDir(cwd, parsed, options.outDir)
90
110
  }
111
+ applyThreading(parsed, options.singleThreaded, options.checkers)
112
+ if options.needsRuleChecker {
113
+ forceSingleChecker(parsed)
114
+ }
91
115
 
116
+ // SingleThreaded is left unset so the program keeps TypeScript-Go's parallel
117
+ // source parsing and parallel emit. For type-aware lint rules, the checker
118
+ // pool is pinned to a single checker (see forceSingleChecker): the lint
119
+ // engine walks files serially against the one checker GetTypeChecker hands
120
+ // back, and rules ask that checker to resolve types in nodes drawn from every
121
+ // source file. TypeScript-Go's multi-checker pool affinitizes each file to a
122
+ // different checker and forbids mixing types across them, so a type whose
123
+ // declarations span files on different checkers (e.g. a circular
124
+ // indexed-access alias) resolves to `any` on the borrowed checker. AST-only
125
+ // lint rules do not receive a checker, so they keep the user's checker pool.
92
126
  tsProgram := shimcompiler.NewProgram(shimcompiler.ProgramOptions{
93
127
  Config: parsed,
94
- SingleThreaded: shimcore.TSTrue,
95
128
  Host: host,
96
129
  UseSourceOfProjectReference: true,
97
130
  })
98
131
  if tsProgram == nil {
99
132
  return nil, nil, errors.New("compiler.NewProgram returned nil")
100
133
  }
101
- checker, release := tsProgram.GetTypeChecker(context.Background())
134
+ var checker *shimchecker.Checker
135
+ var release func()
136
+ if options.needsRuleChecker {
137
+ checker, release = tsProgram.GetTypeChecker(context.Background())
138
+ }
102
139
  return &program{
103
140
  cwd: cwd,
104
141
  tsProgram: tsProgram,
@@ -108,6 +145,8 @@ func loadProgram(cwd, tsconfigPath string, options loadProgramOptions) (*program
108
145
  }, nil, nil
109
146
  }
110
147
 
148
+ // close releases the type checker acquired by loadProgram. Safe to call on
149
+ // a nil receiver and idempotent after the first call.
111
150
  func (p *program) close() {
112
151
  if p == nil {
113
152
  return
@@ -162,6 +201,8 @@ func (p *program) findSourceFile(target string) *shimast.SourceFile {
162
201
  return nil
163
202
  }
164
203
 
204
+ // forceEmit clears the NoEmit and EmitDeclarationOnly flags so the
205
+ // program emits JavaScript even when the tsconfig says otherwise.
165
206
  func forceEmit(parsed *tsoptions.ParsedCommandLine) {
166
207
  if parsed == nil || parsed.ParsedConfig == nil || parsed.ParsedConfig.CompilerOptions == nil {
167
208
  return
@@ -171,6 +212,9 @@ func forceEmit(parsed *tsoptions.ParsedCommandLine) {
171
212
  options.EmitDeclarationOnly = shimcore.TSFalse
172
213
  }
173
214
 
215
+ // forceNoEmit sets the NoEmit flag regardless of what the tsconfig
216
+ // specifies. Used by fix and check subcommands that must not write output
217
+ // files as a side effect of type-checking.
174
218
  func forceNoEmit(parsed *tsoptions.ParsedCommandLine) {
175
219
  if parsed == nil || parsed.ParsedConfig == nil || parsed.ParsedConfig.CompilerOptions == nil {
176
220
  return
@@ -178,6 +222,72 @@ func forceNoEmit(parsed *tsoptions.ParsedCommandLine) {
178
222
  parsed.ParsedConfig.CompilerOptions.NoEmit = shimcore.TSTrue
179
223
  }
180
224
 
225
+ // parseTsgoArgs runs forwarded tsgo CLI flags through TypeScript-Go's own
226
+ // command-line parser, yielding a CompilerOptions overlay loadProgram merges
227
+ // over the tsconfig — so a flag like `ttsc --strict` reaches the in-process
228
+ // lint program even though @ttsc/lint never shells out to `tsgo`. Returns an
229
+ // empty (non-nil) options value when there are no forwarded flags.
230
+ func parseTsgoArgs(args []string, host shimcompiler.CompilerHost) (*shimcore.CompilerOptions, []*shimast.Diagnostic) {
231
+ if len(args) == 0 {
232
+ return &shimcore.CompilerOptions{}, nil
233
+ }
234
+ cli := tsoptions.ParseCommandLine(args, host)
235
+ if cli == nil {
236
+ return &shimcore.CompilerOptions{}, nil
237
+ }
238
+ if len(cli.Errors) > 0 {
239
+ return nil, cli.Errors
240
+ }
241
+ return cli.CompilerOptions(), nil
242
+ }
243
+
244
+ // applyThreading forwards the --singleThreaded / --checkers knobs onto the
245
+ // parsed compiler options. ttsc mirrors tsgo here: the values land in
246
+ // CompilerOptions, and both Program.SingleThreaded() and the checker pool read
247
+ // them from there. SingleThreaded wins over Checkers, matching the pool.
248
+ //
249
+ // When a type-aware lint rule is active, loadProgram calls forceSingleChecker
250
+ // afterwards, so a `--checkers N` greater than 1 is recorded here and then
251
+ // clamped back to a single checker. AST-only lint runs keep the recorded
252
+ // checker count. `--singleThreaded` still takes full effect.
253
+ func applyThreading(parsed *tsoptions.ParsedCommandLine, singleThreaded bool, checkers int) {
254
+ if parsed == nil || parsed.ParsedConfig == nil || parsed.ParsedConfig.CompilerOptions == nil {
255
+ return
256
+ }
257
+ options := parsed.ParsedConfig.CompilerOptions
258
+ if singleThreaded {
259
+ options.SingleThreaded = shimcore.TSTrue
260
+ }
261
+ if checkers > 0 {
262
+ n := checkers
263
+ options.Checkers = &n
264
+ }
265
+ }
266
+
267
+ // forceSingleChecker pins the TypeScript-Go checker pool to a single checker.
268
+ //
269
+ // The lint engine walks the program serially and obtains types through the
270
+ // single checker GetTypeChecker hands back. Rules query types on nodes from
271
+ // arbitrary source files, so the checker must be the same one that checked
272
+ // every file. A pool of size > 1 affinitizes files to distinct checkers;
273
+ // resolving a type whose declarations cross that boundary yields `any`.
274
+ // Parallel parsing and emit are unaffected — they do not consult the count.
275
+ func forceSingleChecker(parsed *tsoptions.ParsedCommandLine) {
276
+ if parsed == nil || parsed.ParsedConfig == nil || parsed.ParsedConfig.CompilerOptions == nil {
277
+ return
278
+ }
279
+ options := parsed.ParsedConfig.CompilerOptions
280
+ if options.SingleThreaded == shimcore.TSTrue {
281
+ return
282
+ }
283
+ one := 1
284
+ options.Checkers = &one
285
+ }
286
+
287
+ // overrideOutDir replaces the parsed config's OutDir with `outDir`.
288
+ // Relative outDir values are resolved against `cwd`; absolute paths are
289
+ // used as-is. Paths are converted to forward slashes for tsgo
290
+ // compatibility.
181
291
  func overrideOutDir(cwd string, parsed *tsoptions.ParsedCommandLine, outDir string) {
182
292
  if parsed == nil || parsed.ParsedConfig == nil || parsed.ParsedConfig.CompilerOptions == nil {
183
293
  return