@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
@@ -1,3 +1,10 @@
1
+ // Inline-disable directive parser and filter for the lint engine.
2
+ //
3
+ // Supports both `eslint-disable` and `lint-disable` comment families in
4
+ // four forms: `disable`, `enable`, `disable-line`, and
5
+ // `disable-next-line`. Rule lists are comma- or space-separated; an
6
+ // empty list disables all rules. The `--` description separator (ESLint
7
+ // convention) is also recognized and stripped before parsing rule names.
1
8
  package linthost
2
9
 
3
10
  import (
@@ -7,36 +14,61 @@ import (
7
14
  shimscanner "github.com/microsoft/typescript-go/shim/scanner"
8
15
  )
9
16
 
17
+ // lintDirectiveKind classifies the four comment forms the parser recognizes.
10
18
  type lintDirectiveKind int
11
19
 
12
20
  const (
21
+ // lintDirectiveDisable activates suppression from the comment position until
22
+ // a matching `enable` is seen (or end of file).
13
23
  lintDirectiveDisable lintDirectiveKind = iota
24
+ // lintDirectiveEnable cancels a prior `disable` for the named rules (or all
25
+ // rules when no rule list is given).
14
26
  lintDirectiveEnable
27
+ // lintDirectiveDisableLine suppresses findings on the same source line as
28
+ // the directive comment.
15
29
  lintDirectiveDisableLine
30
+ // lintDirectiveDisableNextLine suppresses findings on the line immediately
31
+ // following the directive comment.
16
32
  lintDirectiveDisableNextLine
17
33
  )
18
34
 
35
+ // lintDirective is the parsed representation of one directive comment.
19
36
  type lintDirective struct {
20
37
  kind lintDirectiveKind
21
38
  rules lintDirectiveRules
22
39
  }
23
40
 
41
+ // lintDirectiveRules holds the rule scope of a directive. When `all` is
42
+ // true the directive applies to every rule; otherwise only the named rules
43
+ // in `rules` are affected.
24
44
  type lintDirectiveRules struct {
25
45
  all bool
26
46
  rules map[string]struct{}
27
47
  }
28
48
 
49
+ // lintDirectiveEvent is one enable/disable transition recorded in source
50
+ // order. `pos` is the byte offset of the comment. `on` is true for a
51
+ // disable event and false for an enable event.
29
52
  type lintDirectiveEvent struct {
30
53
  pos int
31
54
  rules lintDirectiveRules
32
55
  on bool
33
56
  }
34
57
 
58
+ // lintInlineDirectives accumulates the per-file directive information
59
+ // extracted by parseLintInlineDirectives. `lines` maps a zero-based line
60
+ // number to any disable-line / disable-next-line directives on that line.
61
+ // `events` is the ordered list of range-style disable/enable transitions.
35
62
  type lintInlineDirectives struct {
36
63
  lines map[int][]lintDirectiveRules
37
64
  events []lintDirectiveEvent
38
65
  }
39
66
 
67
+ // lintDisableState tracks the cumulative suppress/allow state as
68
+ // lintDirectiveEvents are replayed in order up to a finding's position.
69
+ // `all` means every rule is suppressed. `rules` is the set of individually
70
+ // suppressed rules. `enabledInAll` is the set of rules that were
71
+ // re-enabled via `eslint-enable <rule>` while `all` was active.
40
72
  type lintDisableState struct {
41
73
  all bool
42
74
  rules map[string]struct{}
@@ -60,6 +92,21 @@ func filterInlineDisabledFindings(file *shimast.SourceFile, findings []*Finding)
60
92
  return filtered
61
93
  }
62
94
 
95
+ // parseLintInlineDirectives scans all comment tokens in `file` for
96
+ // recognized directive markers and returns a structured summary of the
97
+ // per-line and range-style suppressions found.
98
+ //
99
+ // The raw scanner does not split `KindTemplateExpression` on its own:
100
+ // after returning `KindTemplateHead`/`KindTemplateMiddle`, it resumes
101
+ // lexing the substitution as ordinary code, and a later `}` is reported
102
+ // as `KindCloseBraceToken` instead of re-entering the template body.
103
+ // Without intervention the next backtick would open a fresh template
104
+ // scan that swallows the rest of the file (including every disable
105
+ // directive comment) as one runaway unterminated literal. The parser
106
+ // avoids this by calling `ReScanTemplateToken` on the matching `}`;
107
+ // this loop mirrors that behavior with a brace-depth stack so comment
108
+ // positions stay aligned with the source bytes past any template
109
+ // substitution.
63
110
  func parseLintInlineDirectives(file *shimast.SourceFile) *lintInlineDirectives {
64
111
  directives := &lintInlineDirectives{
65
112
  lines: make(map[int][]lintDirectiveRules),
@@ -68,12 +115,44 @@ func parseLintInlineDirectives(file *shimast.SourceFile) *lintInlineDirectives {
68
115
  scanner.SetText(file.Text())
69
116
  scanner.SetSkipTrivia(false)
70
117
 
118
+ // templateBraceDepth tracks `{` nesting inside each open template
119
+ // substitution. A zero on top means the next `}` matches the original
120
+ // `${` and must be re-scanned as a template middle/tail token.
121
+ var templateBraceDepth []int
122
+
71
123
  scan:
72
124
  for {
73
125
  kind := scanner.Scan()
74
126
  switch kind {
75
127
  case shimast.KindEndOfFile:
76
128
  break scan
129
+ case shimast.KindTemplateHead, shimast.KindTemplateMiddle:
130
+ // Entering a `${...}` substitution; account for its closing `}`.
131
+ templateBraceDepth = append(templateBraceDepth, 0)
132
+ continue
133
+ case shimast.KindOpenBraceToken:
134
+ if n := len(templateBraceDepth); n > 0 {
135
+ templateBraceDepth[n-1]++
136
+ }
137
+ continue
138
+ case shimast.KindCloseBraceToken:
139
+ n := len(templateBraceDepth)
140
+ if n == 0 {
141
+ continue
142
+ }
143
+ if templateBraceDepth[n-1] > 0 {
144
+ templateBraceDepth[n-1]--
145
+ continue
146
+ }
147
+ // Matching `}` for the original `${`. Pop the substitution and
148
+ // rescan as template; a `KindTemplateMiddle` reopens a new
149
+ // substitution, a `KindTemplateTail` closes the template literal.
150
+ templateBraceDepth = templateBraceDepth[:n-1]
151
+ rescanned := scanner.ReScanTemplateToken(false /*isTaggedTemplate*/)
152
+ if rescanned == shimast.KindTemplateMiddle {
153
+ templateBraceDepth = append(templateBraceDepth, 0)
154
+ }
155
+ continue
77
156
  case shimast.KindSingleLineCommentTrivia, shimast.KindMultiLineCommentTrivia:
78
157
  default:
79
158
  continue
@@ -111,10 +190,16 @@ scan:
111
190
  return directives
112
191
  }
113
192
 
193
+ // empty reports whether no directives were found in the file, allowing the
194
+ // caller to skip the filtering step entirely.
114
195
  func (d *lintInlineDirectives) empty() bool {
115
196
  return d == nil || (len(d.lines) == 0 && len(d.events) == 0)
116
197
  }
117
198
 
199
+ // suppresses reports whether the directive set causes `finding` to be
200
+ // suppressed. It checks the per-line map first (disable-line and
201
+ // disable-next-line directives), then replays the ordered event list to
202
+ // compute the range-style disable/enable state at the finding's position.
118
203
  func (d *lintInlineDirectives) suppresses(file *shimast.SourceFile, finding *Finding) bool {
119
204
  if d == nil || finding == nil || file == nil {
120
205
  return false
@@ -135,6 +220,10 @@ func (d *lintInlineDirectives) suppresses(file *shimast.SourceFile, finding *Fin
135
220
  return state.matches(finding.Rule)
136
221
  }
137
222
 
223
+ // apply updates the disable state by folding in one directive event.
224
+ // Disable events add rules; enable events remove them. When the event
225
+ // targets all rules (`event.rules.all`), the entire state is replaced
226
+ // rather than merged.
138
227
  func (s *lintDisableState) apply(event lintDirectiveEvent) {
139
228
  if event.on {
140
229
  if event.rules.all {
@@ -169,6 +258,9 @@ func (s *lintDisableState) apply(event lintDirectiveEvent) {
169
258
  }
170
259
  }
171
260
 
261
+ // matches reports whether `rule` is currently suppressed given this state.
262
+ // The name is normalized before lookup so that `@typescript-eslint/` prefixes
263
+ // do not prevent a match.
172
264
  func (s lintDisableState) matches(rule string) bool {
173
265
  normalized := normalizeDirectiveRuleName(rule)
174
266
  if _, ok := s.rules[normalized]; ok {
@@ -181,6 +273,9 @@ func (s lintDisableState) matches(rule string) bool {
181
273
  return !enabled
182
274
  }
183
275
 
276
+ // matches reports whether this rule-set covers `rule`. Returns true when the
277
+ // directive targeted all rules or when `rule` (normalized) appears in the
278
+ // named set.
184
279
  func (r lintDirectiveRules) matches(rule string) bool {
185
280
  if r.all {
186
281
  return true
@@ -189,6 +284,9 @@ func (r lintDirectiveRules) matches(rule string) bool {
189
284
  return ok
190
285
  }
191
286
 
287
+ // parseLintDirectiveComment strips comment delimiters from `raw` and
288
+ // delegates to parseLintDirectiveLine. Returns (zero, false) when the
289
+ // comment does not contain a recognized directive marker.
192
290
  func parseLintDirectiveComment(raw string) (lintDirective, bool) {
193
291
  text := stripCommentDelimiters(raw)
194
292
  if directive, ok := parseLintDirectiveLine(text); ok {
@@ -197,6 +295,9 @@ func parseLintDirectiveComment(raw string) (lintDirective, bool) {
197
295
  return lintDirective{}, false
198
296
  }
199
297
 
298
+ // stripCommentDelimiters removes `//` and `/* … */` syntax from `raw` and
299
+ // returns the trimmed inner text. Handles JSDoc-style `* ` prefix on the
300
+ // first content line.
200
301
  func stripCommentDelimiters(raw string) string {
201
302
  switch {
202
303
  case strings.HasPrefix(raw, "//"):
@@ -216,6 +317,9 @@ func stripCommentDelimiters(raw string) string {
216
317
  }
217
318
  }
218
319
 
320
+ // parseLintDirectiveLine matches `text` against all recognized directive
321
+ // markers in declaration order (longest suffix first to avoid prefix
322
+ // ambiguity). Returns the first match found or (zero, false) if none match.
219
323
  func parseLintDirectiveLine(text string) (lintDirective, bool) {
220
324
  for _, prefix := range []string{"eslint", "lint"} {
221
325
  for _, form := range []struct {
@@ -241,6 +345,9 @@ func parseLintDirectiveLine(text string) (lintDirective, bool) {
241
345
  return lintDirective{}, false
242
346
  }
243
347
 
348
+ // directivePayload returns the text after `marker` in `text` when `text`
349
+ // starts with `marker` followed by whitespace or end of string. The
350
+ // returned payload is trimmed of leading/trailing whitespace.
244
351
  func directivePayload(text, marker string) (string, bool) {
245
352
  if !strings.HasPrefix(text, marker) {
246
353
  return "", false
@@ -252,6 +359,9 @@ func directivePayload(text, marker string) (string, bool) {
252
359
  return strings.TrimSpace(rest), true
253
360
  }
254
361
 
362
+ // parseDirectiveRules converts the payload (the text after the directive
363
+ // marker) into a lintDirectiveRules value. The `--` separator strips an
364
+ // optional human-readable description. An empty rule list means "all rules".
255
365
  func parseDirectiveRules(payload string) lintDirectiveRules {
256
366
  payload = stripDirectiveDescription(payload)
257
367
  payload = strings.ReplaceAll(payload, ",", " ")
@@ -272,6 +382,10 @@ func parseDirectiveRules(payload string) lintDirectiveRules {
272
382
  return lintDirectiveRules{rules: rules}
273
383
  }
274
384
 
385
+ // stripDirectiveDescription returns the portion of `payload` before the
386
+ // first ` -- ` token (ESLint's description separator). The separator must
387
+ // be surrounded by whitespace or be at a string boundary to avoid
388
+ // stripping `--` from rule names like `no--foo`.
275
389
  func stripDirectiveDescription(payload string) string {
276
390
  for i := 0; i < len(payload)-1; i++ {
277
391
  if payload[i] != '-' || payload[i+1] != '-' {
@@ -287,6 +401,8 @@ func stripDirectiveDescription(payload string) string {
287
401
  return payload
288
402
  }
289
403
 
404
+ // normalizeDirectiveRuleName strips the common ESLint namespace prefixes so
405
+ // both `@typescript-eslint/no-var` and `no-var` resolve to the same key.
290
406
  func normalizeDirectiveRuleName(name string) string {
291
407
  name = strings.TrimSpace(name)
292
408
  name = strings.TrimPrefix(name, "@typescript-eslint/")
@@ -7,8 +7,8 @@
7
7
  package linthost
8
8
 
9
9
  import (
10
- "fmt"
11
- "os"
10
+ "fmt"
11
+ "os"
12
12
  )
13
13
 
14
14
  // Version is the build banner string the `version` subcommand prints.
@@ -25,41 +25,41 @@ var Version = "dev"
25
25
  // Recognized verbs: `version` / `-v` / `--version`, `check`, `fix`, `format`,
26
26
  // `build`, `transform`. Anything else is a usage error (exit code 2).
27
27
  func Main(args []string) int {
28
- return run(args)
28
+ return run(args)
29
29
  }
30
30
 
31
31
  // run is the package-local dispatcher invoked by Main and by the in-tree
32
32
  // test/command corpus, which exercises end-to-end subcommand routing through
33
33
  // the same entry point the CLI uses.
34
34
  func run(args []string) int {
35
- if len(args) == 0 {
36
- fmt.Fprintln(os.Stderr, "@ttsc/lint: command required (expected check|fix|format|build|transform|version)")
37
- return 2
38
- }
39
- switch args[0] {
40
- case "-v", "--version", "version":
41
- // Don't pay contributor-registration cost for the version banner.
42
- fmt.Fprintf(os.Stdout, "@ttsc/lint %s\n", Version)
43
- return 0
44
- case "check", "fix", "format", "build", "transform":
45
- default:
46
- fmt.Fprintf(os.Stderr, "@ttsc/lint: unknown command %q\n", args[0])
47
- return 2
48
- }
49
- // Wire contributor rules into the engine's dispatch table after every
50
- // package init has settled. See contrib_adapter.go for the rationale.
51
- registerContributors()
52
- switch args[0] {
53
- case "check":
54
- return RunCheck(args[1:])
55
- case "fix":
56
- return RunFix(args[1:])
57
- case "format":
58
- return RunFormat(args[1:])
59
- case "build":
60
- return RunBuild(args[1:])
61
- case "transform":
62
- return RunTransform(args[1:])
63
- }
64
- return 2
35
+ if len(args) == 0 {
36
+ fmt.Fprintln(os.Stderr, "@ttsc/lint: command required (expected check|fix|format|build|transform|version)")
37
+ return 2
38
+ }
39
+ switch args[0] {
40
+ case "-v", "--version", "version":
41
+ // Don't pay contributor-registration cost for the version banner.
42
+ fmt.Fprintf(os.Stdout, "@ttsc/lint %s\n", Version)
43
+ return 0
44
+ case "check", "fix", "format", "build", "transform":
45
+ default:
46
+ fmt.Fprintf(os.Stderr, "@ttsc/lint: unknown command %q\n", args[0])
47
+ return 2
48
+ }
49
+ // Wire contributor rules into the engine's dispatch table after every
50
+ // package init has settled. See contrib_adapter.go for the rationale.
51
+ registerContributors()
52
+ switch args[0] {
53
+ case "check":
54
+ return RunCheck(args[1:])
55
+ case "fix":
56
+ return RunFix(args[1:])
57
+ case "format":
58
+ return RunFormat(args[1:])
59
+ case "build":
60
+ return RunBuild(args[1:])
61
+ case "transform":
62
+ return RunTransform(args[1:])
63
+ }
64
+ return 2
65
65
  }
@@ -21,7 +21,9 @@ import (
21
21
  "encoding/json"
22
22
  "fmt"
23
23
  "os"
24
+ "runtime"
24
25
  "sort"
26
+ "sync"
25
27
 
26
28
  shimast "github.com/microsoft/typescript-go/shim/ast"
27
29
  shimchecker "github.com/microsoft/typescript-go/shim/checker"
@@ -62,12 +64,23 @@ type FormatRule interface {
62
64
  IsFormat() bool
63
65
  }
64
66
 
67
+ // typeAwareRule marks rules that need a live TypeScript checker in Context.
68
+ // Rules that do not implement it are assumed AST-only.
69
+ type typeAwareRule interface {
70
+ NeedsTypeChecker() bool
71
+ }
72
+
65
73
  // isFormatRule reports whether `r` opts into the format category.
66
74
  func isFormatRule(r Rule) bool {
67
75
  fr, ok := r.(FormatRule)
68
76
  return ok && fr.IsFormat()
69
77
  }
70
78
 
79
+ func ruleNeedsTypeChecker(r Rule) bool {
80
+ tr, ok := r.(typeAwareRule)
81
+ return ok && tr.NeedsTypeChecker()
82
+ }
83
+
71
84
  // Context is the per-(file, rule) handle the engine passes to `Check`.
72
85
  //
73
86
  // `Options` is the raw JSON blob the user wrote in their rule
@@ -184,6 +197,10 @@ func (c *Context) ReportRangeFix(pos, end int, message string, edits ...TextEdit
184
197
  })
185
198
  }
186
199
 
200
+ // cloneTextEdits returns a shallow copy of `edits` so that the caller's
201
+ // variadic slice cannot be mutated through the stored Finding. Returns nil
202
+ // when the input is empty, keeping the Finding.Fix field nil rather than
203
+ // a zero-length slice.
187
204
  func cloneTextEdits(edits []TextEdit) []TextEdit {
188
205
  if len(edits) == 0 {
189
206
  return nil
@@ -230,10 +247,32 @@ func AllRuleNames() []string {
230
247
  // Engine binds a rule configuration to a Program and walks the AST once
231
248
  // per source file, dispatching each visited node to its interested rules.
232
249
  type Engine struct {
233
- config RuleResolver
234
- rules map[shimast.Kind][]Rule
235
- enabled map[string]Severity
236
- unknown []string
250
+ config RuleResolver
251
+ rules map[shimast.Kind][]Rule
252
+ enabled map[string]Severity
253
+ unknown []string
254
+ needsTypeChecker bool
255
+ serial bool
256
+ }
257
+
258
+ // SetSerial forces Engine.Run to walk files one at a time. The host calls
259
+ // this when `--singleThreaded` reaches the lint sidecar so the benchmark
260
+ // (and any caller that wants a deterministic, low-overhead pass) can opt
261
+ // out of file-level parallelism. Type-aware rule sets always run serial
262
+ // regardless of this flag — the single shared checker is not concurrent —
263
+ // so callers do not need to clear it themselves.
264
+ func (e *Engine) SetSerial(serial bool) {
265
+ if e == nil {
266
+ return
267
+ }
268
+ e.serial = serial
269
+ }
270
+
271
+ // runsSerial reports whether Run must walk files one at a time — either
272
+ // because the caller asked for it or because a type-aware rule pins the
273
+ // engine to the shared single checker.
274
+ func (e *Engine) runsSerial() bool {
275
+ return e == nil || e.serial || e.needsTypeChecker
237
276
  }
238
277
 
239
278
  // NewEngine returns an engine configured for `config`. Rules whose
@@ -262,6 +301,9 @@ func NewEngineWithResolver(config RuleResolver) *Engine {
262
301
  eng.unknown = append(eng.unknown, name)
263
302
  continue
264
303
  }
304
+ if ruleNeedsTypeChecker(rule) {
305
+ eng.needsTypeChecker = true
306
+ }
265
307
  eng.enabled[name] = displaySeverities.Severity(name)
266
308
  // Dedup kinds per rule so a contributor that accidentally lists the
267
309
  // same Kind twice in `Visits()` doesn't end up firing twice per node.
@@ -282,23 +324,76 @@ func NewEngineWithResolver(config RuleResolver) *Engine {
282
324
  // have no registered implementation.
283
325
  func (e *Engine) UnknownRules() []string { return e.unknown }
284
326
 
327
+ // NeedsTypeChecker reports whether any active rule requires Context.Checker.
328
+ func (e *Engine) NeedsTypeChecker() bool {
329
+ return e != nil && e.needsTypeChecker
330
+ }
331
+
285
332
  // EnabledRules returns the active rule set keyed by name. Mostly for
286
333
  // tests + introspection.
287
334
  func (e *Engine) EnabledRules() map[string]Severity { return e.enabled }
288
335
 
289
- // Run walks every non-declaration source file in the program and
290
- // returns the collected findings.
336
+ // Run walks every non-declaration source file in the program and returns
337
+ // the collected findings. By default files are processed in parallel,
338
+ // bounded by `runtime.NumCPU()`; the engine falls back to a serial walk
339
+ // when SetSerial(true) was called or when a type-aware rule is active.
340
+ // Findings are merged in source-file order so the diagnostic stream is
341
+ // deterministic across runs even when the per-file work happens out of
342
+ // order.
291
343
  func (e *Engine) Run(files []*shimast.SourceFile, checker *shimchecker.Checker) []*Finding {
292
- var findings []*Finding
293
- for _, file := range files {
344
+ if e.runsSerial() {
345
+ var findings []*Finding
346
+ for _, file := range files {
347
+ if file == nil || file.IsDeclarationFile {
348
+ continue
349
+ }
350
+ findings = append(findings, e.runFile(file, checker)...)
351
+ }
352
+ return findings
353
+ }
354
+
355
+ perFile := make([][]*Finding, len(files))
356
+ var wg sync.WaitGroup
357
+ workers := runtime.NumCPU()
358
+ if workers < 1 {
359
+ workers = 1
360
+ }
361
+ sem := make(chan struct{}, workers)
362
+ for i, file := range files {
294
363
  if file == nil || file.IsDeclarationFile {
295
364
  continue
296
365
  }
297
- findings = append(findings, e.runFile(file, checker)...)
366
+ wg.Add(1)
367
+ sem <- struct{}{}
368
+ go func(idx int, f *shimast.SourceFile) {
369
+ defer wg.Done()
370
+ defer func() { <-sem }()
371
+ perFile[idx] = e.runFile(f, checker)
372
+ }(i, file)
373
+ }
374
+ wg.Wait()
375
+
376
+ total := 0
377
+ for _, fs := range perFile {
378
+ total += len(fs)
379
+ }
380
+ if total == 0 {
381
+ return nil
382
+ }
383
+ findings := make([]*Finding, 0, total)
384
+ for _, fs := range perFile {
385
+ findings = append(findings, fs...)
298
386
  }
299
387
  return findings
300
388
  }
301
389
 
390
+ // boundRule pairs an active rule with the Context the engine reuses for
391
+ // every node it dispatches to that rule within one file. See runFile.
392
+ type boundRule struct {
393
+ rule Rule
394
+ ctx *Context
395
+ }
396
+
302
397
  // runFile is the per-file driver. The visitor is allocated once per file
303
398
  // to keep the per-node hot path branch-free; it visits children
304
399
  // post-order so parents see their already-checked subtrees.
@@ -310,29 +405,53 @@ func (e *Engine) runFile(file *shimast.SourceFile, checker *shimchecker.Checker)
310
405
  return collected
311
406
  }
312
407
  fileRules := resolved.Rules
408
+ if !hasEnabledFileRules(fileRules) {
409
+ return collected
410
+ }
411
+
412
+ // Bind every active rule to a Context once per file. A Context's fields
413
+ // — File, Checker, the file-resolved Severity, the rule's Options blob,
414
+ // and the format marker — are all invariant across the file's nodes, so
415
+ // the engine builds them here. The earlier shape allocated a fresh
416
+ // Context for every (node, rule) pair, which on a large program meant
417
+ // millions of short-lived heap allocations and the GC pressure they
418
+ // carry. Rules never mutate their Context, so reuse is safe.
419
+ byKind := make(map[shimast.Kind][]boundRule, len(e.rules))
420
+ ctxByRule := make(map[string]*Context, len(e.enabled))
421
+ for kind, rules := range e.rules {
422
+ for _, rule := range rules {
423
+ name := rule.Name()
424
+ ctx, built := ctxByRule[name]
425
+ if !built {
426
+ if severity := fileRules.Severity(name); severity != SeverityOff {
427
+ ctx = &Context{
428
+ File: file,
429
+ Checker: checker,
430
+ Severity: severity,
431
+ Options: e.config.RuleOptions(name),
432
+ rule: rule,
433
+ isFormat: isFormatRule(rule),
434
+ collect: collect,
435
+ }
436
+ }
437
+ // A nil entry memoizes "off for this file" so a rule registered
438
+ // for several kinds resolves its severity only once.
439
+ ctxByRule[name] = ctx
440
+ }
441
+ if ctx == nil {
442
+ continue
443
+ }
444
+ byKind[kind] = append(byKind[kind], boundRule{rule: rule, ctx: ctx})
445
+ }
446
+ }
313
447
 
314
448
  var walk func(node *shimast.Node)
315
449
  walk = func(node *shimast.Node) {
316
450
  if node == nil {
317
451
  return
318
452
  }
319
- if rules, ok := e.rules[node.Kind]; ok {
320
- for _, rule := range rules {
321
- severity := fileRules.Severity(rule.Name())
322
- if severity == SeverityOff {
323
- continue
324
- }
325
- ctx := &Context{
326
- File: file,
327
- Checker: checker,
328
- Severity: severity,
329
- Options: e.config.RuleOptions(rule.Name()),
330
- rule: rule,
331
- isFormat: isFormatRule(rule),
332
- collect: collect,
333
- }
334
- runRuleCheck(rule, ctx, node, collect)
335
- }
453
+ for _, bound := range byKind[node.Kind] {
454
+ runRuleCheck(bound.rule, bound.ctx, node, collect)
336
455
  }
337
456
  node.ForEachChild(func(child *shimast.Node) bool {
338
457
  walk(child)
@@ -344,23 +463,8 @@ func (e *Engine) runFile(file *shimast.SourceFile, checker *shimchecker.Checker)
344
463
  // statements explicitly so the file node itself can be inspected by
345
464
  // rules (e.g., `ban-ts-comment` reads CommentDirectives off the
346
465
  // SourceFile).
347
- if rules, ok := e.rules[shimast.KindSourceFile]; ok {
348
- for _, rule := range rules {
349
- severity := fileRules.Severity(rule.Name())
350
- if severity == SeverityOff {
351
- continue
352
- }
353
- ctx := &Context{
354
- File: file,
355
- Checker: checker,
356
- Severity: severity,
357
- Options: e.config.RuleOptions(rule.Name()),
358
- rule: rule,
359
- isFormat: isFormatRule(rule),
360
- collect: collect,
361
- }
362
- runRuleCheck(rule, ctx, file.AsNode(), collect)
363
- }
466
+ for _, bound := range byKind[shimast.KindSourceFile] {
467
+ runRuleCheck(bound.rule, bound.ctx, file.AsNode(), collect)
364
468
  }
365
469
 
366
470
  statements := file.Statements
@@ -377,6 +481,15 @@ func (e *Engine) runFile(file *shimast.SourceFile, checker *shimchecker.Checker)
377
481
  return filterInlineDisabledFindings(file, collected)
378
482
  }
379
483
 
484
+ func hasEnabledFileRules(rules RuleConfig) bool {
485
+ for _, severity := range rules {
486
+ if severity != SeverityOff {
487
+ return true
488
+ }
489
+ }
490
+ return false
491
+ }
492
+
380
493
  // runRuleCheck invokes a rule's `Check` with a `recover()` barrier so a
381
494
  // panicking rule does not abort the entire `ttsc fix` / `ttsc check`
382
495
  // run. Built-in rules are not expected to panic, but contributor rules