@ttsc/lint 0.12.4 → 0.13.1

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 (37) hide show
  1. package/lib/index.js +225 -135
  2. package/lib/index.js.map +1 -1
  3. package/lib/structures/ITtscLintPluginConfig.d.ts +10 -77
  4. package/lib/structures/TtscLintRuleOptions.d.ts +14 -0
  5. package/linthost/ast_helpers.go +68 -16
  6. package/linthost/compile.go +117 -119
  7. package/linthost/config.go +518 -707
  8. package/linthost/config_format.go +16 -4
  9. package/linthost/contrib_adapter.go +7 -0
  10. package/linthost/directives.go +44 -0
  11. package/linthost/engine.go +152 -44
  12. package/linthost/fix.go +24 -33
  13. package/linthost/flags_gen.go +33 -0
  14. package/linthost/format.go +96 -3
  15. package/linthost/host.go +144 -8
  16. package/linthost/print_dispatch.go +121 -23
  17. package/linthost/print_doc.go +19 -0
  18. package/linthost/print_engine.go +168 -4
  19. package/linthost/print_nodes_array.go +17 -7
  20. package/linthost/print_nodes_call.go +129 -20
  21. package/linthost/print_nodes_function.go +353 -0
  22. package/linthost/print_nodes_imports.go +46 -29
  23. package/linthost/print_nodes_list.go +86 -5
  24. package/linthost/print_nodes_object.go +56 -11
  25. package/linthost/rules_escape.go +20 -3
  26. package/linthost/rules_format_print_width.go +267 -15
  27. package/linthost/rules_gap.go +55 -3
  28. package/linthost/rules_logic.go +64 -5
  29. package/linthost/rules_problems.go +65 -21
  30. package/linthost/rules_promise.go +3 -0
  31. package/linthost/rules_suggestions.go +160 -5
  32. package/linthost/rules_var.go +7 -1
  33. package/package.json +3 -3
  34. package/src/index.ts +243 -168
  35. package/src/structures/ITtscLintPluginConfig.ts +10 -83
  36. package/src/structures/TtscLintRuleOptions.ts +15 -0
  37. package/linthost/eslint_runtime.go +0 -351
