@ttsc/lint 0.8.0-dev.20260506 → 0.8.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.
package/plugin/config.go CHANGED
@@ -6,6 +6,7 @@ import (
6
6
  "os"
7
7
  "os/exec"
8
8
  "path/filepath"
9
+ "sort"
9
10
  "strings"
10
11
  )
11
12
 
@@ -71,7 +72,170 @@ func FindLintEntry(entries []PluginEntry) (*PluginEntry, error) {
71
72
  // rule name (e.g. "no-var").
72
73
  type RuleConfig map[string]Severity
73
74
 
74
- // ParseRules normalizes the rules map from a tsconfig plugin entry.
75
+ // ResolvedRuleConfig is the rule map that applies to one source file.
76
+ // `Ignored` means an external ESLint-style ignore-only config matched the
77
+ // file and the engine should skip linting it entirely.
78
+ type ResolvedRuleConfig struct {
79
+ Rules RuleConfig
80
+ Ignored bool
81
+ }
82
+
83
+ type RuleResolver interface {
84
+ ResolveRules(fileName string) ResolvedRuleConfig
85
+ ActiveRuleNames() []string
86
+ EnabledRuleConfig() RuleConfig
87
+ }
88
+
89
+ func (c RuleConfig) ResolveRules(string) ResolvedRuleConfig {
90
+ return ResolvedRuleConfig{Rules: c}
91
+ }
92
+
93
+ func (c RuleConfig) ActiveRuleNames() []string {
94
+ return sortedRuleNames(c, func(sev Severity) bool { return sev != SeverityOff })
95
+ }
96
+
97
+ func (c RuleConfig) EnabledRuleConfig() RuleConfig {
98
+ out := RuleConfig{}
99
+ for name, sev := range c {
100
+ if sev != SeverityOff {
101
+ out[name] = sev
102
+ }
103
+ }
104
+ return out
105
+ }
106
+
107
+ type ConfigStore struct {
108
+ entries []ConfigEntry
109
+ externalConfigPath string
110
+ eslintRuntime bool
111
+ eslintRuntimeRequired bool
112
+ }
113
+
114
+ type ConfigEntry struct {
115
+ BaseDir string
116
+ Files []string
117
+ Ignores []string
118
+ Rules RuleConfig
119
+ IgnoreOnly bool
120
+ }
121
+
122
+ func (s *ConfigStore) ResolveRules(fileName string) ResolvedRuleConfig {
123
+ if s == nil {
124
+ return ResolvedRuleConfig{Rules: RuleConfig{}}
125
+ }
126
+ for _, entry := range s.entries {
127
+ if entry.IgnoreOnly && entry.matchesIgnores(fileName) {
128
+ return ResolvedRuleConfig{Rules: RuleConfig{}, Ignored: true}
129
+ }
130
+ }
131
+ out := RuleConfig{}
132
+ for _, entry := range s.entries {
133
+ if entry.IgnoreOnly || !entry.matchesFile(fileName) {
134
+ continue
135
+ }
136
+ for name, sev := range entry.Rules {
137
+ out[name] = sev
138
+ }
139
+ }
140
+ return ResolvedRuleConfig{Rules: out}
141
+ }
142
+
143
+ func (s *ConfigStore) ActiveRuleNames() []string {
144
+ if s == nil {
145
+ return nil
146
+ }
147
+ active := RuleConfig{}
148
+ for _, entry := range s.entries {
149
+ if entry.IgnoreOnly {
150
+ continue
151
+ }
152
+ for name, sev := range entry.Rules {
153
+ if sev != SeverityOff {
154
+ active[name] = sev
155
+ }
156
+ }
157
+ }
158
+ return sortedRuleNames(active, func(Severity) bool { return true })
159
+ }
160
+
161
+ func (s *ConfigStore) EnabledRuleConfig() RuleConfig {
162
+ out := RuleConfig{}
163
+ if s == nil {
164
+ return out
165
+ }
166
+ for _, entry := range s.entries {
167
+ if entry.IgnoreOnly {
168
+ continue
169
+ }
170
+ for name, sev := range entry.Rules {
171
+ if sev == SeverityOff {
172
+ continue
173
+ }
174
+ if out[name] != SeverityError {
175
+ out[name] = sev
176
+ }
177
+ }
178
+ }
179
+ return out
180
+ }
181
+
182
+ func (s *ConfigStore) Flatten() RuleConfig {
183
+ out := RuleConfig{}
184
+ if s == nil {
185
+ return out
186
+ }
187
+ for _, entry := range s.entries {
188
+ if entry.IgnoreOnly {
189
+ continue
190
+ }
191
+ for name, sev := range entry.Rules {
192
+ out[name] = sev
193
+ }
194
+ }
195
+ return out
196
+ }
197
+
198
+ func (s *ConfigStore) ExternalConfigPath() string {
199
+ if s == nil {
200
+ return ""
201
+ }
202
+ return s.externalConfigPath
203
+ }
204
+
205
+ func (s *ConfigStore) WantsESLintRuntime() bool {
206
+ if s == nil {
207
+ return false
208
+ }
209
+ if s.eslintRuntime {
210
+ return true
211
+ }
212
+ base := filepath.Base(s.externalConfigPath)
213
+ return strings.HasPrefix(base, "eslint.config.")
214
+ }
215
+
216
+ func (s *ConfigStore) RequiresESLintRuntime() bool {
217
+ if s == nil {
218
+ return false
219
+ }
220
+ return s.eslintRuntimeRequired
221
+ }
222
+
223
+ func (e ConfigEntry) matchesFile(fileName string) bool {
224
+ if len(e.Files) > 0 && !matchAnyPattern(e.BaseDir, e.Files, fileName) {
225
+ return false
226
+ }
227
+ if e.matchesIgnores(fileName) {
228
+ return false
229
+ }
230
+ return true
231
+ }
232
+
233
+ func (e ConfigEntry) matchesIgnores(fileName string) bool {
234
+ return len(e.Ignores) > 0 && matchAnyPattern(e.BaseDir, e.Ignores, fileName)
235
+ }
236
+
237
+ // ParseRules normalizes the standard native rules map from a tsconfig plugin
238
+ // entry.
75
239
  //
76
240
  // Severity values:
77
241
  // - `"off"` → SeverityOff
@@ -99,11 +263,257 @@ func ParseRules(raw any) (RuleConfig, error) {
99
263
  return out, nil
100
264
  }
101
265
 
266
+ func parseExternalConfigRules(raw any) (RuleConfig, error) {
267
+ store, err := parseExternalConfigStore(raw, "")
268
+ if err != nil {
269
+ return nil, err
270
+ }
271
+ return store.Flatten(), nil
272
+ }
273
+
274
+ func parseExternalConfigStore(raw any, configDir string) (*ConfigStore, error) {
275
+ return parseExternalConfigStoreWithRuntimeMode(raw, configDir, false)
276
+ }
277
+
278
+ func parseExternalConfigStoreForFile(raw any, configDir string) (*ConfigStore, error) {
279
+ return parseExternalConfigStoreWithRuntimeMode(raw, configDir, true)
280
+ }
281
+
282
+ func parseExternalConfigStoreWithRuntimeMode(raw any, configDir string, allowRuntimeOnly bool) (*ConfigStore, error) {
283
+ store := &ConfigStore{}
284
+ if err := collectExternalConfigEntries(store, raw, configDir, "config", allowRuntimeOnly); err != nil {
285
+ return nil, err
286
+ }
287
+ return store, nil
288
+ }
289
+
290
+ func collectExternalConfigEntries(store *ConfigStore, raw any, baseDir, path string, allowRuntimeOnly bool) error {
291
+ if raw == nil {
292
+ return nil
293
+ }
294
+ switch typed := raw.(type) {
295
+ case []any:
296
+ for i, item := range typed {
297
+ if err := collectExternalConfigEntries(store, item, baseDir, fmt.Sprintf("%s[%d]", path, i), allowRuntimeOnly); err != nil {
298
+ return err
299
+ }
300
+ }
301
+ return nil
302
+ case map[string]any:
303
+ if isESLintConfigObject(typed) {
304
+ if marker, ok := typed["__ttscLintEslintRuntime"].(bool); ok && marker {
305
+ store.eslintRuntime = true
306
+ store.eslintRuntimeRequired = true
307
+ }
308
+ localBaseDir := baseDir
309
+ if rawBasePath, ok := typed["basePath"]; ok {
310
+ basePath, ok := rawBasePath.(string)
311
+ if !ok {
312
+ return fmt.Errorf("@ttsc/lint: %s.basePath must be a string, got %T", path, rawBasePath)
313
+ }
314
+ if filepath.IsAbs(basePath) {
315
+ localBaseDir = basePath
316
+ } else {
317
+ localBaseDir = filepath.Join(baseDir, basePath)
318
+ }
319
+ }
320
+ if hasESLintRuntimeFields(typed) {
321
+ store.eslintRuntime = true
322
+ store.eslintRuntimeRequired = true
323
+ }
324
+ if extended, ok := typed["extends"]; ok {
325
+ if err := collectExternalExtends(store, extended, localBaseDir, path+".extends", allowRuntimeOnly); err != nil {
326
+ return err
327
+ }
328
+ }
329
+ files, err := parsePatternList(typed["files"], path+".files")
330
+ if err != nil {
331
+ return err
332
+ }
333
+ ignores, err := parsePatternList(typed["ignores"], path+".ignores")
334
+ if err != nil {
335
+ return err
336
+ }
337
+ if rules, ok := typed["rules"]; ok {
338
+ parsed, err := parseExternalRuleMap(rules, path+".rules")
339
+ if err != nil {
340
+ return err
341
+ }
342
+ store.entries = append(store.entries, ConfigEntry{
343
+ BaseDir: localBaseDir,
344
+ Files: files,
345
+ Ignores: ignores,
346
+ Rules: parsed,
347
+ })
348
+ return nil
349
+ }
350
+ if len(files) == 0 && len(ignores) > 0 {
351
+ store.entries = append(store.entries, ConfigEntry{
352
+ BaseDir: localBaseDir,
353
+ Ignores: ignores,
354
+ IgnoreOnly: true,
355
+ })
356
+ }
357
+ return nil
358
+ }
359
+ parsed, err := parseExternalRuleMap(typed, path)
360
+ if err != nil {
361
+ return err
362
+ }
363
+ store.entries = append(store.entries, ConfigEntry{
364
+ BaseDir: baseDir,
365
+ Rules: parsed,
366
+ })
367
+ return nil
368
+ default:
369
+ return fmt.Errorf("@ttsc/lint: %s must be an object or flat config array, got %T", path, raw)
370
+ }
371
+ }
372
+
373
+ func collectExternalExtends(store *ConfigStore, raw any, baseDir, path string, allowRuntimeOnly bool) error {
374
+ switch typed := raw.(type) {
375
+ case string:
376
+ if allowRuntimeOnly {
377
+ store.eslintRuntime = true
378
+ store.eslintRuntimeRequired = true
379
+ return nil
380
+ }
381
+ return fmt.Errorf("@ttsc/lint: %s must be an object or flat config array, got %T", path, raw)
382
+ case []any:
383
+ for i, item := range typed {
384
+ itemPath := fmt.Sprintf("%s[%d]", path, i)
385
+ if _, ok := item.(string); ok && allowRuntimeOnly {
386
+ store.eslintRuntime = true
387
+ store.eslintRuntimeRequired = true
388
+ continue
389
+ }
390
+ if err := collectExternalConfigEntries(store, item, baseDir, itemPath, allowRuntimeOnly); err != nil {
391
+ return err
392
+ }
393
+ }
394
+ return nil
395
+ default:
396
+ return collectExternalConfigEntries(store, raw, baseDir, path, allowRuntimeOnly)
397
+ }
398
+ }
399
+
400
+ func parseExternalRuleMap(raw any, path string) (RuleConfig, error) {
401
+ out := RuleConfig{}
402
+ if err := collectExternalRuleMap(out, raw, path); err != nil {
403
+ return nil, err
404
+ }
405
+ return out, nil
406
+ }
407
+
408
+ func collectExternalRuleMap(out RuleConfig, raw any, path string) error {
409
+ dict, ok := raw.(map[string]any)
410
+ if !ok {
411
+ return fmt.Errorf("@ttsc/lint: %s must be a rules object, got %T", path, raw)
412
+ }
413
+ for name, value := range dict {
414
+ sev, err := parseExternalSeverity(value)
415
+ if err != nil {
416
+ return fmt.Errorf("@ttsc/lint: rule %q: %w", name, err)
417
+ }
418
+ out[normalizeExternalRuleName(name)] = sev
419
+ }
420
+ return nil
421
+ }
422
+
423
+ func isESLintConfigObject(value map[string]any) bool {
424
+ for _, key := range []string{
425
+ "basePath",
426
+ "extends",
427
+ "files",
428
+ "ignores",
429
+ "languageOptions",
430
+ "linterOptions",
431
+ "name",
432
+ "plugins",
433
+ "processor",
434
+ "rules",
435
+ "settings",
436
+ "__ttscLintEslintRuntime",
437
+ } {
438
+ if _, ok := value[key]; ok {
439
+ return true
440
+ }
441
+ }
442
+ return false
443
+ }
444
+
445
+ func hasESLintRuntimeFields(value map[string]any) bool {
446
+ for _, key := range []string{
447
+ "languageOptions",
448
+ "linterOptions",
449
+ "plugins",
450
+ "processor",
451
+ "settings",
452
+ } {
453
+ if _, ok := value[key]; ok {
454
+ return true
455
+ }
456
+ }
457
+ return false
458
+ }
459
+
460
+ func normalizeExternalRuleName(name string) string {
461
+ name = strings.TrimPrefix(name, "@typescript-eslint/")
462
+ return strings.TrimPrefix(name, "typescript-eslint/")
463
+ }
464
+
465
+ func parsePatternList(raw any, path string) ([]string, error) {
466
+ if raw == nil {
467
+ return nil, nil
468
+ }
469
+ switch typed := raw.(type) {
470
+ case string:
471
+ if strings.TrimSpace(typed) == "" {
472
+ return nil, fmt.Errorf("@ttsc/lint: %s must not contain an empty pattern", path)
473
+ }
474
+ return []string{typed}, nil
475
+ case []any:
476
+ out := make([]string, 0, len(typed))
477
+ for i, item := range typed {
478
+ pattern, ok := item.(string)
479
+ if !ok {
480
+ return nil, fmt.Errorf("@ttsc/lint: %s[%d] must be a string, got %T", path, i, item)
481
+ }
482
+ if strings.TrimSpace(pattern) == "" {
483
+ return nil, fmt.Errorf("@ttsc/lint: %s[%d] must not be empty", path, i)
484
+ }
485
+ out = append(out, pattern)
486
+ }
487
+ return out, nil
488
+ default:
489
+ return nil, fmt.Errorf("@ttsc/lint: %s must be a string or string array, got %T", path, raw)
490
+ }
491
+ }
492
+
102
493
  // LoadRuleConfig resolves the lint config for one plugin entry. The only
103
494
  // accepted lint-specific tsconfig key is `config`; it may be either an inline
104
495
  // rules object or a string path to a standalone config file. Relative config
105
496
  // paths are resolved from the tsconfig directory.
106
497
  func LoadRuleConfig(entry *PluginEntry, cwd, tsconfigPath string) (RuleConfig, error) {
498
+ resolver, err := LoadConfigResolver(entry, cwd, tsconfigPath)
499
+ if err != nil {
500
+ return nil, err
501
+ }
502
+ switch typed := resolver.(type) {
503
+ case RuleConfig:
504
+ return typed, nil
505
+ case *ConfigStore:
506
+ return typed.Flatten(), nil
507
+ default:
508
+ return resolver.EnabledRuleConfig(), nil
509
+ }
510
+ }
511
+
512
+ // LoadConfigResolver resolves one plugin entry into the engine-facing config
513
+ // model. Inline `config` remains the native flat rule map. External config
514
+ // files may carry ESLint flat-config-style file globs, ignores, object
515
+ // extends, and rule severity tuples.
516
+ func LoadConfigResolver(entry *PluginEntry, cwd, tsconfigPath string) (RuleResolver, error) {
107
517
  if entry == nil {
108
518
  return RuleConfig{}, nil
109
519
  }
@@ -119,18 +529,22 @@ func LoadRuleConfig(entry *PluginEntry, cwd, tsconfigPath string) (RuleConfig, e
119
529
 
120
530
  value, ok := inline["config"]
121
531
  if !ok {
122
- return RuleConfig{}, nil
532
+ discovered, err := findESLintConfigFile(cwd, tsconfigPath)
533
+ if err != nil {
534
+ return nil, err
535
+ }
536
+ if discovered == "" {
537
+ return RuleConfig{}, nil
538
+ }
539
+ return loadExternalConfigResolver(discovered)
123
540
  }
124
541
  switch typed := value.(type) {
125
542
  case string:
126
543
  if strings.TrimSpace(typed) == "" {
127
544
  return nil, fmt.Errorf("@ttsc/lint: \"config\" must not be empty")
128
545
  }
129
- rules, err := loadConfigFile(resolveConfigFilePath(typed, cwd, tsconfigPath))
130
- if err != nil {
131
- return nil, err
132
- }
133
- return ParseRules(rules)
546
+ location := resolveConfigFilePath(typed, cwd, tsconfigPath)
547
+ return loadExternalConfigResolver(location)
134
548
  case map[string]any:
135
549
  return ParseRules(typed)
136
550
  default:
@@ -138,6 +552,57 @@ func LoadRuleConfig(entry *PluginEntry, cwd, tsconfigPath string) (RuleConfig, e
138
552
  }
139
553
  }
140
554
 
555
+ func loadExternalConfigResolver(location string) (RuleResolver, error) {
556
+ raw, err := loadConfigFile(location)
557
+ if err != nil {
558
+ return nil, err
559
+ }
560
+ store, err := parseExternalConfigStoreForFile(raw, filepath.Dir(location))
561
+ if err != nil {
562
+ return nil, err
563
+ }
564
+ store.externalConfigPath = location
565
+ return store, nil
566
+ }
567
+
568
+ func findESLintConfigFile(cwd, tsconfigPath string) (string, error) {
569
+ dir := cwd
570
+ if tsconfigPath != "" {
571
+ resolvedTsconfig := tsconfigPath
572
+ if !filepath.IsAbs(resolvedTsconfig) {
573
+ resolvedTsconfig = filepath.Join(cwd, resolvedTsconfig)
574
+ }
575
+ dir = filepath.Dir(resolvedTsconfig)
576
+ }
577
+ for {
578
+ matches := make([]string, 0, 1)
579
+ for _, name := range []string{
580
+ "eslint.config.js",
581
+ "eslint.config.mjs",
582
+ "eslint.config.cjs",
583
+ "eslint.config.ts",
584
+ "eslint.config.mts",
585
+ "eslint.config.cts",
586
+ } {
587
+ candidate := filepath.Join(dir, name)
588
+ if stat, err := os.Stat(candidate); err == nil && !stat.IsDir() {
589
+ matches = append(matches, candidate)
590
+ }
591
+ }
592
+ if len(matches) > 1 {
593
+ return "", fmt.Errorf("@ttsc/lint: multiple eslint config files found in %s; set \"config\" explicitly", dir)
594
+ }
595
+ if len(matches) == 1 {
596
+ return matches[0], nil
597
+ }
598
+ parent := filepath.Dir(dir)
599
+ if parent == dir {
600
+ return "", nil
601
+ }
602
+ dir = parent
603
+ }
604
+ }
605
+
141
606
  func resolveConfigFilePath(configPath, cwd, tsconfigPath string) string {
142
607
  if filepath.IsAbs(configPath) {
143
608
  return configPath
@@ -153,7 +618,7 @@ func resolveConfigFilePath(configPath, cwd, tsconfigPath string) string {
153
618
  return filepath.Join(base, configPath)
154
619
  }
155
620
 
156
- func loadConfigFile(location string) (map[string]any, error) {
621
+ func loadConfigFile(location string) (any, error) {
157
622
  ext := strings.ToLower(filepath.Ext(location))
158
623
  switch ext {
159
624
  case ".json":
@@ -167,22 +632,22 @@ func loadConfigFile(location string) (map[string]any, error) {
167
632
  }
168
633
  }
169
634
 
170
- func loadJSONConfigFile(location string) (map[string]any, error) {
635
+ func loadJSONConfigFile(location string) (any, error) {
171
636
  body, err := os.ReadFile(location)
172
637
  if err != nil {
173
638
  return nil, fmt.Errorf("@ttsc/lint: read config file %s: %w", location, err)
174
639
  }
175
- var out map[string]any
640
+ var out any
176
641
  if err := json.Unmarshal(body, &out); err != nil {
177
642
  return nil, fmt.Errorf("@ttsc/lint: parse config file %s: %w", location, err)
178
643
  }
179
- if out == nil {
180
- return nil, fmt.Errorf("@ttsc/lint: config file %s must export an object", location)
644
+ if !isConfigContainer(out) {
645
+ return nil, fmt.Errorf("@ttsc/lint: config file %s must export an object or flat config array", location)
181
646
  }
182
647
  return out, nil
183
648
  }
184
649
 
185
- func loadScriptConfigFile(location string) (map[string]any, error) {
650
+ func loadScriptConfigFile(location string) (any, error) {
186
651
  const script = `
187
652
  const { pathToFileURL } = require("node:url");
188
653
 
@@ -190,14 +655,94 @@ const { pathToFileURL } = require("node:url");
190
655
  const mod = await import(pathToFileURL(process.argv[1]).href);
191
656
  const candidate = mod.default ?? mod.config ?? mod;
192
657
  const value = typeof candidate === "function" ? await candidate() : candidate;
193
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
194
- throw new Error("config file must export an object");
658
+ if (value === null || typeof value !== "object") {
659
+ throw new Error("config file must export an object or flat config array");
195
660
  }
196
- process.stdout.write(JSON.stringify(value));
661
+ process.stdout.write(JSON.stringify(toSerializableConfig(value)));
197
662
  })().catch((error) => {
198
663
  process.stderr.write(error && error.stack ? error.stack : String(error));
199
664
  process.exit(1);
200
665
  });
666
+
667
+ function toSerializableConfig(value) {
668
+ if (Array.isArray(value)) {
669
+ return value.map((item) => toSerializableConfig(item));
670
+ }
671
+ if (value === null || typeof value !== "object") {
672
+ return value;
673
+ }
674
+ if (isESLintConfigObject(value)) {
675
+ const out = {};
676
+ if (hasESLintRuntimeFields(value)) {
677
+ out.__ttscLintEslintRuntime = true;
678
+ }
679
+ if (Object.prototype.hasOwnProperty.call(value, "basePath")) {
680
+ out.basePath = value.basePath;
681
+ }
682
+ if (Object.prototype.hasOwnProperty.call(value, "extends")) {
683
+ out.extends = toSerializableConfig(value.extends);
684
+ }
685
+ if (Object.prototype.hasOwnProperty.call(value, "files")) {
686
+ out.files = toSerializablePatterns(value.files, "files");
687
+ }
688
+ if (Object.prototype.hasOwnProperty.call(value, "ignores")) {
689
+ out.ignores = toSerializablePatterns(value.ignores, "ignores");
690
+ }
691
+ if (Object.prototype.hasOwnProperty.call(value, "rules")) {
692
+ out.rules = toSerializableRules(value.rules);
693
+ }
694
+ return out;
695
+ }
696
+ return { rules: toSerializableRules(value) };
697
+ }
698
+
699
+ function toSerializableRules(value) {
700
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
701
+ throw new Error("rules must be an object");
702
+ }
703
+ return Object.fromEntries(Object.entries(value));
704
+ }
705
+
706
+ function toSerializablePatterns(value, key) {
707
+ if (typeof value === "string") {
708
+ return value;
709
+ }
710
+ if (Array.isArray(value)) {
711
+ return value.map((item, index) => {
712
+ if (typeof item !== "string") {
713
+ throw new Error(key + "[" + index + "] must be a string");
714
+ }
715
+ return item;
716
+ });
717
+ }
718
+ throw new Error(key + " must be a string or string array");
719
+ }
720
+
721
+ function isESLintConfigObject(value) {
722
+ return [
723
+ "basePath",
724
+ "extends",
725
+ "files",
726
+ "ignores",
727
+ "languageOptions",
728
+ "linterOptions",
729
+ "name",
730
+ "plugins",
731
+ "processor",
732
+ "rules",
733
+ "settings",
734
+ ].some((key) => Object.prototype.hasOwnProperty.call(value, key));
735
+ }
736
+
737
+ function hasESLintRuntimeFields(value) {
738
+ return [
739
+ "languageOptions",
740
+ "linterOptions",
741
+ "plugins",
742
+ "processor",
743
+ "settings",
744
+ ].some((key) => Object.prototype.hasOwnProperty.call(value, key));
745
+ }
201
746
  `
202
747
  node := os.Getenv("TTSC_NODE_BINARY")
203
748
  if node == "" {
@@ -215,17 +760,17 @@ const { pathToFileURL } = require("node:url");
215
760
  }
216
761
  return nil, fmt.Errorf("@ttsc/lint: load config file %s: %w", location, err)
217
762
  }
218
- var out map[string]any
763
+ var out any
219
764
  if err := json.Unmarshal(output, &out); err != nil {
220
765
  return nil, fmt.Errorf("@ttsc/lint: parse config file %s output: %w", location, err)
221
766
  }
222
- if out == nil {
223
- return nil, fmt.Errorf("@ttsc/lint: config file %s must export an object", location)
767
+ if !isConfigContainer(out) {
768
+ return nil, fmt.Errorf("@ttsc/lint: config file %s must export an object or flat config array", location)
224
769
  }
225
770
  return out, nil
226
771
  }
227
772
 
228
- func loadTypeScriptConfigFile(location string) (map[string]any, error) {
773
+ func loadTypeScriptConfigFile(location string) (any, error) {
229
774
  tempDir, err := os.MkdirTemp("", "ttsc-lint-config-")
230
775
  if err != nil {
231
776
  return nil, fmt.Errorf("@ttsc/lint: create config loader tempdir: %w", err)
@@ -276,16 +821,25 @@ func loadTypeScriptConfigFile(location string) (map[string]any, error) {
276
821
  }
277
822
  return nil, fmt.Errorf("@ttsc/lint: load TypeScript config file %s: %w", location, err)
278
823
  }
279
- var out map[string]any
824
+ var out any
280
825
  if err := json.Unmarshal(output, &out); err != nil {
281
826
  return nil, fmt.Errorf("@ttsc/lint: parse TypeScript config file %s output: %w", location, err)
282
827
  }
283
- if out == nil {
284
- return nil, fmt.Errorf("@ttsc/lint: config file %s must export an object", location)
828
+ if !isConfigContainer(out) {
829
+ return nil, fmt.Errorf("@ttsc/lint: config file %s must export an object or flat config array", location)
285
830
  }
286
831
  return out, nil
287
832
  }
288
833
 
834
+ func isConfigContainer(value any) bool {
835
+ switch value.(type) {
836
+ case []any, map[string]any:
837
+ return true
838
+ default:
839
+ return false
840
+ }
841
+ }
842
+
289
843
  func relativeImportSpecifier(fromDir, location string) (string, error) {
290
844
  relative, err := filepath.Rel(fromDir, location)
291
845
  if err != nil {
@@ -309,10 +863,10 @@ declare const process: {
309
863
 
310
864
  try {
311
865
  const value = await resolveConfig(importedConfig, true);
312
- if (!isObject(value) || Array.isArray(value)) {
313
- throw new Error("config file must export an object");
866
+ if (!isObject(value)) {
867
+ throw new Error("config file must export an object or flat config array");
314
868
  }
315
- process.stdout.write(JSON.stringify(value));
869
+ process.stdout.write(JSON.stringify(toSerializableConfig(value)));
316
870
  } catch (error) {
317
871
  process.stderr.write(error instanceof Error && error.stack ? error.stack : String(error));
318
872
  process.exit(1);
@@ -346,6 +900,86 @@ function isObject(value: unknown): value is Record<string, unknown> {
346
900
  function hasOwn(value: Record<string, unknown>, key: string): boolean {
347
901
  return Object.prototype.hasOwnProperty.call(value, key);
348
902
  }
903
+
904
+ function toSerializableConfig(value: unknown): unknown {
905
+ if (Array.isArray(value)) {
906
+ return value.map((item) => toSerializableConfig(item));
907
+ }
908
+ if (!isObject(value)) {
909
+ return value;
910
+ }
911
+ if (isESLintConfigObject(value)) {
912
+ const out: Record<string, unknown> = {};
913
+ if (hasESLintRuntimeFields(value)) {
914
+ out.__ttscLintEslintRuntime = true;
915
+ }
916
+ if (hasOwn(value, "basePath")) {
917
+ out.basePath = value.basePath;
918
+ }
919
+ if (hasOwn(value, "extends")) {
920
+ out.extends = toSerializableConfig(value.extends);
921
+ }
922
+ if (hasOwn(value, "files")) {
923
+ out.files = toSerializablePatterns(value.files, "files");
924
+ }
925
+ if (hasOwn(value, "ignores")) {
926
+ out.ignores = toSerializablePatterns(value.ignores, "ignores");
927
+ }
928
+ if (hasOwn(value, "rules")) {
929
+ out.rules = toSerializableRules(value.rules);
930
+ }
931
+ return out;
932
+ }
933
+ return { rules: toSerializableRules(value) };
934
+ }
935
+
936
+ function toSerializableRules(value: unknown): Record<string, unknown> {
937
+ if (!isObject(value) || Array.isArray(value)) {
938
+ throw new Error("rules must be an object");
939
+ }
940
+ return Object.fromEntries(Object.entries(value));
941
+ }
942
+
943
+ function toSerializablePatterns(value: unknown, key: string): string | string[] {
944
+ if (typeof value === "string") {
945
+ return value;
946
+ }
947
+ if (Array.isArray(value)) {
948
+ return value.map((item, index) => {
949
+ if (typeof item !== "string") {
950
+ throw new Error(key + "[" + index + "] must be a string");
951
+ }
952
+ return item;
953
+ });
954
+ }
955
+ throw new Error(key + " must be a string or string array");
956
+ }
957
+
958
+ function isESLintConfigObject(value: Record<string, unknown>): boolean {
959
+ return [
960
+ "basePath",
961
+ "extends",
962
+ "files",
963
+ "ignores",
964
+ "languageOptions",
965
+ "linterOptions",
966
+ "name",
967
+ "plugins",
968
+ "processor",
969
+ "rules",
970
+ "settings",
971
+ ].some((key) => hasOwn(value, key));
972
+ }
973
+
974
+ function hasESLintRuntimeFields(value: Record<string, unknown>): boolean {
975
+ return [
976
+ "languageOptions",
977
+ "linterOptions",
978
+ "plugins",
979
+ "processor",
980
+ "settings",
981
+ ].some((key) => hasOwn(value, key));
982
+ }
349
983
  `, importLiteral)
350
984
  }
351
985
 
@@ -451,6 +1085,16 @@ func setEnv(env []string, key, value string) []string {
451
1085
  return append(env, prefix+value)
452
1086
  }
453
1087
 
1088
+ func parseExternalSeverity(v any) (Severity, error) {
1089
+ if tuple, ok := v.([]any); ok {
1090
+ if len(tuple) == 0 {
1091
+ return SeverityOff, fmt.Errorf("severity tuple must not be empty")
1092
+ }
1093
+ return parseSeverity(tuple[0])
1094
+ }
1095
+ return parseSeverity(v)
1096
+ }
1097
+
454
1098
  func parseSeverity(v any) (Severity, error) {
455
1099
  switch x := v.(type) {
456
1100
  case string:
@@ -462,7 +1106,7 @@ func parseSeverity(v any) (Severity, error) {
462
1106
  case "error":
463
1107
  return SeverityError, nil
464
1108
  }
465
- return SeverityOff, fmt.Errorf("unknown severity %q (want off | warning | error)", x)
1109
+ return SeverityOff, fmt.Errorf("unknown severity %q (want off | warn | warning | error)", x)
466
1110
  case float64:
467
1111
  switch x {
468
1112
  case 0:
@@ -472,9 +1116,96 @@ func parseSeverity(v any) (Severity, error) {
472
1116
  case 2:
473
1117
  return SeverityError, nil
474
1118
  }
475
- return SeverityOff, fmt.Errorf("unknown severity %v (want off | warning | error)", x)
1119
+ return SeverityOff, fmt.Errorf("unknown severity %v (want 0 | 1 | 2)", x)
1120
+ }
1121
+ return SeverityOff, fmt.Errorf("severity must be one of: off | warn | warning | error | 0 | 1 | 2, got %T", v)
1122
+ }
1123
+
1124
+ func sortedRuleNames(config RuleConfig, include func(Severity) bool) []string {
1125
+ names := make([]string, 0, len(config))
1126
+ for name, sev := range config {
1127
+ if include(sev) {
1128
+ names = append(names, name)
1129
+ }
1130
+ }
1131
+ sort.Strings(names)
1132
+ return names
1133
+ }
1134
+
1135
+ func matchAnyPattern(baseDir string, patterns []string, fileName string) bool {
1136
+ rel := filepath.ToSlash(fileName)
1137
+ if baseDir != "" {
1138
+ base := baseDir
1139
+ if abs, err := filepath.Abs(base); err == nil {
1140
+ base = abs
1141
+ }
1142
+ file := fileName
1143
+ if abs, err := filepath.Abs(file); err == nil {
1144
+ file = abs
1145
+ }
1146
+ if candidate, err := filepath.Rel(base, file); err == nil {
1147
+ if candidate == ".." || strings.HasPrefix(candidate, ".."+string(filepath.Separator)) {
1148
+ return false
1149
+ }
1150
+ rel = filepath.ToSlash(candidate)
1151
+ }
1152
+ }
1153
+ rel = strings.TrimPrefix(rel, "./")
1154
+ for _, pattern := range patterns {
1155
+ if matchGlob(normalizeGlobPattern(pattern), rel) {
1156
+ return true
1157
+ }
1158
+ }
1159
+ return false
1160
+ }
1161
+
1162
+ func normalizeGlobPattern(pattern string) string {
1163
+ pattern = filepath.ToSlash(pattern)
1164
+ pattern = strings.TrimPrefix(pattern, "./")
1165
+ if !strings.Contains(pattern, "/") {
1166
+ return "**/" + pattern
1167
+ }
1168
+ return pattern
1169
+ }
1170
+
1171
+ func matchGlob(pattern, name string) bool {
1172
+ pattern = strings.Trim(pattern, "/")
1173
+ name = strings.Trim(name, "/")
1174
+ if pattern == "" {
1175
+ return name == ""
1176
+ }
1177
+ patternParts := strings.Split(pattern, "/")
1178
+ nameParts := []string{}
1179
+ if name != "" {
1180
+ nameParts = strings.Split(name, "/")
1181
+ }
1182
+ return matchGlobParts(patternParts, nameParts)
1183
+ }
1184
+
1185
+ func matchGlobParts(patternParts, nameParts []string) bool {
1186
+ if len(patternParts) == 0 {
1187
+ return len(nameParts) == 0
1188
+ }
1189
+ head := patternParts[0]
1190
+ if head == "**" {
1191
+ if matchGlobParts(patternParts[1:], nameParts) {
1192
+ return true
1193
+ }
1194
+ for i := range nameParts {
1195
+ if matchGlobParts(patternParts[1:], nameParts[i+1:]) {
1196
+ return true
1197
+ }
1198
+ }
1199
+ return false
1200
+ }
1201
+ if len(nameParts) == 0 {
1202
+ return false
1203
+ }
1204
+ ok, err := filepath.Match(head, nameParts[0])
1205
+ if err != nil || !ok {
1206
+ return false
476
1207
  }
477
- return SeverityOff, fmt.Errorf("severity must be one of: off | warning | error, got %T", v)
1208
+ return matchGlobParts(patternParts[1:], nameParts[1:])
478
1209
  }
479
1210
 
480
1211
  // Severity returns the configured level for a rule, defaulting to