@ttsc/lint 0.10.1 → 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
@@ -1,15 +1,29 @@
1
- package main
1
+ package linthost
2
2
 
3
3
  import (
4
+ "bytes"
5
+ "context"
4
6
  "encoding/json"
5
7
  "fmt"
6
8
  "os"
7
9
  "os/exec"
8
10
  "path/filepath"
11
+ "runtime"
9
12
  "sort"
10
13
  "strings"
14
+ "sync"
15
+ "time"
11
16
  )
12
17
 
18
+ // configLoaderTimeout caps every `ttsx`/`node -e` subprocess that
19
+ // evaluates a user-supplied lint config. The JS factory imposes the
20
+ // same 60 s budget on its mirroring spawnSync; without the Go-side cap
21
+ // a runaway user config would hang `ttsc-lint` forever, while
22
+ // `ttsc`/`pnpm` upstream of it stays responsive. 60 s is generous
23
+ // enough for cold ttsx starts on CI runners and tight enough to keep
24
+ // user-visible feedback under a minute.
25
+ const configLoaderTimeout = 60 * time.Second
26
+
13
27
  // Severity is the `error | warning | off` ladder.
14
28
  type Severity int
15
29
 
@@ -69,6 +83,13 @@ func FindLintEntry(entries []PluginEntry) (*PluginEntry, error) {
69
83
  // rule name (e.g. "no-var").
70
84
  type RuleConfig map[string]Severity
71
85
 
86
+ // RuleOptionsMap captures the rule-specific options blob, keyed by rule
87
+ // name. Severity-only rules never appear here. The values are the raw
88
+ // JSON the user wrote in the second tuple slot of an ESLint-style
89
+ // `["error", { ... }]` setting; each rule decodes the blob into its own
90
+ // option struct on demand.
91
+ type RuleOptionsMap map[string]json.RawMessage
92
+
72
93
  // ResolvedRuleConfig is the rule map that applies to one source file.
73
94
  // `Ignored` means an external ESLint-style ignore-only config matched the
74
95
  // file and the engine should skip linting it entirely.
@@ -81,6 +102,10 @@ type RuleResolver interface {
81
102
  ResolveRules(fileName string) ResolvedRuleConfig
82
103
  ActiveRuleNames() []string
83
104
  EnabledRuleConfig() RuleConfig
105
+ // RuleOptions returns the raw JSON options for `name`, or nil when the
106
+ // rule was configured with a severity alone. Returns nil for unknown
107
+ // rule names too — rules treat that as "use defaults".
108
+ RuleOptions(name string) json.RawMessage
84
109
  }
85
110
 
86
111
  func (c RuleConfig) ResolveRules(string) ResolvedRuleConfig {
@@ -101,11 +126,57 @@ func (c RuleConfig) EnabledRuleConfig() RuleConfig {
101
126
  return out
102
127
  }
103
128
 
129
+ // RuleOptions on a bare RuleConfig is always nil — this form is the
130
+ // severity-only path used by Go unit tests and the legacy inline-rules
131
+ // surface that predates option support.
132
+ func (RuleConfig) RuleOptions(string) json.RawMessage { return nil }
133
+
134
+ // InlineRuleResolver pairs a severity map with an options map for
135
+ // tsconfig-inline rule blocks. The fields are public so tests can
136
+ // construct one without going through ParseRulesWithOptions.
137
+ type InlineRuleResolver struct {
138
+ Rules RuleConfig
139
+ Options RuleOptionsMap
140
+ }
141
+
142
+ func (r InlineRuleResolver) ResolveRules(string) ResolvedRuleConfig {
143
+ return ResolvedRuleConfig{Rules: r.Rules}
144
+ }
145
+
146
+ func (r InlineRuleResolver) ActiveRuleNames() []string {
147
+ return r.Rules.ActiveRuleNames()
148
+ }
149
+
150
+ func (r InlineRuleResolver) EnabledRuleConfig() RuleConfig {
151
+ return r.Rules.EnabledRuleConfig()
152
+ }
153
+
154
+ func (r InlineRuleResolver) RuleOptions(name string) json.RawMessage {
155
+ if r.Options == nil {
156
+ return nil
157
+ }
158
+ return r.Options[name]
159
+ }
160
+
104
161
  type ConfigStore struct {
105
162
  entries []ConfigEntry
106
163
  externalConfigPath string
107
164
  eslintRuntime bool
108
165
  eslintRuntimeRequired bool
166
+ // options is a flat rule-name → JSON map. Options are not scoped by
167
+ // `files` / `ignores`: a rule's behavior is a single project-wide
168
+ // configuration even when its severity is per-file. The simplification
169
+ // matches prettier-style options (one setting per project) while
170
+ // keeping severity layering intact.
171
+ options RuleOptionsMap
172
+ }
173
+
174
+ // RuleOptions implements RuleResolver.RuleOptions on ConfigStore.
175
+ func (s *ConfigStore) RuleOptions(name string) json.RawMessage {
176
+ if s == nil {
177
+ return nil
178
+ }
179
+ return s.options[name]
109
180
  }
110
181
 
111
182
  type ConfigEntry struct {
@@ -242,22 +313,77 @@ func (e ConfigEntry) matchesIgnores(fileName string) bool {
242
313
  // Anything else returns an error (no silent fallback — typos in a rule
243
314
  // severity should be loud).
244
315
  func ParseRules(raw any) (RuleConfig, error) {
316
+ cfg, _, err := ParseRulesWithOptions(raw)
317
+ return cfg, err
318
+ }
319
+
320
+ // ParseRulesWithOptions accepts either a severity literal or a
321
+ // `[severity, options]` tuple per rule and returns the severity map
322
+ // alongside an options map keyed by rule name. The options map only
323
+ // contains entries for rules whose configuration was the tuple form.
324
+ func ParseRulesWithOptions(raw any) (RuleConfig, RuleOptionsMap, error) {
245
325
  if raw == nil {
246
- return RuleConfig{}, nil
326
+ return RuleConfig{}, RuleOptionsMap{}, nil
247
327
  }
248
328
  dict, ok := raw.(map[string]any)
249
329
  if !ok {
250
- return nil, fmt.Errorf("@ttsc/lint: \"config\" must be an object, got %T", raw)
330
+ return nil, nil, fmt.Errorf("@ttsc/lint: \"config\" must be an object, got %T", raw)
251
331
  }
252
- out := make(RuleConfig, len(dict))
332
+ cfg := make(RuleConfig, len(dict))
333
+ opts := make(RuleOptionsMap)
253
334
  for name, value := range dict {
254
- sev, err := parseSeverity(value)
335
+ sev, raw, err := parseRuleEntry(value)
255
336
  if err != nil {
256
- return nil, fmt.Errorf("@ttsc/lint: rule %q: %w", name, err)
337
+ return nil, nil, fmt.Errorf("@ttsc/lint: rule %q: %w", name, err)
338
+ }
339
+ cfg[name] = sev
340
+ if len(raw) > 0 {
341
+ opts[name] = raw
257
342
  }
258
- out[name] = sev
259
343
  }
260
- return out, nil
344
+ return cfg, opts, nil
345
+ }
346
+
347
+ // parseRuleEntry splits an ESLint-shaped rule entry into its severity
348
+ // and (optional) options payload. Bare severity literals produce a nil
349
+ // options blob; `[severity]` (no options) does the same; `[severity,
350
+ // options]` re-serializes the options to JSON so each rule can decode it
351
+ // into its own struct later.
352
+ func parseRuleEntry(value any) (Severity, json.RawMessage, error) {
353
+ if tuple, ok := value.([]any); ok {
354
+ if len(tuple) == 0 {
355
+ return SeverityOff, nil, fmt.Errorf("severity tuple must not be empty")
356
+ }
357
+ sev, err := parseSeverity(tuple[0])
358
+ if err != nil {
359
+ return SeverityOff, nil, err
360
+ }
361
+ if len(tuple) == 1 {
362
+ return sev, nil, nil
363
+ }
364
+ if len(tuple) > 2 {
365
+ return SeverityOff, nil, fmt.Errorf("severity tuple must be [severity] or [severity, options], got %d elements", len(tuple))
366
+ }
367
+ if tuple[1] == nil {
368
+ return sev, nil, nil
369
+ }
370
+ if _, ok := tuple[1].(map[string]any); !ok {
371
+ // ESLint accepts string-typed positional options as a shorthand
372
+ // (e.g. `["error", "single"]` for the `quotes` rule). ttsc does
373
+ // not: every option struct in TtscLintRuleOptions is an object,
374
+ // and silently encoding a non-object slot would land in
375
+ // DecodeOptions as a decode error that every rule discards. Fail
376
+ // loudly so users discover the proper `["error", { … }]` form.
377
+ return SeverityOff, nil, fmt.Errorf("severity tuple's options slot must be an object, got %T", tuple[1])
378
+ }
379
+ encoded, err := json.Marshal(tuple[1])
380
+ if err != nil {
381
+ return SeverityOff, nil, fmt.Errorf("encode options: %w", err)
382
+ }
383
+ return sev, encoded, nil
384
+ }
385
+ sev, err := parseSeverity(value)
386
+ return sev, nil, err
261
387
  }
262
388
 
263
389
  func parseExternalConfigRules(raw any) (RuleConfig, error) {
@@ -331,8 +457,43 @@ func collectExternalConfigEntries(store *ConfigStore, raw any, baseDir, path str
331
457
  if err != nil {
332
458
  return err
333
459
  }
334
- if rules, ok := typed["rules"]; ok {
335
- parsed, err := parseExternalRuleMap(rules, path+".rules")
460
+ rulesValue, hasRules := typed["rules"]
461
+ formatValue, hasFormat := typed["format"]
462
+ if hasRules || hasFormat {
463
+ // Expand the format block (if any) into a rules-shaped map,
464
+ // then overlay any explicit `rules` entries. `rules`-wins
465
+ // semantics match the inline path; the conflict-resolution
466
+ // table is identical regardless of which surface a user
467
+ // chose.
468
+ var formatRulesRaw map[string]any
469
+ if hasFormat {
470
+ fmtMap, ok := formatValue.(map[string]any)
471
+ if !ok {
472
+ return fmt.Errorf("@ttsc/lint: %s.format must be an object, got %T", path, formatValue)
473
+ }
474
+ expanded, err := expandFormatBlock(fmtMap)
475
+ if err != nil {
476
+ return err
477
+ }
478
+ formatRulesRaw = expanded
479
+ }
480
+ var rulesMap map[string]any
481
+ if hasRules {
482
+ // `parseExternalRuleMapInto` accepts the raw map directly.
483
+ // Coerce here to feed the same merge pipeline as the
484
+ // inline path.
485
+ typedMap, ok := rulesValue.(map[string]any)
486
+ if !ok {
487
+ return fmt.Errorf("@ttsc/lint: %s.rules must be a rule severity map, got %T", path, rulesValue)
488
+ }
489
+ rulesMap = typedMap
490
+ }
491
+ merged := mergeRuleMaps(formatRulesRaw, rulesMap)
492
+ if len(merged) == 0 {
493
+ // `format: { severity: "off" }` and no rules — drop entry.
494
+ return nil
495
+ }
496
+ parsed, err := parseExternalRuleMapInto(merged, path+".rules", store)
336
497
  if err != nil {
337
498
  return err
338
499
  }
@@ -353,7 +514,7 @@ func collectExternalConfigEntries(store *ConfigStore, raw any, baseDir, path str
353
514
  }
354
515
  return nil
355
516
  }
356
- parsed, err := parseExternalRuleMap(typed, path)
517
+ parsed, err := parseExternalRuleMapInto(typed, path, store)
357
518
  if err != nil {
358
519
  return err
359
520
  }
@@ -394,25 +555,38 @@ func collectExternalExtends(store *ConfigStore, raw any, baseDir, path string, a
394
555
  }
395
556
  }
396
557
 
397
- func parseExternalRuleMap(raw any, path string) (RuleConfig, error) {
558
+ // parseExternalRuleMapInto parses the rules map and folds any
559
+ // option blobs into `store.options`. Used by entry-creation paths so
560
+ // the store ends with a unified options map for RuleResolver consumers.
561
+ func parseExternalRuleMapInto(raw any, path string, store *ConfigStore) (RuleConfig, error) {
398
562
  out := RuleConfig{}
399
- if err := collectExternalRuleMap(out, raw, path); err != nil {
563
+ if store.options == nil {
564
+ store.options = RuleOptionsMap{}
565
+ }
566
+ if err := collectExternalRuleMapWithOptions(out, store.options, raw, path); err != nil {
400
567
  return nil, err
401
568
  }
402
569
  return out, nil
403
570
  }
404
571
 
405
- func collectExternalRuleMap(out RuleConfig, raw any, path string) error {
572
+ // collectExternalRuleMapWithOptions also records the rule's options blob
573
+ // when the entry is a `[severity, options]` tuple. `opts` may be nil
574
+ // when the caller does not need option capture (legacy paths).
575
+ func collectExternalRuleMapWithOptions(out RuleConfig, opts RuleOptionsMap, raw any, path string) error {
406
576
  dict, ok := raw.(map[string]any)
407
577
  if !ok {
408
578
  return fmt.Errorf("@ttsc/lint: %s must be a rules object, got %T", path, raw)
409
579
  }
410
580
  for name, value := range dict {
411
- sev, err := parseExternalSeverity(value)
581
+ sev, ruleOpts, err := parseExternalRuleEntry(value)
412
582
  if err != nil {
413
583
  return fmt.Errorf("@ttsc/lint: rule %q: %w", name, err)
414
584
  }
415
- out[normalizeExternalRuleName(name)] = sev
585
+ canonical := normalizeExternalRuleName(name)
586
+ out[canonical] = sev
587
+ if opts != nil && len(ruleOpts) > 0 {
588
+ opts[canonical] = ruleOpts
589
+ }
416
590
  }
417
591
  return nil
418
592
  }
@@ -422,6 +596,7 @@ func isESLintConfigObject(value map[string]any) bool {
422
596
  "basePath",
423
597
  "extends",
424
598
  "files",
599
+ "format",
425
600
  "ignores",
426
601
  "languageOptions",
427
602
  "linterOptions",
@@ -443,7 +618,6 @@ func hasESLintRuntimeFields(value map[string]any) bool {
443
618
  for _, key := range []string{
444
619
  "languageOptions",
445
620
  "linterOptions",
446
- "plugins",
447
621
  "processor",
448
622
  "settings",
449
623
  } {
@@ -451,9 +625,71 @@ func hasESLintRuntimeFields(value map[string]any) bool {
451
625
  return true
452
626
  }
453
627
  }
628
+ if plugins, ok := value["plugins"]; ok {
629
+ if !isNativePluginMap(plugins) {
630
+ return true
631
+ }
632
+ }
454
633
  return false
455
634
  }
456
635
 
636
+ // isNativePluginMap reports whether every entry in a flat-config
637
+ // `plugins` map points at a ttsc-lint native contributor object
638
+ // (carrying a non-empty string `source` field). Native contributors are
639
+ // compiled into the lint binary at build time and require no JS ESLint
640
+ // runtime; only mixed or pure-ESLint plugin maps require the runtime
641
+ // fallback.
642
+ func isNativePluginMap(value any) bool {
643
+ dict, ok := value.(map[string]any)
644
+ if !ok {
645
+ return false
646
+ }
647
+ if len(dict) == 0 {
648
+ return true
649
+ }
650
+ for _, entry := range dict {
651
+ if !isNativePluginValue(entry) {
652
+ return false
653
+ }
654
+ }
655
+ return true
656
+ }
657
+
658
+ func isNativePluginValue(entry any) bool {
659
+ if entry == nil {
660
+ return false
661
+ }
662
+ switch typed := entry.(type) {
663
+ case string:
664
+ // A non-empty string is a native npm specifier (matching the JS
665
+ // factory's `normalizePluginValue` contract for `.js`/`.cjs`/`.ts`
666
+ // configs and the JSON-only `readJsonConfigPlugins` path). The JS
667
+ // factory resolves the specifier at load time and bakes the
668
+ // contributor into the binary, so the Go sidecar should not flip
669
+ // `eslintRuntimeRequired` for a file that already declared a
670
+ // native specifier.
671
+ return typed != ""
672
+ case map[string]any:
673
+ // Walk ESM-from-CJS `.default` indirection so a contributor authored
674
+ // as `export default plugin` registers as native here, matching the
675
+ // JS factory's `extractPluginSource` behavior.
676
+ current := typed
677
+ for i := 0; i < 4; i++ {
678
+ if source, ok := current["source"].(string); ok && source != "" {
679
+ return true
680
+ }
681
+ next, ok := current["default"].(map[string]any)
682
+ if !ok {
683
+ return false
684
+ }
685
+ current = next
686
+ }
687
+ return false
688
+ default:
689
+ return false
690
+ }
691
+ }
692
+
457
693
  func normalizeExternalRuleName(name string) string {
458
694
  name = strings.TrimPrefix(name, "@typescript-eslint/")
459
695
  return strings.TrimPrefix(name, "typescript-eslint/")
@@ -506,10 +742,22 @@ func LoadRuleConfig(entry *PluginEntry, cwd, tsconfigPath string) (RuleConfig, e
506
742
  }
507
743
  }
508
744
 
509
- // LoadConfigResolver resolves one plugin entry into the engine-facing config
510
- // model. Inline `config` remains the native flat rule map. External config
511
- // files may carry ESLint flat-config-style file globs, ignores, object
512
- // extends, and rule severity tuples.
745
+ // LoadConfigResolver resolves one plugin entry into the engine-facing
746
+ // config model.
747
+ //
748
+ // Two equivalent input shapes are accepted:
749
+ //
750
+ // - `rules` (inline severity map) + `extends` (config file path) —
751
+ // the canonical fields mirroring ESLint flat-config vocabulary.
752
+ // - `config` (legacy) — accepts the same string-or-map values but
753
+ // emits a one-time stderr deprecation notice. Removed in a future
754
+ // minor.
755
+ //
756
+ // `rules` and `extends` are mutually exclusive on a single plugin
757
+ // entry; mixing legacy `config` with either new field is rejected.
758
+ // `configFile` and `configPath` remain reserved keywords surfaced with
759
+ // a hint pointing at `extends`, in case a user mistakenly reaches for
760
+ // either spelling.
513
761
  func LoadConfigResolver(entry *PluginEntry, cwd, tsconfigPath string) (RuleResolver, error) {
514
762
  if entry == nil {
515
763
  return RuleConfig{}, nil
@@ -518,35 +766,124 @@ func LoadConfigResolver(entry *PluginEntry, cwd, tsconfigPath string) (RuleResol
518
766
  if inline == nil {
519
767
  inline = map[string]any{}
520
768
  }
521
- for _, key := range []string{"rules", "configFile", "configPath"} {
769
+ for _, key := range []string{"configFile", "configPath"} {
522
770
  if _, ok := inline[key]; ok {
523
- return nil, fmt.Errorf("@ttsc/lint: %q is not supported; use \"config\"", key)
771
+ return nil, fmt.Errorf("@ttsc/lint: %q is not supported; use \"extends\"", key)
524
772
  }
525
773
  }
526
774
 
527
- value, ok := inline["config"]
528
- if !ok {
529
- discovered, err := findLintConfigFile(cwd, tsconfigPath)
775
+ rulesValue, hasRules := inline["rules"]
776
+ extendsValue, hasExtends := inline["extends"]
777
+ legacyValue, hasLegacy := inline["config"]
778
+ formatValue, hasFormat := inline["format"]
779
+
780
+ if hasLegacy && (hasRules || hasExtends || hasFormat) {
781
+ sibling := ""
782
+ switch {
783
+ case hasFormat && !hasRules && !hasExtends:
784
+ sibling = "format"
785
+ case hasExtends && !hasRules && !hasFormat:
786
+ sibling = "extends"
787
+ case hasRules && !hasExtends && !hasFormat:
788
+ sibling = "rules"
789
+ default:
790
+ sibling = "rules/extends/format"
791
+ }
792
+ return nil, fmt.Errorf("@ttsc/lint: tsconfig plugin entry mixes legacy \"config\" with the new %q field; remove \"config\" (deprecated)", sibling)
793
+ }
794
+ if hasRules && hasExtends {
795
+ return nil, fmt.Errorf("@ttsc/lint: \"rules\" and \"extends\" cannot be combined on a single plugin entry; put base rules in the \"extends\" file and inline overrides in lint.config.ts itself")
796
+ }
797
+ if hasFormat && hasExtends {
798
+ return nil, fmt.Errorf("@ttsc/lint: \"format\" and \"extends\" cannot be combined on a single plugin entry; put format options inside the extends-target lint.config.ts instead")
799
+ }
800
+
801
+ // Expand the format block (if any) into a rules-shaped map. The
802
+ // returned map is empty when `format.severity === "off"` so the
803
+ // downstream merge with `rules` still produces the right result.
804
+ var formatRulesRaw map[string]any
805
+ if hasFormat {
806
+ formatMap, ok := formatValue.(map[string]any)
807
+ if !ok {
808
+ return nil, fmt.Errorf("@ttsc/lint: \"format\" must be an object, got %T", formatValue)
809
+ }
810
+ expanded, err := expandFormatBlock(formatMap)
530
811
  if err != nil {
531
812
  return nil, err
532
813
  }
533
- if discovered == "" {
534
- return nil, fmt.Errorf("@ttsc/lint: \"config\" is required when no lint.config.*, ttsc-lint.config.*, or supported eslint.config.* file can be discovered")
814
+ formatRulesRaw = expanded
815
+ }
816
+
817
+ if hasRules || hasFormat {
818
+ // Merge format defaults with inline rule overrides; the `rules`
819
+ // entry wins on key collisions (more explicit surface).
820
+ var rulesMap map[string]any
821
+ if hasRules {
822
+ typed, ok := rulesValue.(map[string]any)
823
+ if !ok {
824
+ return nil, fmt.Errorf("@ttsc/lint: \"rules\" must be a rule severity map, got %T", rulesValue)
825
+ }
826
+ rulesMap = typed
827
+ }
828
+ merged := mergeRuleMaps(formatRulesRaw, rulesMap)
829
+ if len(merged) == 0 {
830
+ // `format: { severity: "off" }` with no `rules` field — no
831
+ // rules to register.
832
+ return InlineRuleResolver{Rules: RuleConfig{}, Options: RuleOptionsMap{}}, nil
833
+ }
834
+ cfg, opts, err := ParseRulesWithOptions(merged)
835
+ if err != nil {
836
+ return nil, err
535
837
  }
536
- return loadExternalConfigResolver(discovered)
838
+ return InlineRuleResolver{Rules: cfg, Options: opts}, nil
537
839
  }
538
- switch typed := value.(type) {
539
- case string:
540
- if strings.TrimSpace(typed) == "" {
541
- return nil, fmt.Errorf("@ttsc/lint: \"config\" must not be empty")
840
+ if hasExtends {
841
+ extendsStr, ok := extendsValue.(string)
842
+ if !ok {
843
+ return nil, fmt.Errorf("@ttsc/lint: \"extends\" must be a string path, got %T", extendsValue)
844
+ }
845
+ if strings.TrimSpace(extendsStr) == "" {
846
+ return nil, fmt.Errorf("@ttsc/lint: \"extends\" must not be empty")
542
847
  }
543
- location := resolveConfigFilePath(typed, cwd, tsconfigPath)
848
+ location := resolveConfigFilePath(extendsStr, cwd, tsconfigPath)
544
849
  return loadExternalConfigResolver(location)
545
- case map[string]any:
546
- return ParseRules(typed)
547
- default:
548
- return nil, fmt.Errorf("@ttsc/lint: \"config\" must be a string path or object, got %T", value)
549
850
  }
851
+ if hasLegacy {
852
+ emitLegacyConfigDeprecation()
853
+ switch typed := legacyValue.(type) {
854
+ case string:
855
+ if strings.TrimSpace(typed) == "" {
856
+ return nil, fmt.Errorf("@ttsc/lint: legacy \"config\" must not be empty")
857
+ }
858
+ location := resolveConfigFilePath(typed, cwd, tsconfigPath)
859
+ return loadExternalConfigResolver(location)
860
+ case map[string]any:
861
+ cfg, opts, err := ParseRulesWithOptions(typed)
862
+ if err != nil {
863
+ return nil, err
864
+ }
865
+ return InlineRuleResolver{Rules: cfg, Options: opts}, nil
866
+ default:
867
+ return nil, fmt.Errorf("@ttsc/lint: legacy \"config\" must be a string path or object, got %T", legacyValue)
868
+ }
869
+ }
870
+
871
+ discovered, err := findLintConfigFile(cwd, tsconfigPath)
872
+ if err != nil {
873
+ return nil, err
874
+ }
875
+ if discovered == "" {
876
+ return nil, fmt.Errorf("@ttsc/lint: \"rules\" or \"extends\" is required when no lint.config.*, ttsc-lint.config.*, or supported eslint.config.* file can be discovered (searched upward from %s)", cwd)
877
+ }
878
+ return loadExternalConfigResolver(discovered)
879
+ }
880
+
881
+ var legacyConfigDeprecationOnce sync.Once
882
+
883
+ func emitLegacyConfigDeprecation() {
884
+ legacyConfigDeprecationOnce.Do(func() {
885
+ fmt.Fprintln(os.Stderr, "@ttsc/lint: tsconfig plugin entry \"config\" is deprecated; use \"rules\" for inline severity maps or \"extends\" for a config file path.")
886
+ })
550
887
  }
551
888
 
552
889
  func loadExternalConfigResolver(location string) (RuleResolver, error) {
@@ -594,7 +931,11 @@ func findLintConfigFile(cwd, tsconfigPath string) (string, error) {
594
931
  }
595
932
  }
596
933
  if len(matches) > 1 {
597
- return "", fmt.Errorf("@ttsc/lint: multiple lint config files found in %s; set \"config\" explicitly", dir)
934
+ names := make([]string, 0, len(matches))
935
+ for _, m := range matches {
936
+ names = append(names, filepath.Base(m))
937
+ }
938
+ return "", fmt.Errorf("@ttsc/lint: multiple lint config files found in %s (%s); set \"extends\" explicitly", dir, strings.Join(names, ", "))
598
939
  }
599
940
  if len(matches) == 1 {
600
941
  return matches[0], nil
@@ -655,6 +996,11 @@ func loadJSONConfigFile(location string) (any, error) {
655
996
  if err != nil {
656
997
  return nil, fmt.Errorf("@ttsc/lint: read config file %s: %w", location, err)
657
998
  }
999
+ // Strip a leading UTF-8 BOM so files saved by Windows editors round
1000
+ // trip through `json.Unmarshal` without an opaque "invalid character"
1001
+ // failure. Mirrors the equivalent JS-side guard in
1002
+ // `packages/lint/src/index.ts::readJsonConfigPlugins`.
1003
+ body = bytes.TrimPrefix(body, []byte{0xEF, 0xBB, 0xBF})
658
1004
  var out any
659
1005
  if err := json.Unmarshal(body, &out); err != nil {
660
1006
  return nil, fmt.Errorf("@ttsc/lint: parse config file %s: %w", location, err)
@@ -671,8 +1017,26 @@ const { pathToFileURL } = require("node:url");
671
1017
 
672
1018
  (async () => {
673
1019
  const mod = await import(pathToFileURL(process.argv[1]).href);
674
- const candidate = mod.default ?? mod.config ?? mod;
675
- const value = typeof candidate === "function" ? await candidate() : candidate;
1020
+ let current = mod;
1021
+ let allowNamedConfig = true;
1022
+ // Match the 8-hop walk used by the TypeScript loader at
1023
+ // ` + "`" + `typeScriptConfigLoaderSource` + "`" + ` so doubly-wrapped CJS/ESM
1024
+ // interop (e.g. ` + "`" + `{default:{default:config}}` + "`" + `) is resolved
1025
+ // consistently across .js/.cjs/.mjs and .ts/.cts/.mts loaders.
1026
+ for (let i = 0; i < 8; i++) {
1027
+ if (current !== null && typeof current === "object" && Object.prototype.hasOwnProperty.call(current, "default")) {
1028
+ current = current.default;
1029
+ allowNamedConfig = false;
1030
+ continue;
1031
+ }
1032
+ if (allowNamedConfig && current !== null && typeof current === "object" && Object.prototype.hasOwnProperty.call(current, "config")) {
1033
+ current = current.config;
1034
+ allowNamedConfig = false;
1035
+ continue;
1036
+ }
1037
+ break;
1038
+ }
1039
+ const value = typeof current === "function" ? await current() : current;
676
1040
  if (value === null || typeof value !== "object") {
677
1041
  throw new Error("config file must export an object or flat config array");
678
1042
  }
@@ -753,22 +1117,57 @@ function isESLintConfigObject(value) {
753
1117
  }
754
1118
 
755
1119
  function hasESLintRuntimeFields(value) {
756
- return [
757
- "languageOptions",
758
- "linterOptions",
759
- "plugins",
760
- "processor",
761
- "settings",
762
- ].some((key) => Object.prototype.hasOwnProperty.call(value, key));
1120
+ for (const key of ["languageOptions", "linterOptions", "processor", "settings"]) {
1121
+ if (Object.prototype.hasOwnProperty.call(value, key)) return true;
1122
+ }
1123
+ if (Object.prototype.hasOwnProperty.call(value, "plugins")) {
1124
+ if (!isNativePluginMap(value.plugins)) return true;
1125
+ }
1126
+ return false;
1127
+ }
1128
+
1129
+ function isNativePluginMap(value) {
1130
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
1131
+ return false;
1132
+ }
1133
+ const entries = Object.values(value);
1134
+ if (entries.length === 0) return true;
1135
+ for (const entry of entries) {
1136
+ if (!isNativePluginValue(entry)) return false;
1137
+ }
1138
+ return true;
1139
+ }
1140
+
1141
+ function isNativePluginValue(entry) {
1142
+ // A non-empty string is a native specifier — JS factory resolves it
1143
+ // at load time, so the loader must not flip the ESLint-runtime flag.
1144
+ if (typeof entry === "string") return entry.length > 0;
1145
+ if (entry === null || typeof entry !== "object") return false;
1146
+ let current = entry;
1147
+ for (let i = 0; i < 4; i++) {
1148
+ if (typeof current.source === "string" && current.source.length > 0) {
1149
+ return true;
1150
+ }
1151
+ if (current.default === null || typeof current.default !== "object") {
1152
+ return false;
1153
+ }
1154
+ current = current.default;
1155
+ }
1156
+ return false;
763
1157
  }
764
1158
  `
765
1159
  node := os.Getenv("TTSC_NODE_BINARY")
766
1160
  if node == "" {
767
1161
  node = "node"
768
1162
  }
769
- cmd := exec.Command(node, "-e", script, location)
1163
+ ctx, cancel := context.WithTimeout(context.Background(), configLoaderTimeout)
1164
+ defer cancel()
1165
+ cmd := exec.CommandContext(ctx, node, "-e", script, location)
770
1166
  output, err := cmd.Output()
771
1167
  if err != nil {
1168
+ if ctx.Err() == context.DeadlineExceeded {
1169
+ return nil, fmt.Errorf("@ttsc/lint: load config file %s: timed out after %s", location, configLoaderTimeout)
1170
+ }
772
1171
  stderr := ""
773
1172
  if exit, ok := err.(*exec.ExitError); ok {
774
1173
  stderr = strings.TrimSpace(string(exit.Stderr))
@@ -826,10 +1225,15 @@ func loadTypeScriptConfigFile(location string) (any, error) {
826
1225
  }
827
1226
  args = append(args, loader)
828
1227
 
829
- cmd := ttsxCommand(args...)
1228
+ ctx, cancel := context.WithTimeout(context.Background(), configLoaderTimeout)
1229
+ defer cancel()
1230
+ cmd := ttsxCommandContext(ctx, args...)
830
1231
  cmd.Env = nodeConfigLoaderEnv(location)
831
1232
  output, err := cmd.Output()
832
1233
  if err != nil {
1234
+ if ctx.Err() == context.DeadlineExceeded {
1235
+ return nil, fmt.Errorf("@ttsc/lint: load TypeScript config file %s: timed out after %s", location, configLoaderTimeout)
1236
+ }
833
1237
  stderr := ""
834
1238
  if exit, ok := err.(*exec.ExitError); ok {
835
1239
  stderr = strings.TrimSpace(string(exit.Stderr))
@@ -990,28 +1394,81 @@ function isESLintConfigObject(value: Record<string, unknown>): boolean {
990
1394
  }
991
1395
 
992
1396
  function hasESLintRuntimeFields(value: Record<string, unknown>): boolean {
993
- return [
994
- "languageOptions",
995
- "linterOptions",
996
- "plugins",
997
- "processor",
998
- "settings",
999
- ].some((key) => hasOwn(value, key));
1397
+ for (const key of ["languageOptions", "linterOptions", "processor", "settings"]) {
1398
+ if (hasOwn(value, key)) return true;
1399
+ }
1400
+ if (hasOwn(value, "plugins")) {
1401
+ const plugins = value.plugins;
1402
+ if (!isNativePluginMap(plugins)) return true;
1403
+ }
1404
+ return false;
1405
+ }
1406
+
1407
+ // isNativePluginMap reports whether every entry of a plugins map points
1408
+ // at a ttsc-lint native contributor (an object with a string "source"
1409
+ // field). Native plugins are compiled into the lint binary at build
1410
+ // time, so their presence does NOT require the JavaScript ESLint
1411
+ // runtime; only mixed or pure-ESLint plugin maps do.
1412
+ function isNativePluginMap(value: unknown): boolean {
1413
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
1414
+ return false;
1415
+ }
1416
+ const entries = Object.values(value as Record<string, unknown>);
1417
+ if (entries.length === 0) return true;
1418
+ for (const entry of entries) {
1419
+ if (!isNativePluginValue(entry)) return false;
1420
+ }
1421
+ return true;
1422
+ }
1423
+
1424
+ function isNativePluginValue(entry: unknown): boolean {
1425
+ // A non-empty string is a native specifier — see the matching Go-side
1426
+ // and JS-loader implementations.
1427
+ if (typeof entry === "string") return entry.length > 0;
1428
+ if (entry === null || typeof entry !== "object") return false;
1429
+ // ESM-from-CJS interop wraps CJS modules' "exports.default" so a
1430
+ // contributor authored as "export default plugin" lands under a
1431
+ // ".default" indirection. Walk a few hops so both "export default"
1432
+ // and plain "module.exports = plugin" contributors register as
1433
+ // native here.
1434
+ let current = entry as Record<string, unknown>;
1435
+ for (let i = 0; i < 4; i++) {
1436
+ if (typeof current.source === "string" && (current.source as string).length > 0) {
1437
+ return true;
1438
+ }
1439
+ const next = current.default;
1440
+ if (next === null || typeof next !== "object" || Array.isArray(next)) {
1441
+ return false;
1442
+ }
1443
+ current = next as Record<string, unknown>;
1444
+ }
1445
+ return false;
1000
1446
  }
1001
1447
  `, importLiteral)
1002
1448
  }
1003
1449
 
1004
1450
  func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
1451
+ // Mirror the JS-factory loader's lenient settings (see the matching
1452
+ // tsconfig synthesis in `packages/lint/src/index.ts::readTtsxConfigPlugins`).
1453
+ // Both sides evaluate the SAME user-authored lint config; without
1454
+ // matching strictness, a config that loads fine through the JS
1455
+ // factory could be rejected by the Go sidecar (or vice versa) on
1456
+ // identical input. The loader is extracting data, not validating
1457
+ // user code, so `strict: false` + `allowJs: true` + `noImplicitAny:
1458
+ // false` is the right baseline.
1005
1459
  content := map[string]any{
1006
1460
  "compilerOptions": map[string]any{
1007
1461
  "allowImportingTsExtensions": true,
1462
+ "allowJs": true,
1463
+ "checkJs": false,
1008
1464
  "module": "ESNext",
1009
1465
  "moduleResolution": "bundler",
1466
+ "noImplicitAny": false,
1010
1467
  "outDir": filepath.ToSlash(filepath.Join(outDir, "out")),
1011
1468
  "rewriteRelativeImportExtensions": true,
1012
1469
  "rootDir": "/",
1013
1470
  "skipLibCheck": true,
1014
- "strict": true,
1471
+ "strict": false,
1015
1472
  "target": "ES2022",
1016
1473
  },
1017
1474
  "files": []string{
@@ -1027,6 +1484,14 @@ func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
1027
1484
  }
1028
1485
 
1029
1486
  func ttsxCommand(args ...string) *exec.Cmd {
1487
+ return ttsxCommandContext(context.Background(), args...)
1488
+ }
1489
+
1490
+ // ttsxCommandContext is the timeout-aware variant. Callers that
1491
+ // evaluate user-supplied config should wrap their context with
1492
+ // `context.WithTimeout(parent, configLoaderTimeout)` so a runaway
1493
+ // `ttsx` subprocess can never hang the lint binary indefinitely.
1494
+ func ttsxCommandContext(ctx context.Context, args ...string) *exec.Cmd {
1030
1495
  ttsx := os.Getenv("TTSC_TTSX_BINARY")
1031
1496
  if ttsx == "" {
1032
1497
  ttsx = "ttsx"
@@ -1036,9 +1501,9 @@ func ttsxCommand(args ...string) *exec.Cmd {
1036
1501
  if node == "" {
1037
1502
  node = "node"
1038
1503
  }
1039
- return exec.Command(node, append([]string{ttsx}, args...)...)
1504
+ return exec.CommandContext(ctx, node, append([]string{ttsx}, args...)...)
1040
1505
  }
1041
- return exec.Command(ttsx, args...)
1506
+ return exec.CommandContext(ctx, ttsx, args...)
1042
1507
  }
1043
1508
 
1044
1509
  func shouldRunTtsxThroughNode(binary string) bool {
@@ -1071,8 +1536,32 @@ func linkNearestNodeModules(tempDir, sourceDir string) error {
1071
1536
  return nil
1072
1537
  }
1073
1538
  link := filepath.Join(tempDir, "node_modules")
1074
- if err := os.Symlink(nodeModules, link); err != nil {
1075
- return fmt.Errorf("@ttsc/lint: link config node_modules %s: %w", nodeModules, err)
1539
+ err := os.Symlink(nodeModules, link)
1540
+ if err == nil {
1541
+ return nil
1542
+ }
1543
+ // Windows: a true symbolic link needs SeCreateSymbolicLinkPrivilege
1544
+ // (admin or Developer Mode). The JS side uses fs.symlink with the
1545
+ // `"junction"` type to dodge that restriction; here we shell out to
1546
+ // `mklink /J` to create an equivalent directory junction. Junctions
1547
+ // only work for absolute directory targets, which matches the input.
1548
+ if runtime.GOOS == "windows" {
1549
+ jerr := createWindowsJunction(link, nodeModules)
1550
+ if jerr == nil {
1551
+ return nil
1552
+ }
1553
+ err = fmt.Errorf("%w (junction fallback: %v)", err, jerr)
1554
+ }
1555
+ return fmt.Errorf("@ttsc/lint: link config node_modules %s: %w", nodeModules, err)
1556
+ }
1557
+
1558
+ func createWindowsJunction(link, target string) error {
1559
+ // `cmd /c mklink /J link target` is the standard recipe and works
1560
+ // without elevated privileges. Both arguments must be absolute paths
1561
+ // with native separators, which they already are here.
1562
+ cmd := exec.Command("cmd", "/c", "mklink", "/J", link, target)
1563
+ if out, err := cmd.CombinedOutput(); err != nil {
1564
+ return fmt.Errorf("mklink /J failed: %v: %s", err, strings.TrimSpace(string(out)))
1076
1565
  }
1077
1566
  return nil
1078
1567
  }
@@ -1103,14 +1592,42 @@ func setEnv(env []string, key, value string) []string {
1103
1592
  return append(env, prefix+value)
1104
1593
  }
1105
1594
 
1106
- func parseExternalSeverity(v any) (Severity, error) {
1595
+ // parseExternalRuleEntry mirrors parseRuleEntry for ESLint-style config
1596
+ // inputs. The contract is identical to the inline path: severity
1597
+ // literal, `[severity]`, or `[severity, options]`. Tuples longer than
1598
+ // two elements are rejected because no built-in rule's option struct
1599
+ // models positional ESLint args, so silently encoding the tail as a
1600
+ // JSON array would land in `DecodeOptions` and fall back to defaults
1601
+ // — a silent fallback the standard parser deliberately avoids.
1602
+ func parseExternalRuleEntry(v any) (Severity, json.RawMessage, error) {
1107
1603
  if tuple, ok := v.([]any); ok {
1108
1604
  if len(tuple) == 0 {
1109
- return SeverityOff, fmt.Errorf("severity tuple must not be empty")
1605
+ return SeverityOff, nil, fmt.Errorf("severity tuple must not be empty")
1606
+ }
1607
+ sev, err := parseSeverity(tuple[0])
1608
+ if err != nil {
1609
+ return SeverityOff, nil, err
1610
+ }
1611
+ if len(tuple) == 1 {
1612
+ return sev, nil, nil
1613
+ }
1614
+ if len(tuple) > 2 {
1615
+ return SeverityOff, nil, fmt.Errorf("severity tuple must be [severity] or [severity, options], got %d elements", len(tuple))
1616
+ }
1617
+ if tuple[1] == nil {
1618
+ return sev, nil, nil
1619
+ }
1620
+ if _, ok := tuple[1].(map[string]any); !ok {
1621
+ return SeverityOff, nil, fmt.Errorf("severity tuple's options slot must be an object, got %T", tuple[1])
1622
+ }
1623
+ encoded, err := json.Marshal(tuple[1])
1624
+ if err != nil {
1625
+ return SeverityOff, nil, fmt.Errorf("encode options: %w", err)
1110
1626
  }
1111
- return parseSeverity(tuple[0])
1627
+ return sev, encoded, nil
1112
1628
  }
1113
- return parseSeverity(v)
1629
+ sev, err := parseSeverity(v)
1630
+ return sev, nil, err
1114
1631
  }
1115
1632
 
1116
1633
  func parseSeverity(v any) (Severity, error) {