@@ -3,6 +3,8 @@ package linthost
3
3
  import (
4
4
  "bytes"
5
5
  "context"
6
+ "crypto/sha256"
7
+ "encoding/hex"
6
8
  "encoding/json"
7
9
  "fmt"
8
10
  "os"
@@ -85,23 +87,24 @@ type RuleConfig map[string]Severity
85
87
 
86
88
  // RuleOptionsMap captures the rule-specific options blob, keyed by rule
87
89
  // 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.
90
+ // JSON the user wrote in the second tuple slot of a `["error", { ... }]`
91
+ // rule setting; each rule decodes the blob into its own option struct on
92
+ // demand.
91
93
  type RuleOptionsMap map[string]json.RawMessage
92
94
 
93
95
  // ResolvedRuleConfig is the rule map that applies to one source file.
94
- // `Ignored` means an external ESLint-style ignore-only config matched the
95
- // file and the engine should skip linting it entirely.
96
+ // `Ignored` means an `ignores`-only config entry matched the file and the
97
+ // engine should skip linting it entirely.
96
98
  type ResolvedRuleConfig struct {
97
99
  Rules RuleConfig
98
100
  Ignored bool
99
101
  }
100
102
 
101
103
  // RuleResolver is the engine-facing view of a resolved lint configuration.
102
- // Implementations include RuleConfig (severity-only, no options), InlineRuleResolver
103
- // (tsconfig inline rules + optional per-rule options), and *ConfigStore (external
104
- // flat-config file, with per-file glob resolution and a unified options map).
104
+ // Implementations include RuleConfig (severity-only, no options),
105
+ // InlineRuleResolver (a severity map plus per-rule options), and *ConfigStore
106
+ // (a parsed lint config file, with per-file glob resolution and a unified
107
+ // options map).
105
108
  type RuleResolver interface {
106
109
  // ResolveRules returns the effective severity map for the given source file.
107
110
  // Implementations that support `files`/`ignores` patterns apply them here;
@@ -144,13 +147,13 @@ func (c RuleConfig) EnabledRuleConfig() RuleConfig {
144
147
  }
145
148
 
146
149
  // RuleOptions on a bare RuleConfig is always nil — this form is the
147
- // severity-only path used by Go unit tests and the legacy inline-rules
148
- // surface that predates option support.
150
+ // severity-only path used by Go unit tests and rule constructors that
151
+ // predate option support.
149
152
  func (RuleConfig) RuleOptions(string) json.RawMessage { return nil }
150
153
 
151
- // InlineRuleResolver pairs a severity map with an options map for
152
- // tsconfig-inline rule blocks. The fields are public so tests can
153
- // construct one without going through ParseRulesWithOptions.
154
+ // InlineRuleResolver pairs a severity map with an options map. The fields
155
+ // are public so tests can construct one without going through
156
+ // ParseRulesWithOptions.
154
157
  type InlineRuleResolver struct {
155
158
  Rules RuleConfig
156
159
  Options RuleOptionsMap
@@ -182,16 +185,18 @@ func (r InlineRuleResolver) RuleOptions(name string) json.RawMessage {
182
185
  return r.Options[name]
183
186
  }
184
187
 
185
- // ConfigStore holds the parsed representation of an external flat-config file.
186
- // It implements RuleResolver with per-file glob scoping: ResolveRules walks the
188
+ // ConfigStore holds the parsed representation of a lint config file. It
189
+ // implements RuleResolver with per-file glob scoping: ResolveRules walks the
187
190
  // entries in declaration order and the last matching entry wins. Options are
188
191
  // intentionally NOT per-file — one project-wide options map is kept so rule
189
192
  // behavior is uniform across the codebase even when severity varies by glob.
193
+ //
194
+ // A config file is a single `ITtscLintConfig` object. Its `extends` field
195
+ // names another config file to fold in first; the extends chain produces one
196
+ // ConfigEntry per file, the extends-target entries declared before the
197
+ // extending file's own entry so local rules win on collision.
190
198
  type ConfigStore struct {
191
- entries []ConfigEntry
192
- externalConfigPath string
193
- eslintRuntime bool
194
- eslintRuntimeRequired bool
199
+ entries []ConfigEntry
195
200
  // options is a flat rule-name → JSON map. Options are not scoped by
196
201
  // `files` / `ignores`: a rule's behavior is a single project-wide
197
202
  // configuration even when its severity is per-file. The simplification
@@ -208,10 +213,11 @@ func (s *ConfigStore) RuleOptions(name string) json.RawMessage {
208
213
  return s.options[name]
209
214
  }
210
215
 
211
- // ConfigEntry is one block of a flat-config array. BaseDir anchors glob
212
- // resolution; Files and Ignores are the ESLint-style pattern lists. IgnoreOnly
213
- // marks entries that carry only `ignores` (no `files`, no `rules`) — these
214
- // are evaluated first in ResolveRules and short-circuit the walk when matched.
216
+ // ConfigEntry is the parsed form of one config file in the extends chain.
217
+ // BaseDir anchors glob resolution; Files and Ignores are the pattern lists.
218
+ // IgnoreOnly marks entries that carry only `ignores` (no `files`, no `rules`)
219
+ // — these are evaluated first in ResolveRules and short-circuit the walk when
220
+ // matched.
215
221
  type ConfigEntry struct {
216
222
  BaseDir string
217
223
  Files []string
@@ -292,9 +298,8 @@ func (s *ConfigStore) EnabledRuleConfig() RuleConfig {
292
298
  }
293
299
 
294
300
  // Flatten returns the unconstrained union of all non-ignore-only entries,
295
- // including SeverityOff rules. Used by LoadRuleConfig (legacy callers that
296
- // expect a plain RuleConfig) and by parseExternalConfigRules. Later entries
297
- // shadow earlier ones for the same rule name.
301
+ // including SeverityOff rules. Used by LoadRuleConfig (callers that expect a
302
+ // plain RuleConfig). Later entries shadow earlier ones for the same rule name.
298
303
  func (s *ConfigStore) Flatten() RuleConfig {
299
304
  out := RuleConfig{}
300
305
  if s == nil {
@@ -311,43 +316,6 @@ func (s *ConfigStore) Flatten() RuleConfig {
311
316
  return out
312
317
  }
313
318
 
314
- // ExternalConfigPath returns the absolute path of the config file that was
315
- // loaded into this store, or the empty string for stores built from inline
316
- // tsconfig plugin entries.
317
- func (s *ConfigStore) ExternalConfigPath() string {
318
- if s == nil {
319
- return ""
320
- }
321
- return s.externalConfigPath
322
- }
323
-
324
- // WantsESLintRuntime reports whether the config uses any ESLint-native feature
325
- // (languageOptions, linterOptions, processor, settings, or a non-native plugin
326
- // map) OR whether the config file itself is an eslint.config.* file. The latter
327
- // check covers pure-ttsc ESLint-named configs that happen not to use runtime
328
- // fields.
329
- func (s *ConfigStore) WantsESLintRuntime() bool {
330
- if s == nil {
331
- return false
332
- }
333
- if s.eslintRuntime {
334
- return true
335
- }
336
- base := filepath.Base(s.externalConfigPath)
337
- return strings.HasPrefix(base, "eslint.config.")
338
- }
339
-
340
- // RequiresESLintRuntime reports whether the config contains fields that cannot
341
- // be evaluated without the JS ESLint runtime (e.g. non-native plugins, runtime
342
- // language options). Unlike WantsESLintRuntime it does NOT fire for the
343
- // eslint.config.* naming convention alone.
344
- func (s *ConfigStore) RequiresESLintRuntime() bool {
345
- if s == nil {
346
- return false
347
- }
348
- return s.eslintRuntimeRequired
349
- }
350
-
351
319
  func (e ConfigEntry) matchesFile(fileName string) bool {
352
320
  if len(e.Files) > 0 && !matchAnyPattern(e.BaseDir, e.Files, fileName) {
353
321
  return false
@@ -362,8 +330,7 @@ func (e ConfigEntry) matchesIgnores(fileName string) bool {
362
330
  return len(e.Ignores) > 0 && matchAnyPattern(e.BaseDir, e.Ignores, fileName)
363
331
  }
364
332
 
365
- // ParseRules normalizes the standard native rules map from a tsconfig plugin
366
- // entry.
333
+ // ParseRules normalizes a rule severity map.
367
334
  //
368
335
  // Severity values:
369
336
  // - `"off"` → SeverityOff
@@ -387,7 +354,7 @@ func ParseRulesWithOptions(raw any) (RuleConfig, RuleOptionsMap, error) {
387
354
  }
388
355
  dict, ok := raw.(map[string]any)
389
356
  if !ok {
390
- return nil, nil, fmt.Errorf("@ttsc/lint: \"config\" must be an object, got %T", raw)
357
+ return nil, nil, fmt.Errorf("@ttsc/lint: \"rules\" must be an object, got %T", raw)
391
358
  }
392
359
  cfg := make(RuleConfig, len(dict))
393
360
  opts := make(RuleOptionsMap)
@@ -404,11 +371,10 @@ func ParseRulesWithOptions(raw any) (RuleConfig, RuleOptionsMap, error) {
404
371
  return cfg, opts, nil
405
372
  }
406
373
 
407
- // parseRuleEntry splits an ESLint-shaped rule entry into its severity
408
- // and (optional) options payload. Bare severity literals produce a nil
409
- // options blob; `[severity]` (no options) does the same; `[severity,
410
- // options]` re-serializes the options to JSON so each rule can decode it
411
- // into its own struct later.
374
+ // parseRuleEntry splits a rule entry into its severity and (optional) options
375
+ // payload. Bare severity literals produce a nil options blob; `[severity]`
376
+ // (no options) does the same; `[severity, options]` re-serializes the options
377
+ // to JSON so each rule can decode it into its own struct later.
412
378
  func parseRuleEntry(value any) (Severity, json.RawMessage, error) {
413
379
  if tuple, ok := value.([]any); ok {
414
380
  if len(tuple) == 0 {
@@ -428,10 +394,9 @@ func parseRuleEntry(value any) (Severity, json.RawMessage, error) {
428
394
  return sev, nil, nil
429
395
  }
430
396
  if _, ok := tuple[1].(map[string]any); !ok {
431
- // ESLint accepts string-typed positional options as a shorthand
432
- // (e.g. `["error", "single"]` for the `quotes` rule). ttsc does
433
- // not: every option struct in TtscLintRuleOptions is an object,
434
- // and silently encoding a non-object slot would land in
397
+ // A positional string option (e.g. `["error", "single"]`) is
398
+ // rejected: every option struct in TtscLintRuleOptions is an
399
+ // object, and silently encoding a non-object slot would land in
435
400
  // DecodeOptions as a decode error that every rule discards. Fail
436
401
  // loudly so users discover the proper `["error", { … }]` form.
437
402
  return SeverityOff, nil, fmt.Errorf("severity tuple's options slot must be an object, got %T", tuple[1])
@@ -446,9 +411,9 @@ func parseRuleEntry(value any) (Severity, json.RawMessage, error) {
446
411
  return sev, nil, err
447
412
  }
448
413
 
449
- // parseExternalConfigRules is a convenience wrapper used by legacy callers
450
- // (e.g. tests) that only need a flat RuleConfig from an already-deserialized
451
- // config value. Glob scoping and options are discarded.
414
+ // parseExternalConfigRules is a convenience wrapper used by unit tests that
415
+ // only need a flat RuleConfig from an already-deserialized config object. Glob
416
+ // scoping and options are discarded.
452
417
  func parseExternalConfigRules(raw any) (RuleConfig, error) {
453
418
  store, err := parseExternalConfigStore(raw, "")
454
419
  if err != nil {
@@ -457,174 +422,175 @@ func parseExternalConfigRules(raw any) (RuleConfig, error) {
457
422
  return store.Flatten(), nil
458
423
  }
459
424
 
460
- // parseExternalConfigStore parses `raw` without allowing runtime-only string
461
- // extends. Used by unit tests and paths that do not load from a real file.
425
+ // parseExternalConfigStore parses a single `ITtscLintConfig` object into a
426
+ // *ConfigStore. `configDir` anchors glob resolution and `extends` lookups; it
427
+ // is empty for in-memory inputs that do not load from a real file.
462
428
  func parseExternalConfigStore(raw any, configDir string) (*ConfigStore, error) {
463
- return parseExternalConfigStoreWithRuntimeMode(raw, configDir, false)
429
+ return collectConfigStore(raw, configDir, "")
464
430
  }
465
431
 
466
- // parseExternalConfigStoreForFile parses `raw` with allowRuntimeOnly=true,
467
- // which lets string-typed `extends` entries set eslintRuntimeRequired instead
468
- // of returning an error. Called by loadExternalConfigResolver after a config
469
- // file is read from disk.
470
- func parseExternalConfigStoreForFile(raw any, configDir string) (*ConfigStore, error) {
471
- return parseExternalConfigStoreWithRuntimeMode(raw, configDir, true)
472
- }
473
-
474
- // parseExternalConfigStoreWithRuntimeMode is the shared implementation for the
475
- // two public parse variants. allowRuntimeOnly controls whether a bare string
476
- // extends value is accepted (sets eslintRuntimeRequired) or rejected with an
477
- // error.
478
- func parseExternalConfigStoreWithRuntimeMode(raw any, configDir string, allowRuntimeOnly bool) (*ConfigStore, error) {
432
+ // collectConfigStore parses a single `ITtscLintConfig` object into a fresh
433
+ // *ConfigStore. `rootPath` is the absolute path of the file `raw` was loaded
434
+ // from, or "" for in-memory inputs; when set it seeds the `extends` cycle
435
+ // guard so a config that `extends` itself (directly or transitively) is
436
+ // rejected.
437
+ func collectConfigStore(raw any, configDir, rootPath string) (*ConfigStore, error) {
479
438
  store := &ConfigStore{}
480
- if err := collectExternalConfigEntries(store, raw, configDir, "config", allowRuntimeOnly); err != nil {
439
+ var chain []string
440
+ if rootPath != "" {
441
+ chain = []string{filepath.Clean(rootPath)}
442
+ }
443
+ if err := collectConfigObject(store, raw, configDir, "config", chain); err != nil {
481
444
  return nil, err
482
445
  }
483
446
  return store, nil
484
447
  }
485
448
 
486
- func collectExternalConfigEntries(store *ConfigStore, raw any, baseDir, path string, allowRuntimeOnly bool) error {
449
+ // extendsDepthLimit caps how many `extends` hops collectConfigObject will
450
+ // follow. The cycle check in appendExtendsLink already rejects every loop;
451
+ // this is a backstop so a config chain that escapes that check (e.g. a future
452
+ // change that resolves the same file under two different cleaned paths) still
453
+ // fails fast instead of spawning an unbounded run of `ttsx`/`node`
454
+ // config-loader subprocesses — one per hop.
455
+ const extendsDepthLimit = 32
456
+
457
+ // appendExtendsLink validates that following the `extends` target at `next`
458
+ // neither closes a cycle nor exceeds extendsDepthLimit, then returns `chain`
459
+ // extended by `next`. `chain` holds the resolved absolute paths of every
460
+ // config file already on the current `extends` lineage, root first. The guard
461
+ // runs before loadConfigFile so a cyclic chain fails fast instead of
462
+ // re-reading files (and re-spawning subprocesses) without bound.
463
+ func appendExtendsLink(chain []string, next string) ([]string, error) {
464
+ for i, prior := range chain {
465
+ if prior == next {
466
+ // chain[i:] capped at its own length so append allocates a fresh
467
+ // backing array rather than mutating the caller's `chain`.
468
+ cycle := append(chain[i:len(chain):len(chain)], next)
469
+ return nil, fmt.Errorf(
470
+ "@ttsc/lint: extends cycle detected: %s",
471
+ strings.Join(cycle, " -> "),
472
+ )
473
+ }
474
+ }
475
+ if len(chain) >= extendsDepthLimit {
476
+ return nil, fmt.Errorf(
477
+ "@ttsc/lint: extends chain exceeds the depth limit of %d: %s -> ...",
478
+ extendsDepthLimit,
479
+ strings.Join(chain, " -> "),
480
+ )
481
+ }
482
+ extended := make([]string, len(chain)+1)
483
+ copy(extended, chain)
484
+ extended[len(chain)] = next
485
+ return extended, nil
486
+ }
487
+
488
+ // collectConfigObject parses one `ITtscLintConfig` object into `store`,
489
+ // appending one ConfigEntry for the object's own rules (and, recursively, the
490
+ // entries of any `extends`-named config file). The extends-target's entries
491
+ // are appended first so the extending file's local rules win on collision.
492
+ //
493
+ // `chain` carries the resolved absolute paths of the config files already on
494
+ // the current `extends` lineage (root first); appendExtendsLink consults it to
495
+ // reject cyclic or pathologically deep chains before another file is read.
496
+ func collectConfigObject(store *ConfigStore, raw any, baseDir, path string, chain []string) error {
487
497
  if raw == nil {
488
498
  return nil
489
499
  }
490
- switch typed := raw.(type) {
491
- case []any:
492
- for i, item := range typed {
493
- if err := collectExternalConfigEntries(store, item, baseDir, fmt.Sprintf("%s[%d]", path, i), allowRuntimeOnly); err != nil {
494
- return err
495
- }
500
+ obj, ok := raw.(map[string]any)
501
+ if !ok {
502
+ return fmt.Errorf("@ttsc/lint: %s must be an ITtscLintConfig object, got %T", path, raw)
503
+ }
504
+ if err := rejectUnknownConfigKeys(obj, path); err != nil {
505
+ return err
506
+ }
507
+
508
+ if extended, hasExtends := obj["extends"]; hasExtends && extended != nil {
509
+ extendsStr, ok := extended.(string)
510
+ if !ok {
511
+ return fmt.Errorf("@ttsc/lint: %s.extends must be a string path to another config file, got %T", path, extended)
496
512
  }
497
- return nil
498
- case map[string]any:
499
- if isESLintConfigObject(typed) {
500
- if marker, ok := typed["__ttscLintEslintRuntime"].(bool); ok && marker {
501
- store.eslintRuntime = true
502
- store.eslintRuntimeRequired = true
503
- }
504
- localBaseDir := baseDir
505
- if rawBasePath, ok := typed["basePath"]; ok {
506
- basePath, ok := rawBasePath.(string)
507
- if !ok {
508
- return fmt.Errorf("@ttsc/lint: %s.basePath must be a string, got %T", path, rawBasePath)
509
- }
510
- if filepath.IsAbs(basePath) {
511
- localBaseDir = basePath
512
- } else {
513
- localBaseDir = filepath.Join(baseDir, basePath)
514
- }
515
- }
516
- if hasESLintRuntimeFields(typed) {
517
- store.eslintRuntime = true
518
- store.eslintRuntimeRequired = true
519
- }
520
- if extended, ok := typed["extends"]; ok {
521
- if err := collectExternalExtends(store, extended, localBaseDir, path+".extends", allowRuntimeOnly); err != nil {
522
- return err
523
- }
524
- }
525
- files, err := parsePatternList(typed["files"], path+".files")
526
- if err != nil {
527
- return err
528
- }
529
- ignores, err := parsePatternList(typed["ignores"], path+".ignores")
530
- if err != nil {
531
- return err
532
- }
533
- rulesValue, hasRules := typed["rules"]
534
- formatValue, hasFormat := typed["format"]
535
- if hasRules || hasFormat {
536
- // Expand the format block (if any) into a rules-shaped map,
537
- // then overlay any explicit `rules` entries. `rules`-wins
538
- // semantics match the inline path; the conflict-resolution
539
- // table is identical regardless of which surface a user
540
- // chose.
541
- var formatRulesRaw map[string]any
542
- if hasFormat {
543
- fmtMap, ok := formatValue.(map[string]any)
544
- if !ok {
545
- return fmt.Errorf("@ttsc/lint: %s.format must be an object, got %T", path, formatValue)
546
- }
547
- expanded, err := expandFormatBlock(fmtMap)
548
- if err != nil {
549
- return err
550
- }
551
- formatRulesRaw = expanded
552
- }
553
- var rulesMap map[string]any
554
- if hasRules {
555
- // `parseExternalRuleMapInto` accepts the raw map directly.
556
- // Coerce here to feed the same merge pipeline as the
557
- // inline path.
558
- typedMap, ok := rulesValue.(map[string]any)
559
- if !ok {
560
- return fmt.Errorf("@ttsc/lint: %s.rules must be a rule severity map, got %T", path, rulesValue)
561
- }
562
- rulesMap = typedMap
563
- }
564
- merged := mergeRuleMaps(formatRulesRaw, rulesMap)
565
- if len(merged) == 0 {
566
- return nil
567
- }
568
- parsed, err := parseExternalRuleMapInto(merged, path+".rules", store)
569
- if err != nil {
570
- return err
571
- }
572
- store.entries = append(store.entries, ConfigEntry{
573
- BaseDir: localBaseDir,
574
- Files: files,
575
- Ignores: ignores,
576
- Rules: parsed,
577
- })
578
- return nil
579
- }
580
- if len(files) == 0 && len(ignores) > 0 {
581
- store.entries = append(store.entries, ConfigEntry{
582
- BaseDir: localBaseDir,
583
- Ignores: ignores,
584
- IgnoreOnly: true,
585
- })
586
- }
587
- return nil
513
+ if strings.TrimSpace(extendsStr) == "" {
514
+ return fmt.Errorf("@ttsc/lint: %s.extends must not be empty", path)
515
+ }
516
+ location := extendsStr
517
+ if !filepath.IsAbs(location) {
518
+ location = filepath.Join(baseDir, location)
588
519
  }
589
- parsed, err := parseExternalRuleMapInto(typed, path, store)
520
+ location = filepath.Clean(location)
521
+ extendedChain, err := appendExtendsLink(chain, location)
590
522
  if err != nil {
591
523
  return err
592
524
  }
593
- store.entries = append(store.entries, ConfigEntry{
594
- BaseDir: baseDir,
595
- Rules: parsed,
596
- })
597
- return nil
598
- default:
599
- return fmt.Errorf("@ttsc/lint: %s must be an object or flat config array, got %T", path, raw)
525
+ extendedRaw, err := loadConfigFile(location)
526
+ if err != nil {
527
+ return err
528
+ }
529
+ if err := collectConfigObject(store, extendedRaw, filepath.Dir(location), path+".extends", extendedChain); err != nil {
530
+ return err
531
+ }
600
532
  }
601
- }
602
533
 
603
- func collectExternalExtends(store *ConfigStore, raw any, baseDir, path string, allowRuntimeOnly bool) error {
604
- switch typed := raw.(type) {
605
- case string:
606
- if allowRuntimeOnly {
607
- store.eslintRuntime = true
608
- store.eslintRuntimeRequired = true
609
- return nil
534
+ files, err := parsePatternList(obj["files"], path+".files")
535
+ if err != nil {
536
+ return err
537
+ }
538
+ ignores, err := parsePatternList(obj["ignores"], path+".ignores")
539
+ if err != nil {
540
+ return err
541
+ }
542
+
543
+ rulesValue, hasRules := obj["rules"]
544
+ formatValue, hasFormat := obj["format"]
545
+ if hasRules || hasFormat {
546
+ // Expand the format block (if any) into a rules-shaped map, then
547
+ // overlay any explicit `rules` entries. `rules`-wins semantics: a
548
+ // `rules` entry that names a `format/*` rule fully replaces the
549
+ // entry expanded from the `format` block.
550
+ var formatRulesRaw map[string]any
551
+ if hasFormat {
552
+ formatMap, ok := formatValue.(map[string]any)
553
+ if !ok {
554
+ return fmt.Errorf("@ttsc/lint: %s.format must be an object, got %T", path, formatValue)
555
+ }
556
+ expanded, err := expandFormatBlock(formatMap)
557
+ if err != nil {
558
+ return err
559
+ }
560
+ formatRulesRaw = expanded
610
561
  }
611
- return fmt.Errorf("@ttsc/lint: %s must be an object or flat config array, got %T", path, raw)
612
- case []any:
613
- for i, item := range typed {
614
- itemPath := fmt.Sprintf("%s[%d]", path, i)
615
- if _, ok := item.(string); ok && allowRuntimeOnly {
616
- store.eslintRuntime = true
617
- store.eslintRuntimeRequired = true
618
- continue
562
+ var rulesMap map[string]any
563
+ if hasRules {
564
+ typedMap, ok := rulesValue.(map[string]any)
565
+ if !ok {
566
+ return fmt.Errorf("@ttsc/lint: %s.rules must be a rule severity map, got %T", path, rulesValue)
619
567
  }
620
- if err := collectExternalConfigEntries(store, item, baseDir, itemPath, allowRuntimeOnly); err != nil {
568
+ rulesMap = typedMap
569
+ }
570
+ merged := mergeRuleMaps(formatRulesRaw, rulesMap)
571
+ if len(merged) > 0 {
572
+ parsed, err := parseExternalRuleMapInto(merged, path+".rules", store)
573
+ if err != nil {
621
574
  return err
622
575
  }
576
+ store.entries = append(store.entries, ConfigEntry{
577
+ BaseDir: baseDir,
578
+ Files: files,
579
+ Ignores: ignores,
580
+ Rules: parsed,
581
+ })
623
582
  }
624
583
  return nil
625
- default:
626
- return collectExternalConfigEntries(store, raw, baseDir, path, allowRuntimeOnly)
627
584
  }
585
+
586
+ if len(files) == 0 && len(ignores) > 0 {
587
+ store.entries = append(store.entries, ConfigEntry{
588
+ BaseDir: baseDir,
589
+ Ignores: ignores,
590
+ IgnoreOnly: true,
591
+ })
592
+ }
593
+ return nil
628
594
  }
629
595
 
630
596
  // parseExternalRuleMapInto parses the rules map and folds any
@@ -643,7 +609,7 @@ func parseExternalRuleMapInto(raw any, path string, store *ConfigStore) (RuleCon
643
609
 
644
610
  // collectExternalRuleMapWithOptions also records the rule's options blob
645
611
  // when the entry is a `[severity, options]` tuple. `opts` may be nil
646
- // when the caller does not need option capture (legacy paths).
612
+ // when the caller does not need option capture.
647
613
  func collectExternalRuleMapWithOptions(out RuleConfig, opts RuleOptionsMap, raw any, path string) error {
648
614
  dict, ok := raw.(map[string]any)
649
615
  if !ok {
@@ -663,109 +629,24 @@ func collectExternalRuleMapWithOptions(out RuleConfig, opts RuleOptionsMap, raw
663
629
  return nil
664
630
  }
665
631
 
666
- // isESLintConfigObject reports whether `value` looks like an ESLint flat-config
667
- // object (has at least one recognized top-level key). Used to distinguish
668
- // flat-config objects from bare rules maps during config parsing.
669
- func isESLintConfigObject(value map[string]any) bool {
670
- for _, key := range []string{
671
- "basePath",
672
- "extends",
673
- "files",
674
- "format",
675
- "ignores",
676
- "languageOptions",
677
- "linterOptions",
678
- "name",
679
- "plugins",
680
- "processor",
681
- "rules",
682
- "settings",
683
- "__ttscLintEslintRuntime",
684
- } {
685
- if _, ok := value[key]; ok {
686
- return true
687
- }
688
- }
689
- return false
690
- }
691
-
692
- // hasESLintRuntimeFields reports whether `value` contains keys that require the
693
- // JS ESLint runtime to evaluate: languageOptions, linterOptions, processor,
694
- // settings, or a plugins map that is not purely native contributors.
695
- func hasESLintRuntimeFields(value map[string]any) bool {
696
- for _, key := range []string{
697
- "languageOptions",
698
- "linterOptions",
699
- "processor",
700
- "settings",
701
- } {
702
- if _, ok := value[key]; ok {
703
- return true
704
- }
705
- }
706
- if plugins, ok := value["plugins"]; ok {
707
- if !isNativePluginMap(plugins) {
708
- return true
709
- }
710
- }
711
- return false
712
- }
713
-
714
- // isNativePluginMap reports whether every entry in a flat-config
715
- // `plugins` map points at a ttsc-lint native contributor object
716
- // (carrying a non-empty string `source` field). Native contributors are
717
- // compiled into the lint binary at build time and require no JS ESLint
718
- // runtime; only mixed or pure-ESLint plugin maps require the runtime
719
- // fallback.
720
- func isNativePluginMap(value any) bool {
721
- dict, ok := value.(map[string]any)
722
- if !ok {
723
- return false
724
- }
725
- if len(dict) == 0 {
726
- return true
727
- }
728
- for _, entry := range dict {
729
- if !isNativePluginValue(entry) {
730
- return false
731
- }
732
- }
733
- return true
734
- }
735
-
736
- func isNativePluginValue(entry any) bool {
737
- if entry == nil {
738
- return false
632
+ // rejectUnknownConfigKeys surfaces typos in top-level config-file keys at the
633
+ // boundary rather than silently ignoring them. The key set mirrors
634
+ // `ITtscLintConfig` exactly.
635
+ func rejectUnknownConfigKeys(value map[string]any, path string) error {
636
+ allowed := map[string]struct{}{
637
+ "files": {},
638
+ "ignores": {},
639
+ "extends": {},
640
+ "plugins": {},
641
+ "rules": {},
642
+ "format": {},
739
643
  }
740
- switch typed := entry.(type) {
741
- case string:
742
- // A non-empty string is a native npm specifier (matching the JS
743
- // factory's `normalizePluginValue` contract for `.js`/`.cjs`/`.ts`
744
- // configs and the JSON-only `readJsonConfigPlugins` path). The JS
745
- // factory resolves the specifier at load time and bakes the
746
- // contributor into the binary, so the Go sidecar should not flip
747
- // `eslintRuntimeRequired` for a file that already declared a
748
- // native specifier.
749
- return typed != ""
750
- case map[string]any:
751
- // Walk ESM-from-CJS `.default` indirection so a contributor authored
752
- // as `export default plugin` registers as native here, matching the
753
- // JS factory's `extractPluginSource` behavior.
754
- current := typed
755
- for i := 0; i < 4; i++ {
756
- if source, ok := current["source"].(string); ok && source != "" {
757
- return true
758
- }
759
- next, ok := current["default"].(map[string]any)
760
- if !ok {
761
- return false
762
- }
763
- current = next
644
+ for key := range value {
645
+ if _, ok := allowed[key]; !ok {
646
+ return fmt.Errorf("@ttsc/lint: %s has unknown key %q; a lint config file must be an ITtscLintConfig object (files, ignores, extends, plugins, rules, format)", path, key)
764
647
  }
765
- return false
766
- default:
767
- return false
768
648
  }
649
+ return nil
769
650
  }
770
651
 
771
652
  // normalizeExternalRuleName strips the standard typescript-eslint namespace
@@ -807,10 +688,9 @@ func parsePatternList(raw any, path string) ([]string, error) {
807
688
  }
808
689
  }
809
690
 
810
- // LoadRuleConfig resolves the lint config for one plugin entry. The only
811
- // accepted lint-specific tsconfig key is `config`; it may be either an inline
812
- // rules object or a string path to a standalone config file. Relative config
813
- // paths are resolved from the tsconfig directory.
691
+ // LoadRuleConfig resolves the lint config for one plugin entry and flattens it
692
+ // to a plain RuleConfig (no glob scoping). Used by callers and tests that only
693
+ // need a project-wide severity map.
814
694
  func LoadRuleConfig(entry *PluginEntry, cwd, tsconfigPath string) (RuleConfig, error) {
815
695
  resolver, err := LoadConfigResolver(entry, cwd, tsconfigPath)
816
696
  if err != nil {
@@ -826,22 +706,17 @@ func LoadRuleConfig(entry *PluginEntry, cwd, tsconfigPath string) (RuleConfig, e
826
706
  }
827
707
  }
828
708
 
829
- // LoadConfigResolver resolves one plugin entry into the engine-facing
830
- // config model.
709
+ // LoadConfigResolver resolves one plugin entry into the engine-facing config
710
+ // model.
831
711
  //
832
- // Two equivalent input shapes are accepted:
712
+ // The tsconfig plugin entry carries exactly one optional lint-specific key:
713
+ // `configFile`, a path (relative to the tsconfig directory, or absolute) to
714
+ // the lint config file. When `configFile` is set, that file is loaded; when it
715
+ // is absent, a `lint.config.*` / `ttsc-lint.config.*` file is discovered by
716
+ // walking upward from the tsconfig directory.
833
717
  //
834
- // - `rules` (inline severity map) + `extends` (config file path) —
835
- // the canonical fields mirroring ESLint flat-config vocabulary.
836
- // - `config` (legacy) — accepts the same string-or-map values but
837
- // emits a one-time stderr deprecation notice. Removed in a future
838
- // minor.
839
- //
840
- // `rules` and `extends` are mutually exclusive on a single plugin
841
- // entry; mixing legacy `config` with either new field is rejected.
842
- // `configFile` and `configPath` remain reserved keywords surfaced with
843
- // a hint pointing at `extends`, in case a user mistakenly reaches for
844
- // either spelling.
718
+ // All rules, format options, and contributor plugins live in the config file
719
+ // itself — the tsconfig entry has no inline rule/format/plugin surface.
845
720
  func LoadConfigResolver(entry *PluginEntry, cwd, tsconfigPath string) (RuleResolver, error) {
846
721
  if entry == nil {
847
722
  return RuleConfig{}, nil
@@ -850,102 +725,17 @@ func LoadConfigResolver(entry *PluginEntry, cwd, tsconfigPath string) (RuleResol
850
725
  if inline == nil {
851
726
  inline = map[string]any{}
852
727
  }
853
- for _, key := range []string{"configFile", "configPath"} {
854
- if _, ok := inline[key]; ok {
855
- return nil, fmt.Errorf("@ttsc/lint: %q is not supported; use \"extends\"", key)
856
- }
857
- }
858
-
859
- rulesValue, hasRules := inline["rules"]
860
- extendsValue, hasExtends := inline["extends"]
861
- legacyValue, hasLegacy := inline["config"]
862
- formatValue, hasFormat := inline["format"]
863
-
864
- if hasLegacy && (hasRules || hasExtends || hasFormat) {
865
- sibling := ""
866
- switch {
867
- case hasFormat && !hasRules && !hasExtends:
868
- sibling = "format"
869
- case hasExtends && !hasRules && !hasFormat:
870
- sibling = "extends"
871
- case hasRules && !hasExtends && !hasFormat:
872
- sibling = "rules"
873
- default:
874
- sibling = "rules/extends/format"
875
- }
876
- return nil, fmt.Errorf("@ttsc/lint: tsconfig plugin entry mixes legacy \"config\" with the new %q field; remove \"config\" (deprecated)", sibling)
877
- }
878
- if hasRules && hasExtends {
879
- 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")
880
- }
881
- if hasFormat && hasExtends {
882
- 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")
883
- }
884
728
 
885
- // Expand the format block (if any) into a rules-shaped map.
886
- var formatRulesRaw map[string]any
887
- if hasFormat {
888
- formatMap, ok := formatValue.(map[string]any)
889
- if !ok {
890
- return nil, fmt.Errorf("@ttsc/lint: \"format\" must be an object, got %T", formatValue)
891
- }
892
- expanded, err := expandFormatBlock(formatMap)
893
- if err != nil {
894
- return nil, err
895
- }
896
- formatRulesRaw = expanded
897
- }
898
-
899
- if hasRules || hasFormat {
900
- // Merge format defaults with inline rule overrides; the `rules`
901
- // entry wins on key collisions (more explicit surface).
902
- var rulesMap map[string]any
903
- if hasRules {
904
- typed, ok := rulesValue.(map[string]any)
905
- if !ok {
906
- return nil, fmt.Errorf("@ttsc/lint: \"rules\" must be a rule severity map, got %T", rulesValue)
907
- }
908
- rulesMap = typed
909
- }
910
- merged := mergeRuleMaps(formatRulesRaw, rulesMap)
911
- if len(merged) == 0 {
912
- return InlineRuleResolver{Rules: RuleConfig{}, Options: RuleOptionsMap{}}, nil
913
- }
914
- cfg, opts, err := ParseRulesWithOptions(merged)
915
- if err != nil {
916
- return nil, err
917
- }
918
- return InlineRuleResolver{Rules: cfg, Options: opts}, nil
919
- }
920
- if hasExtends {
921
- extendsStr, ok := extendsValue.(string)
729
+ if configFileValue, ok := inline["configFile"]; ok {
730
+ configFile, ok := configFileValue.(string)
922
731
  if !ok {
923
- return nil, fmt.Errorf("@ttsc/lint: \"extends\" must be a string path, got %T", extendsValue)
732
+ return nil, fmt.Errorf("@ttsc/lint: \"configFile\" must be a string path, got %T", configFileValue)
924
733
  }
925
- if strings.TrimSpace(extendsStr) == "" {
926
- return nil, fmt.Errorf("@ttsc/lint: \"extends\" must not be empty")
927
- }
928
- location := resolveConfigFilePath(extendsStr, cwd, tsconfigPath)
929
- return loadExternalConfigResolver(location)
930
- }
931
- if hasLegacy {
932
- emitLegacyConfigDeprecation()
933
- switch typed := legacyValue.(type) {
934
- case string:
935
- if strings.TrimSpace(typed) == "" {
936
- return nil, fmt.Errorf("@ttsc/lint: legacy \"config\" must not be empty")
937
- }
938
- location := resolveConfigFilePath(typed, cwd, tsconfigPath)
939
- return loadExternalConfigResolver(location)
940
- case map[string]any:
941
- cfg, opts, err := ParseRulesWithOptions(typed)
942
- if err != nil {
943
- return nil, err
944
- }
945
- return InlineRuleResolver{Rules: cfg, Options: opts}, nil
946
- default:
947
- return nil, fmt.Errorf("@ttsc/lint: legacy \"config\" must be a string path or object, got %T", legacyValue)
734
+ if strings.TrimSpace(configFile) == "" {
735
+ return nil, fmt.Errorf("@ttsc/lint: \"configFile\" must not be empty")
948
736
  }
737
+ location := resolveConfigFilePath(configFile, cwd, tsconfigPath)
738
+ return loadConfigResolver(location)
949
739
  }
950
740
 
951
741
  discovered, err := findLintConfigFile(cwd, tsconfigPath)
@@ -953,32 +743,22 @@ func LoadConfigResolver(entry *PluginEntry, cwd, tsconfigPath string) (RuleResol
953
743
  return nil, err
954
744
  }
955
745
  if discovered == "" {
956
- 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)
746
+ return nil, fmt.Errorf("@ttsc/lint: no lint.config.* or ttsc-lint.config.* file found (searched upward from %s); create one or set \"configFile\" on the tsconfig plugin entry", cwd)
957
747
  }
958
- return loadExternalConfigResolver(discovered)
959
- }
960
-
961
- var legacyConfigDeprecationOnce sync.Once
962
-
963
- func emitLegacyConfigDeprecation() {
964
- legacyConfigDeprecationOnce.Do(func() {
965
- 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.")
966
- })
748
+ return loadConfigResolver(discovered)
967
749
  }
968
750
 
969
- // loadExternalConfigResolver loads and parses an external config file at
970
- // `location` into a *ConfigStore and returns it as a RuleResolver. The store's
971
- // externalConfigPath is set so callers can query WantsESLintRuntime.
972
- func loadExternalConfigResolver(location string) (RuleResolver, error) {
751
+ // loadConfigResolver loads and parses the lint config file at `location` into
752
+ // a *ConfigStore and returns it as a RuleResolver.
753
+ func loadConfigResolver(location string) (RuleResolver, error) {
973
754
  raw, err := loadConfigFile(location)
974
755
  if err != nil {
975
756
  return nil, err
976
757
  }
977
- store, err := parseExternalConfigStoreForFile(raw, filepath.Dir(location))
758
+ store, err := collectConfigStore(raw, filepath.Dir(location), location)
978
759
  if err != nil {
979
760
  return nil, err
980
761
  }
981
- store.externalConfigPath = location
982
762
  return store, nil
983
763
  }
984
764
 
@@ -1001,12 +781,6 @@ func findLintConfigFile(cwd, tsconfigPath string) (string, error) {
1001
781
  "ttsc-lint.config.ts",
1002
782
  "ttsc-lint.config.mts",
1003
783
  "ttsc-lint.config.cts",
1004
- "eslint.config.js",
1005
- "eslint.config.mjs",
1006
- "eslint.config.cjs",
1007
- "eslint.config.ts",
1008
- "eslint.config.mts",
1009
- "eslint.config.cts",
1010
784
  } {
1011
785
  candidate := filepath.Join(dir, name)
1012
786
  if stat, err := os.Stat(candidate); err == nil && !stat.IsDir() {
@@ -1018,7 +792,7 @@ func findLintConfigFile(cwd, tsconfigPath string) (string, error) {
1018
792
  for _, m := range matches {
1019
793
  names = append(names, filepath.Base(m))
1020
794
  }
1021
- return "", fmt.Errorf("@ttsc/lint: multiple lint config files found in %s (%s); set \"extends\" explicitly", dir, strings.Join(names, ", "))
795
+ return "", fmt.Errorf("@ttsc/lint: multiple lint config files found in %s (%s); set \"configFile\" explicitly", dir, strings.Join(names, ", "))
1022
796
  }
1023
797
  if len(matches) == 1 {
1024
798
  return matches[0], nil
@@ -1073,20 +847,166 @@ func tsconfigBaseDir(cwd, tsconfigPath string) string {
1073
847
  // loadConfigFile loads and deserializes a lint config file at `location`.
1074
848
  // The file format is determined by extension: .json is parsed natively;
1075
849
  // .js/.cjs/.mjs run through a Node subprocess; .ts/.cts/.mts run through ttsx.
850
+ // The two subprocess-backed forms go through loadCachedConfigFile so that a
851
+ // monorepo build — which spawns one `ttsc` process per package — evaluates a
852
+ // shared lint config once instead of once per package.
1076
853
  func loadConfigFile(location string) (any, error) {
1077
854
  ext := strings.ToLower(filepath.Ext(location))
1078
855
  switch ext {
1079
856
  case ".json":
1080
857
  return loadJSONConfigFile(location)
1081
858
  case ".js", ".cjs", ".mjs":
1082
- return loadScriptConfigFile(location)
859
+ return loadCachedConfigFile(location, loadScriptConfigFile)
1083
860
  case ".ts", ".cts", ".mts":
1084
- return loadTypeScriptConfigFile(location)
861
+ return loadCachedConfigFile(location, loadTypeScriptConfigFile)
1085
862
  default:
1086
863
  return nil, fmt.Errorf("@ttsc/lint: unsupported config file extension %q for %s", ext, location)
1087
864
  }
1088
865
  }
1089
866
 
867
+ // configCacheVersion namespaces the on-disk config cache. Bump it whenever
868
+ // the shape of a cached config object changes so that entries written by an
869
+ // older @ttsc/lint binary are treated as a miss rather than silently reused.
870
+ const configCacheVersion = "v1"
871
+
872
+ // configEvalCache memoizes evaluated .ts/.js lint config objects for the
873
+ // lifetime of one process; the on-disk cache (configCacheDir) extends the
874
+ // same memoization across the separate `ttsc` processes a monorepo build
875
+ // spawns. Guarded by configEvalCacheMu.
876
+ var (
877
+ configEvalCacheMu sync.Mutex
878
+ configEvalCache = map[string]any{}
879
+ )
880
+
881
+ // configCacheDir is the directory shared by this Go sidecar and the JS
882
+ // plugin factory (packages/lint/src/index.ts) for cached lint configs.
883
+ // Evaluating a .ts/.js config means spawning a ttsx/node subprocess; the
884
+ // cache keeps every `ttsc` invocation after the first from re-paying it.
885
+ func configCacheDir() string {
886
+ return filepath.Join(os.TempDir(), "ttsc-lint-config-cache")
887
+ }
888
+
889
+ // configCacheDisabled reports whether the env opt-out is set — an escape
890
+ // hatch for callers that must force a fresh evaluation (e.g. a config whose
891
+ // behavior depends on imported non-config files that the key cannot see).
892
+ func configCacheDisabled() bool {
893
+ return os.Getenv("TTSC_LINT_DISABLE_CONFIG_CACHE") != ""
894
+ }
895
+
896
+ // configCacheKey derives the cache key for a config file from a version
897
+ // tag, a namespace `kind`, the file's absolute path, and its exact
898
+ // contents. Content-addressing means an edited config invalidates cleanly
899
+ // with no clock-resolution race; the absolute path keeps two projects with
900
+ // byte-identical configs distinct; `kind` separates this sidecar's
901
+ // evaluated-config namespace from the JS factory's plugin-entry namespace.
902
+ func configCacheKey(kind, absPath string, content []byte) string {
903
+ h := sha256.New()
904
+ h.Write([]byte(configCacheVersion))
905
+ h.Write([]byte{0})
906
+ h.Write([]byte(kind))
907
+ h.Write([]byte{0})
908
+ h.Write([]byte(absPath))
909
+ h.Write([]byte{0})
910
+ h.Write(content)
911
+ return hex.EncodeToString(h.Sum(nil))
912
+ }
913
+
914
+ // loadCachedConfigFile wraps a subprocess-backed config loader (`eval`)
915
+ // with the two-tier (in-process + on-disk) config cache. The cache key
916
+ // covers the config file's path and bytes only — a config's own `import`s
917
+ // of non-config files are NOT tracked, since a lint config is expected to
918
+ // be self-contained; set TTSC_LINT_DISABLE_CONFIG_CACHE when it is not.
919
+ // Errors are never cached: a failed evaluation re-runs next time.
920
+ func loadCachedConfigFile(location string, eval func(string) (any, error)) (any, error) {
921
+ if configCacheDisabled() {
922
+ return eval(location)
923
+ }
924
+ content, err := os.ReadFile(location)
925
+ if err != nil {
926
+ return nil, fmt.Errorf("@ttsc/lint: read config file %s: %w", location, err)
927
+ }
928
+ abs := location
929
+ if resolved, absErr := filepath.Abs(location); absErr == nil {
930
+ abs = resolved
931
+ }
932
+ key := configCacheKey("config", abs, content)
933
+
934
+ configEvalCacheMu.Lock()
935
+ cached, ok := configEvalCache[key]
936
+ configEvalCacheMu.Unlock()
937
+ if ok {
938
+ return cached, nil
939
+ }
940
+ if value, hit := readConfigDiskCache(key); hit {
941
+ configEvalCacheMu.Lock()
942
+ configEvalCache[key] = value
943
+ configEvalCacheMu.Unlock()
944
+ return value, nil
945
+ }
946
+
947
+ value, err := eval(location)
948
+ if err != nil {
949
+ return nil, err
950
+ }
951
+ configEvalCacheMu.Lock()
952
+ configEvalCache[key] = value
953
+ configEvalCacheMu.Unlock()
954
+ writeConfigDiskCache(key, value)
955
+ return value, nil
956
+ }
957
+
958
+ // readConfigDiskCache returns the cached config object for `key`, or
959
+ // (nil, false) on any miss — a missing file, an unreadable file, or
960
+ // content that no longer parses as a config object. Every failure is a
961
+ // soft miss: the caller re-evaluates rather than surfacing a cache fault.
962
+ func readConfigDiskCache(key string) (any, bool) {
963
+ body, err := os.ReadFile(filepath.Join(configCacheDir(), key+".json"))
964
+ if err != nil {
965
+ return nil, false
966
+ }
967
+ var value any
968
+ if err := json.Unmarshal(body, &value); err != nil {
969
+ return nil, false
970
+ }
971
+ if !isConfigObject(value) {
972
+ return nil, false
973
+ }
974
+ return value, true
975
+ }
976
+
977
+ // writeConfigDiskCache stores `value` under `key`. It is best-effort: a
978
+ // failure to create the directory or write the file leaves the cache cold
979
+ // (the next run re-evaluates) rather than failing the lint run. The write
980
+ // goes through a temp file + rename so a concurrent reader in a sibling
981
+ // `ttsc` process never observes a half-written entry.
982
+ func writeConfigDiskCache(key string, value any) {
983
+ body, err := json.Marshal(value)
984
+ if err != nil {
985
+ return
986
+ }
987
+ dir := configCacheDir()
988
+ if err := os.MkdirAll(dir, 0o755); err != nil {
989
+ return
990
+ }
991
+ tmp, err := os.CreateTemp(dir, key+".*.tmp")
992
+ if err != nil {
993
+ return
994
+ }
995
+ tmpName := tmp.Name()
996
+ if _, err := tmp.Write(body); err != nil {
997
+ tmp.Close()
998
+ os.Remove(tmpName)
999
+ return
1000
+ }
1001
+ if err := tmp.Close(); err != nil {
1002
+ os.Remove(tmpName)
1003
+ return
1004
+ }
1005
+ if err := os.Rename(tmpName, filepath.Join(dir, key+".json")); err != nil {
1006
+ os.Remove(tmpName)
1007
+ }
1008
+ }
1009
+
1090
1010
  // loadJSONConfigFile reads and JSON-parses a lint config file. A leading UTF-8
1091
1011
  // BOM is stripped before parsing so files saved by Windows editors are accepted.
1092
1012
  func loadJSONConfigFile(location string) (any, error) {
@@ -1103,8 +1023,8 @@ func loadJSONConfigFile(location string) (any, error) {
1103
1023
  if err := json.Unmarshal(body, &out); err != nil {
1104
1024
  return nil, fmt.Errorf("@ttsc/lint: parse config file %s: %w", location, err)
1105
1025
  }
1106
- if !isConfigContainer(out) {
1107
- return nil, fmt.Errorf("@ttsc/lint: config file %s must export an object or flat config array", location)
1026
+ if !isConfigObject(out) {
1027
+ return nil, fmt.Errorf("@ttsc/lint: config file %s must export an ITtscLintConfig object", location)
1108
1028
  }
1109
1029
  return out, nil
1110
1030
  }
@@ -1140,8 +1060,8 @@ const { pathToFileURL } = require("node:url");
1140
1060
  break;
1141
1061
  }
1142
1062
  const value = typeof current === "function" ? await current() : current;
1143
- if (value === null || typeof value !== "object") {
1144
- throw new Error("config file must export an object or flat config array");
1063
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
1064
+ throw new Error("config file must export an ITtscLintConfig object");
1145
1065
  }
1146
1066
  process.stdout.write(JSON.stringify(toSerializableConfig(value)));
1147
1067
  })().catch((error) => {
@@ -1149,114 +1069,18 @@ const { pathToFileURL } = require("node:url");
1149
1069
  process.exit(1);
1150
1070
  });
1151
1071
 
1072
+ // toSerializableConfig copies every ITtscLintConfig key onto a plain object so
1073
+ // it survives the JSON round trip to the Go sidecar. Every key is copied
1074
+ // verbatim — files, ignores, extends, plugins, rules, AND format — so a config
1075
+ // whose only key is ` + "`" + `format` + "`" + ` is not silently dropped.
1152
1076
  function toSerializableConfig(value) {
1153
- if (Array.isArray(value)) {
1154
- return value.map((item) => toSerializableConfig(item));
1155
- }
1156
- if (value === null || typeof value !== "object") {
1157
- return value;
1158
- }
1159
- if (isESLintConfigObject(value)) {
1160
- const out = {};
1161
- if (hasESLintRuntimeFields(value)) {
1162
- out.__ttscLintEslintRuntime = true;
1163
- }
1164
- if (Object.prototype.hasOwnProperty.call(value, "basePath")) {
1165
- out.basePath = value.basePath;
1166
- }
1167
- if (Object.prototype.hasOwnProperty.call(value, "extends")) {
1168
- out.extends = toSerializableConfig(value.extends);
1169
- }
1170
- if (Object.prototype.hasOwnProperty.call(value, "files")) {
1171
- out.files = toSerializablePatterns(value.files, "files");
1172
- }
1173
- if (Object.prototype.hasOwnProperty.call(value, "ignores")) {
1174
- out.ignores = toSerializablePatterns(value.ignores, "ignores");
1175
- }
1176
- if (Object.prototype.hasOwnProperty.call(value, "rules")) {
1177
- out.rules = toSerializableRules(value.rules);
1178
- }
1179
- return out;
1180
- }
1181
- return { rules: toSerializableRules(value) };
1182
- }
1183
-
1184
- function toSerializableRules(value) {
1185
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
1186
- throw new Error("rules must be an object");
1187
- }
1188
- return Object.fromEntries(Object.entries(value));
1189
- }
1190
-
1191
- function toSerializablePatterns(value, key) {
1192
- if (typeof value === "string") {
1193
- return value;
1194
- }
1195
- if (Array.isArray(value)) {
1196
- return value.map((item, index) => {
1197
- if (typeof item !== "string") {
1198
- throw new Error(key + "[" + index + "] must be a string");
1199
- }
1200
- return item;
1201
- });
1202
- }
1203
- throw new Error(key + " must be a string or string array");
1204
- }
1205
-
1206
- function isESLintConfigObject(value) {
1207
- return [
1208
- "basePath",
1209
- "extends",
1210
- "files",
1211
- "ignores",
1212
- "languageOptions",
1213
- "linterOptions",
1214
- "name",
1215
- "plugins",
1216
- "processor",
1217
- "rules",
1218
- "settings",
1219
- ].some((key) => Object.prototype.hasOwnProperty.call(value, key));
1220
- }
1221
-
1222
- function hasESLintRuntimeFields(value) {
1223
- for (const key of ["languageOptions", "linterOptions", "processor", "settings"]) {
1224
- if (Object.prototype.hasOwnProperty.call(value, key)) return true;
1225
- }
1226
- if (Object.prototype.hasOwnProperty.call(value, "plugins")) {
1227
- if (!isNativePluginMap(value.plugins)) return true;
1228
- }
1229
- return false;
1230
- }
1231
-
1232
- function isNativePluginMap(value) {
1233
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
1234
- return false;
1235
- }
1236
- const entries = Object.values(value);
1237
- if (entries.length === 0) return true;
1238
- for (const entry of entries) {
1239
- if (!isNativePluginValue(entry)) return false;
1240
- }
1241
- return true;
1242
- }
1243
-
1244
- function isNativePluginValue(entry) {
1245
- // A non-empty string is a native specifier — JS factory resolves it
1246
- // at load time, so the loader must not flip the ESLint-runtime flag.
1247
- if (typeof entry === "string") return entry.length > 0;
1248
- if (entry === null || typeof entry !== "object") return false;
1249
- let current = entry;
1250
- for (let i = 0; i < 4; i++) {
1251
- if (typeof current.source === "string" && current.source.length > 0) {
1252
- return true;
1077
+ const out = {};
1078
+ for (const key of ["files", "ignores", "extends", "plugins", "rules", "format"]) {
1079
+ if (Object.prototype.hasOwnProperty.call(value, key)) {
1080
+ out[key] = value[key];
1253
1081
  }
1254
- if (current.default === null || typeof current.default !== "object") {
1255
- return false;
1256
- }
1257
- current = current.default;
1258
1082
  }
1259
- return false;
1083
+ return out;
1260
1084
  }
1261
1085
  `
1262
1086
  node := os.Getenv("TTSC_NODE_BINARY")
@@ -1284,8 +1108,8 @@ function isNativePluginValue(entry) {
1284
1108
  if err := json.Unmarshal(output, &out); err != nil {
1285
1109
  return nil, fmt.Errorf("@ttsc/lint: parse config file %s output: %w", location, err)
1286
1110
  }
1287
- if !isConfigContainer(out) {
1288
- return nil, fmt.Errorf("@ttsc/lint: config file %s must export an object or flat config array", location)
1111
+ if !isConfigObject(out) {
1112
+ return nil, fmt.Errorf("@ttsc/lint: config file %s must export an ITtscLintConfig object", location)
1289
1113
  }
1290
1114
  return out, nil
1291
1115
  }
@@ -1327,6 +1151,15 @@ func loadTypeScriptConfigFile(location string) (any, error) {
1327
1151
  "--project", tsconfig,
1328
1152
  "--cwd", tempDir,
1329
1153
  "--cache-dir", filepath.Join(tempDir, "cache"),
1154
+ // The loader only needs to type-check and execute the user's
1155
+ // `*.config.ts`; it must NOT load the host project's transform /
1156
+ // check plugins. Discovering them (`@nestia/core`, `typia`, …)
1157
+ // would run their project checks against this ephemeral loader
1158
+ // tsconfig — which is deliberately lenient (`strict: false`) — so a
1159
+ // plugin like `@nestia/core` that demands strict mode would fail
1160
+ // the build and abort config evaluation. `--no-plugins` makes the
1161
+ // ttsx build hermetic.
1162
+ "--no-plugins",
1330
1163
  }
1331
1164
  if tsgo := os.Getenv("TTSC_TSGO_BINARY"); tsgo != "" {
1332
1165
  args = append(args, "--binary", tsgo)
@@ -1355,22 +1188,19 @@ func loadTypeScriptConfigFile(location string) (any, error) {
1355
1188
  if err := json.Unmarshal(output, &out); err != nil {
1356
1189
  return nil, fmt.Errorf("@ttsc/lint: parse TypeScript config file %s output: %w", location, err)
1357
1190
  }
1358
- if !isConfigContainer(out) {
1359
- return nil, fmt.Errorf("@ttsc/lint: config file %s must export an object or flat config array", location)
1191
+ if !isConfigObject(out) {
1192
+ return nil, fmt.Errorf("@ttsc/lint: config file %s must export an ITtscLintConfig object", location)
1360
1193
  }
1361
1194
  return out, nil
1362
1195
  }
1363
1196
 
1364
- // isConfigContainer reports whether `value` is a top-level config container
1365
- // (an object or flat-config array). Scalar values are rejected so users get a
1366
- // clear error instead of an opaque parse failure downstream.
1367
- func isConfigContainer(value any) bool {
1368
- switch value.(type) {
1369
- case []any, map[string]any:
1370
- return true
1371
- default:
1372
- return false
1373
- }
1197
+ // isConfigObject reports whether `value` is a top-level config object. A lint
1198
+ // config file always exports a single `ITtscLintConfig` object; arrays and
1199
+ // scalars are rejected so users get a clear error instead of an opaque parse
1200
+ // failure downstream.
1201
+ func isConfigObject(value any) bool {
1202
+ _, ok := value.(map[string]any)
1203
+ return ok
1374
1204
  }
1375
1205
 
1376
1206
  // relativeImportSpecifier computes the ESM import specifier for `location`
@@ -1405,8 +1235,8 @@ declare const process: {
1405
1235
 
1406
1236
  try {
1407
1237
  const value = await resolveConfig(importedConfig, true);
1408
- if (!isObject(value)) {
1409
- throw new Error("config file must export an object or flat config array");
1238
+ if (!isObject(value) || Array.isArray(value)) {
1239
+ throw new Error("config file must export an ITtscLintConfig object");
1410
1240
  }
1411
1241
  process.stdout.write(JSON.stringify(toSerializableConfig(value)));
1412
1242
  } catch (error) {
@@ -1443,126 +1273,18 @@ function hasOwn(value: Record<string, unknown>, key: string): boolean {
1443
1273
  return Object.prototype.hasOwnProperty.call(value, key);
1444
1274
  }
1445
1275
 
1446
- function toSerializableConfig(value: unknown): unknown {
1447
- if (Array.isArray(value)) {
1448
- return value.map((item) => toSerializableConfig(item));
1449
- }
1450
- if (!isObject(value)) {
1451
- return value;
1452
- }
1453
- if (isESLintConfigObject(value)) {
1454
- const out: Record<string, unknown> = {};
1455
- if (hasESLintRuntimeFields(value)) {
1456
- out.__ttscLintEslintRuntime = true;
1457
- }
1458
- if (hasOwn(value, "basePath")) {
1459
- out.basePath = value.basePath;
1460
- }
1461
- if (hasOwn(value, "extends")) {
1462
- out.extends = toSerializableConfig(value.extends);
1463
- }
1464
- if (hasOwn(value, "files")) {
1465
- out.files = toSerializablePatterns(value.files, "files");
1466
- }
1467
- if (hasOwn(value, "ignores")) {
1468
- out.ignores = toSerializablePatterns(value.ignores, "ignores");
1469
- }
1470
- if (hasOwn(value, "rules")) {
1471
- out.rules = toSerializableRules(value.rules);
1472
- }
1473
- return out;
1474
- }
1475
- return { rules: toSerializableRules(value) };
1476
- }
1477
-
1478
- function toSerializableRules(value: unknown): Record<string, unknown> {
1479
- if (!isObject(value) || Array.isArray(value)) {
1480
- throw new Error("rules must be an object");
1481
- }
1482
- return Object.fromEntries(Object.entries(value));
1483
- }
1484
-
1485
- function toSerializablePatterns(value: unknown, key: string): string | string[] {
1486
- if (typeof value === "string") {
1487
- return value;
1488
- }
1489
- if (Array.isArray(value)) {
1490
- return value.map((item, index) => {
1491
- if (typeof item !== "string") {
1492
- throw new Error(key + "[" + index + "] must be a string");
1493
- }
1494
- return item;
1495
- });
1496
- }
1497
- throw new Error(key + " must be a string or string array");
1498
- }
1499
-
1500
- function isESLintConfigObject(value: Record<string, unknown>): boolean {
1501
- return [
1502
- "basePath",
1503
- "extends",
1504
- "files",
1505
- "ignores",
1506
- "languageOptions",
1507
- "linterOptions",
1508
- "name",
1509
- "plugins",
1510
- "processor",
1511
- "rules",
1512
- "settings",
1513
- ].some((key) => hasOwn(value, key));
1514
- }
1515
-
1516
- function hasESLintRuntimeFields(value: Record<string, unknown>): boolean {
1517
- for (const key of ["languageOptions", "linterOptions", "processor", "settings"]) {
1518
- if (hasOwn(value, key)) return true;
1519
- }
1520
- if (hasOwn(value, "plugins")) {
1521
- const plugins = value.plugins;
1522
- if (!isNativePluginMap(plugins)) return true;
1523
- }
1524
- return false;
1525
- }
1526
-
1527
- // isNativePluginMap reports whether every entry of a plugins map points
1528
- // at a ttsc-lint native contributor (an object with a string "source"
1529
- // field). Native plugins are compiled into the lint binary at build
1530
- // time, so their presence does NOT require the JavaScript ESLint
1531
- // runtime; only mixed or pure-ESLint plugin maps do.
1532
- function isNativePluginMap(value: unknown): boolean {
1533
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
1534
- return false;
1535
- }
1536
- const entries = Object.values(value as Record<string, unknown>);
1537
- if (entries.length === 0) return true;
1538
- for (const entry of entries) {
1539
- if (!isNativePluginValue(entry)) return false;
1540
- }
1541
- return true;
1542
- }
1543
-
1544
- function isNativePluginValue(entry: unknown): boolean {
1545
- // A non-empty string is a native specifier — see the matching Go-side
1546
- // and JS-loader implementations.
1547
- if (typeof entry === "string") return entry.length > 0;
1548
- if (entry === null || typeof entry !== "object") return false;
1549
- // ESM-from-CJS interop wraps CJS modules' "exports.default" so a
1550
- // contributor authored as "export default plugin" lands under a
1551
- // ".default" indirection. Walk a few hops so both "export default"
1552
- // and plain "module.exports = plugin" contributors register as
1553
- // native here.
1554
- let current = entry as Record<string, unknown>;
1555
- for (let i = 0; i < 4; i++) {
1556
- if (typeof current.source === "string" && (current.source as string).length > 0) {
1557
- return true;
1276
+ // toSerializableConfig copies every ITtscLintConfig key onto a plain object so
1277
+ // it survives the JSON round trip to the Go sidecar. Every key is copied
1278
+ // verbatim files, ignores, extends, plugins, rules, AND format — so a config
1279
+ // whose only key is "format" is not silently dropped.
1280
+ function toSerializableConfig(value: Record<string, unknown>): Record<string, unknown> {
1281
+ const out: Record<string, unknown> = {};
1282
+ for (const key of ["files", "ignores", "extends", "plugins", "rules", "format"]) {
1283
+ if (hasOwn(value, key)) {
1284
+ out[key] = value[key];
1558
1285
  }
1559
- const next = current.default;
1560
- if (next === null || typeof next !== "object" || Array.isArray(next)) {
1561
- return false;
1562
- }
1563
- current = next as Record<string, unknown>;
1564
1286
  }
1565
- return false;
1287
+ return out;
1566
1288
  }
1567
1289
  `, importLiteral)
1568
1290
  }
@@ -1740,11 +1462,8 @@ func setEnv(env []string, key, value string) []string {
1740
1462
  return append(env, prefix+value)
1741
1463
  }
1742
1464
 
1743
- // parseExternalRuleEntry delegates to parseRuleEntry. Both the inline
1744
- // (tsconfig) and external (flat-config file) paths accept exactly the
1745
- // same severity-tuple grammar, so a single implementation suffices.
1746
- // The name is preserved because test files in the same package call it
1747
- // directly.
1465
+ // parseExternalRuleEntry delegates to parseRuleEntry. It is kept under this
1466
+ // name because test files in the same package call it directly.
1748
1467
  func parseExternalRuleEntry(v any) (Severity, json.RawMessage, error) {
1749
1468
  return parseRuleEntry(v)
1750
1469
  }
@@ -1842,19 +1561,111 @@ func normalizeGlobPattern(pattern string) string {
1842
1561
  // matchGlob tests whether `name` matches `pattern` using the ESLint-compatible
1843
1562
  // glob semantics implemented by matchGlobParts. Both strings are trimmed of
1844
1563
  // leading/trailing slashes before splitting on "/" so that empty segments do
1845
- // not appear in the part slices.
1564
+ // not appear in the part slices. Brace alternatives (`{a,b,c}`) are expanded
1565
+ // before matching so patterns like `src/foo/{a.ts,b.ts}` reach every branch —
1566
+ // Go's `filepath.Match` does not honor brace expansion natively.
1846
1567
  func matchGlob(pattern, name string) bool {
1847
1568
  pattern = strings.Trim(pattern, "/")
1848
1569
  name = strings.Trim(name, "/")
1849
1570
  if pattern == "" {
1850
1571
  return name == ""
1851
1572
  }
1852
- patternParts := strings.Split(pattern, "/")
1853
1573
  nameParts := []string{}
1854
1574
  if name != "" {
1855
1575
  nameParts = strings.Split(name, "/")
1856
1576
  }
1857
- return matchGlobParts(patternParts, nameParts)
1577
+ for _, expanded := range expandBraces(pattern) {
1578
+ if matchGlobParts(strings.Split(expanded, "/"), nameParts) {
1579
+ return true
1580
+ }
1581
+ }
1582
+ return false
1583
+ }
1584
+
1585
+ // expandBraces expands shell-style brace alternatives (`{a,b,c}`) in `pattern`
1586
+ // into the equivalent flat list of patterns. The expansion is recursive: a
1587
+ // pattern with multiple brace groups produces the Cartesian product across all
1588
+ // groups. Patterns with no braces, or with an unmatched opening `{`, are
1589
+ // returned unchanged so that callers can treat the result as an authoritative
1590
+ // list of every concrete alternative the user wrote.
1591
+ //
1592
+ // Only top-level braces are recognized; nested braces inside another brace
1593
+ // group's alternative are honored by the recursion in alternative expansion,
1594
+ // but escaped braces (`\{`, `\}`) are not currently supported because lint
1595
+ // config patterns have no reason to embed literal braces. If a user ever needs
1596
+ // one, the simplest workaround is to author the glob without the brace group.
1597
+ func expandBraces(pattern string) []string {
1598
+ open := strings.IndexByte(pattern, '{')
1599
+ if open < 0 {
1600
+ return []string{pattern}
1601
+ }
1602
+ // Find the matching close brace, accounting for nested groups so the
1603
+ // outermost group is split first. A pattern with no matching close brace
1604
+ // is treated as a literal — return it unchanged. `closeIdx` shadows no
1605
+ // builtin (unlike the natural `close` name), which keeps `go vet` quiet.
1606
+ depth := 0
1607
+ closeIdx := -1
1608
+ for i := open; i < len(pattern); i++ {
1609
+ switch pattern[i] {
1610
+ case '{':
1611
+ depth++
1612
+ case '}':
1613
+ depth--
1614
+ if depth == 0 {
1615
+ closeIdx = i
1616
+ }
1617
+ }
1618
+ if closeIdx >= 0 {
1619
+ break
1620
+ }
1621
+ }
1622
+ if closeIdx < 0 {
1623
+ return []string{pattern}
1624
+ }
1625
+ prefix := pattern[:open]
1626
+ suffix := pattern[closeIdx+1:]
1627
+ // Split the brace body on top-level commas so nested groups remain
1628
+ // intact for the recursive expansion below.
1629
+ body := pattern[open+1 : closeIdx]
1630
+ alternatives := splitBraceAlternatives(body)
1631
+ // Expand each alternative against the suffix; the suffix may itself
1632
+ // contain further brace groups, which the recursive call handles.
1633
+ suffixExpansions := expandBraces(suffix)
1634
+ out := make([]string, 0, len(alternatives)*len(suffixExpansions))
1635
+ for _, alt := range alternatives {
1636
+ for _, altExpanded := range expandBraces(alt) {
1637
+ for _, suf := range suffixExpansions {
1638
+ out = append(out, prefix+altExpanded+suf)
1639
+ }
1640
+ }
1641
+ }
1642
+ return out
1643
+ }
1644
+
1645
+ // splitBraceAlternatives splits the body of a brace group on top-level commas.
1646
+ // Commas inside a nested `{...}` are not separators — the matching close brace
1647
+ // is tracked so `a,{b,c},d` splits into three alternatives, not four.
1648
+ func splitBraceAlternatives(body string) []string {
1649
+ out := []string{}
1650
+ depth := 0
1651
+ start := 0
1652
+ for i := 0; i < len(body); i++ {
1653
+ switch body[i] {
1654
+ case '{':
1655
+ depth++
1656
+ case '}':
1657
+ if depth > 0 {
1658
+ depth--
1659
+ }
1660
+ case ',':
1661
+ if depth == 0 {
1662
+ out = append(out, body[start:i])
1663
+ start = i + 1
1664
+ }
1665
+ }
1666
+ }
1667
+ out = append(out, body[start:])
1668
+ return out
1858
1669
  }
1859
1670
 
1860
1671
  // matchGlobParts recursively matches path segments against pattern segments.