@ttsc/lint 0.12.3 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/lib/defaultFormat.d.ts +10 -11
  2. package/lib/defaultFormat.js +10 -11
  3. package/lib/defaultFormat.js.map +1 -1
  4. package/lib/index.js +233 -135
  5. package/lib/index.js.map +1 -1
  6. package/lib/structures/ITtscLintConfig.d.ts +2 -2
  7. package/lib/structures/ITtscLintFormatConfig.d.ts +53 -55
  8. package/lib/structures/ITtscLintPluginConfig.d.ts +10 -77
  9. package/lib/structures/ITtscLintPluginMeta.d.ts +9 -1
  10. package/lib/structures/TtscLintRule.d.ts +10 -1
  11. package/lib/structures/TtscLintRuleMap.d.ts +2 -2
  12. package/lib/structures/TtscLintRuleOptions.d.ts +14 -0
  13. package/linthost/ast_helpers.go +80 -11
  14. package/linthost/compile.go +116 -112
  15. package/linthost/config.go +647 -687
  16. package/linthost/config_format.go +272 -247
  17. package/linthost/contrib_adapter.go +7 -0
  18. package/linthost/directives.go +116 -0
  19. package/linthost/dispatch.go +33 -33
  20. package/linthost/engine.go +156 -43
  21. package/linthost/fix.go +47 -27
  22. package/linthost/flags_gen.go +31 -0
  23. package/linthost/format.go +119 -3
  24. package/linthost/host.go +115 -5
  25. package/linthost/print_dispatch.go +128 -28
  26. package/linthost/print_doc.go +23 -0
  27. package/linthost/print_engine.go +168 -4
  28. package/linthost/print_nodes_array.go +17 -7
  29. package/linthost/print_nodes_call.go +138 -20
  30. package/linthost/print_nodes_function.go +353 -0
  31. package/linthost/print_nodes_imports.go +46 -29
  32. package/linthost/print_nodes_list.go +86 -5
  33. package/linthost/print_nodes_object.go +56 -11
  34. package/linthost/rules_arrays.go +5 -2
  35. package/linthost/rules_debugger.go +3 -2
  36. package/linthost/rules_dupes.go +7 -4
  37. package/linthost/rules_empty.go +3 -2
  38. package/linthost/rules_escape.go +35 -3
  39. package/linthost/rules_eval.go +3 -0
  40. package/linthost/rules_finally.go +11 -0
  41. package/linthost/rules_format_jsdoc.go +7 -0
  42. package/linthost/rules_format_print_width.go +270 -15
  43. package/linthost/rules_format_quotes.go +3 -0
  44. package/linthost/rules_format_sort_imports.go +4 -0
  45. package/linthost/rules_gap.go +75 -3
  46. package/linthost/rules_imports.go +5 -7
  47. package/linthost/rules_logic.go +75 -5
  48. package/linthost/rules_loops.go +4 -0
  49. package/linthost/rules_misc.go +4 -2
  50. package/linthost/rules_params.go +7 -11
  51. package/linthost/rules_problems.go +89 -22
  52. package/linthost/rules_promise.go +15 -0
  53. package/linthost/rules_protos.go +3 -2
  54. package/linthost/rules_self.go +6 -0
  55. package/linthost/rules_strings.go +10 -0
  56. package/linthost/rules_suggestions.go +174 -9
  57. package/linthost/rules_throw.go +2 -0
  58. package/linthost/rules_ts.go +13 -0
  59. package/linthost/rules_ts_extra.go +25 -8
  60. package/linthost/rules_var.go +15 -2
  61. package/package.json +3 -3
  62. package/plugin/main.go +4 -4
  63. package/rule/astutil/astutil.go +12 -0
  64. package/rule/rule.go +123 -123
  65. package/src/defaultFormat.ts +10 -11
  66. package/src/index.ts +257 -168
  67. package/src/structures/ITtscLintConfig.ts +2 -2
  68. package/src/structures/ITtscLintFormatConfig.ts +53 -55
  69. package/src/structures/ITtscLintPluginConfig.ts +10 -83
  70. package/src/structures/ITtscLintPluginMeta.ts +9 -1
  71. package/src/structures/TtscLintRule.ts +10 -1
  72. package/src/structures/TtscLintRuleMap.ts +17 -18
  73. package/src/structures/TtscLintRuleOptions.ts +15 -0
  74. package/linthost/eslint_runtime.go +0 -320
@@ -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,22 +87,34 @@ 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
 
103
+ // RuleResolver is the engine-facing view of a resolved lint configuration.
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).
101
108
  type RuleResolver interface {
109
+ // ResolveRules returns the effective severity map for the given source file.
110
+ // Implementations that support `files`/`ignores` patterns apply them here;
111
+ // flat RuleConfig always returns all rules unchanged.
102
112
  ResolveRules(fileName string) ResolvedRuleConfig
113
+ // ActiveRuleNames returns the sorted names of every rule that is not SeverityOff
114
+ // in at least one config entry. Used to build the engine's dispatch table.
103
115
  ActiveRuleNames() []string
116
+ // EnabledRuleConfig returns the project-wide severity map for rules that are
117
+ // not SeverityOff. Where multiple entries disagree, SeverityError wins.
104
118
  EnabledRuleConfig() RuleConfig
105
119
  // RuleOptions returns the raw JSON options for `name`, or nil when the
106
120
  // rule was configured with a severity alone. Returns nil for unknown
@@ -108,14 +122,20 @@ type RuleResolver interface {
108
122
  RuleOptions(name string) json.RawMessage
109
123
  }
110
124
 
125
+ // ResolveRules implements RuleResolver. A flat RuleConfig has no glob scoping,
126
+ // so every file receives the full map unchanged.
111
127
  func (c RuleConfig) ResolveRules(string) ResolvedRuleConfig {
112
128
  return ResolvedRuleConfig{Rules: c}
113
129
  }
114
130
 
131
+ // ActiveRuleNames implements RuleResolver. Returns rule names whose severity
132
+ // is not SeverityOff, sorted for deterministic engine dispatch-table construction.
115
133
  func (c RuleConfig) ActiveRuleNames() []string {
116
134
  return sortedRuleNames(c, func(sev Severity) bool { return sev != SeverityOff })
117
135
  }
118
136
 
137
+ // EnabledRuleConfig implements RuleResolver. Returns a copy containing only the
138
+ // non-off entries; used to populate engine state and diagnostic reporting.
119
139
  func (c RuleConfig) EnabledRuleConfig() RuleConfig {
120
140
  out := RuleConfig{}
121
141
  for name, sev := range c {
@@ -127,30 +147,37 @@ func (c RuleConfig) EnabledRuleConfig() RuleConfig {
127
147
  }
128
148
 
129
149
  // RuleOptions on a bare RuleConfig is always nil — this form is the
130
- // severity-only path used by Go unit tests and the legacy inline-rules
131
- // surface that predates option support.
150
+ // severity-only path used by Go unit tests and rule constructors that
151
+ // predate option support.
132
152
  func (RuleConfig) RuleOptions(string) json.RawMessage { return nil }
133
153
 
134
- // InlineRuleResolver pairs a severity map with an options map for
135
- // tsconfig-inline rule blocks. The fields are public so tests can
136
- // construct one without going through ParseRulesWithOptions.
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.
137
157
  type InlineRuleResolver struct {
138
158
  Rules RuleConfig
139
159
  Options RuleOptionsMap
140
160
  }
141
161
 
162
+ // ResolveRules implements RuleResolver. Inline rules have no glob scoping;
163
+ // the full map applies to every file.
142
164
  func (r InlineRuleResolver) ResolveRules(string) ResolvedRuleConfig {
143
165
  return ResolvedRuleConfig{Rules: r.Rules}
144
166
  }
145
167
 
168
+ // ActiveRuleNames implements RuleResolver by delegating to the inner RuleConfig.
146
169
  func (r InlineRuleResolver) ActiveRuleNames() []string {
147
170
  return r.Rules.ActiveRuleNames()
148
171
  }
149
172
 
173
+ // EnabledRuleConfig implements RuleResolver by delegating to the inner RuleConfig.
150
174
  func (r InlineRuleResolver) EnabledRuleConfig() RuleConfig {
151
175
  return r.Rules.EnabledRuleConfig()
152
176
  }
153
177
 
178
+ // RuleOptions implements RuleResolver. Returns the raw JSON options blob for
179
+ // `name`, or nil when the rule was configured without options or the name is
180
+ // unknown.
154
181
  func (r InlineRuleResolver) RuleOptions(name string) json.RawMessage {
155
182
  if r.Options == nil {
156
183
  return nil
@@ -158,11 +185,18 @@ func (r InlineRuleResolver) RuleOptions(name string) json.RawMessage {
158
185
  return r.Options[name]
159
186
  }
160
187
 
188
+ // ConfigStore holds the parsed representation of a lint config file. It
189
+ // implements RuleResolver with per-file glob scoping: ResolveRules walks the
190
+ // entries in declaration order and the last matching entry wins. Options are
191
+ // intentionally NOT per-file — one project-wide options map is kept so rule
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.
161
198
  type ConfigStore struct {
162
- entries []ConfigEntry
163
- externalConfigPath string
164
- eslintRuntime bool
165
- eslintRuntimeRequired bool
199
+ entries []ConfigEntry
166
200
  // options is a flat rule-name → JSON map. Options are not scoped by
167
201
  // `files` / `ignores`: a rule's behavior is a single project-wide
168
202
  // configuration even when its severity is per-file. The simplification
@@ -179,6 +213,11 @@ func (s *ConfigStore) RuleOptions(name string) json.RawMessage {
179
213
  return s.options[name]
180
214
  }
181
215
 
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.
182
221
  type ConfigEntry struct {
183
222
  BaseDir string
184
223
  Files []string
@@ -187,6 +226,10 @@ type ConfigEntry struct {
187
226
  IgnoreOnly bool
188
227
  }
189
228
 
229
+ // ResolveRules implements RuleResolver. Ignore-only entries are checked first;
230
+ // if one matches, the file is marked Ignored and linting is skipped entirely.
231
+ // Otherwise the entries are walked in declaration order and the last matching
232
+ // entry wins (later entries shadow earlier ones for the same rule name).
190
233
  func (s *ConfigStore) ResolveRules(fileName string) ResolvedRuleConfig {
191
234
  if s == nil {
192
235
  return ResolvedRuleConfig{Rules: RuleConfig{}}
@@ -208,6 +251,10 @@ func (s *ConfigStore) ResolveRules(fileName string) ResolvedRuleConfig {
208
251
  return ResolvedRuleConfig{Rules: out}
209
252
  }
210
253
 
254
+ // ActiveRuleNames implements RuleResolver. Returns the sorted union of all rule
255
+ // names that are not SeverityOff across every non-ignore-only config entry,
256
+ // regardless of which files they apply to. The engine uses this to build the
257
+ // per-rule dispatch table before file iteration begins.
211
258
  func (s *ConfigStore) ActiveRuleNames() []string {
212
259
  if s == nil {
213
260
  return nil
@@ -226,6 +273,9 @@ func (s *ConfigStore) ActiveRuleNames() []string {
226
273
  return sortedRuleNames(active, func(Severity) bool { return true })
227
274
  }
228
275
 
276
+ // EnabledRuleConfig implements RuleResolver. Returns the project-wide severity
277
+ // map for non-off rules. Where multiple entries configure the same rule,
278
+ // SeverityError is sticky — it cannot be downgraded by a later warning entry.
229
279
  func (s *ConfigStore) EnabledRuleConfig() RuleConfig {
230
280
  out := RuleConfig{}
231
281
  if s == nil {
@@ -247,6 +297,9 @@ func (s *ConfigStore) EnabledRuleConfig() RuleConfig {
247
297
  return out
248
298
  }
249
299
 
300
+ // Flatten returns the unconstrained union of all non-ignore-only entries,
301
+ // including SeverityOff rules. Used by LoadRuleConfig (callers that expect a
302
+ // plain RuleConfig). Later entries shadow earlier ones for the same rule name.
250
303
  func (s *ConfigStore) Flatten() RuleConfig {
251
304
  out := RuleConfig{}
252
305
  if s == nil {
@@ -263,31 +316,6 @@ func (s *ConfigStore) Flatten() RuleConfig {
263
316
  return out
264
317
  }
265
318
 
266
- func (s *ConfigStore) ExternalConfigPath() string {
267
- if s == nil {
268
- return ""
269
- }
270
- return s.externalConfigPath
271
- }
272
-
273
- func (s *ConfigStore) WantsESLintRuntime() bool {
274
- if s == nil {
275
- return false
276
- }
277
- if s.eslintRuntime {
278
- return true
279
- }
280
- base := filepath.Base(s.externalConfigPath)
281
- return strings.HasPrefix(base, "eslint.config.")
282
- }
283
-
284
- func (s *ConfigStore) RequiresESLintRuntime() bool {
285
- if s == nil {
286
- return false
287
- }
288
- return s.eslintRuntimeRequired
289
- }
290
-
291
319
  func (e ConfigEntry) matchesFile(fileName string) bool {
292
320
  if len(e.Files) > 0 && !matchAnyPattern(e.BaseDir, e.Files, fileName) {
293
321
  return false
@@ -302,8 +330,7 @@ func (e ConfigEntry) matchesIgnores(fileName string) bool {
302
330
  return len(e.Ignores) > 0 && matchAnyPattern(e.BaseDir, e.Ignores, fileName)
303
331
  }
304
332
 
305
- // ParseRules normalizes the standard native rules map from a tsconfig plugin
306
- // entry.
333
+ // ParseRules normalizes a rule severity map.
307
334
  //
308
335
  // Severity values:
309
336
  // - `"off"` → SeverityOff
@@ -327,7 +354,7 @@ func ParseRulesWithOptions(raw any) (RuleConfig, RuleOptionsMap, error) {
327
354
  }
328
355
  dict, ok := raw.(map[string]any)
329
356
  if !ok {
330
- 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)
331
358
  }
332
359
  cfg := make(RuleConfig, len(dict))
333
360
  opts := make(RuleOptionsMap)
@@ -344,11 +371,10 @@ func ParseRulesWithOptions(raw any) (RuleConfig, RuleOptionsMap, error) {
344
371
  return cfg, opts, nil
345
372
  }
346
373
 
347
- // parseRuleEntry splits an ESLint-shaped rule entry into its severity
348
- // and (optional) options payload. Bare severity literals produce a nil
349
- // options blob; `[severity]` (no options) does the same; `[severity,
350
- // options]` re-serializes the options to JSON so each rule can decode it
351
- // into its own struct later.
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.
352
378
  func parseRuleEntry(value any) (Severity, json.RawMessage, error) {
353
379
  if tuple, ok := value.([]any); ok {
354
380
  if len(tuple) == 0 {
@@ -368,10 +394,9 @@ func parseRuleEntry(value any) (Severity, json.RawMessage, error) {
368
394
  return sev, nil, nil
369
395
  }
370
396
  if _, ok := tuple[1].(map[string]any); !ok {
371
- // ESLint accepts string-typed positional options as a shorthand
372
- // (e.g. `["error", "single"]` for the `quotes` rule). ttsc does
373
- // not: every option struct in TtscLintRuleOptions is an object,
374
- // and silently encoding a non-object slot would land in
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
375
400
  // DecodeOptions as a decode error that every rule discards. Fail
376
401
  // loudly so users discover the proper `["error", { … }]` form.
377
402
  return SeverityOff, nil, fmt.Errorf("severity tuple's options slot must be an object, got %T", tuple[1])
@@ -386,6 +411,9 @@ func parseRuleEntry(value any) (Severity, json.RawMessage, error) {
386
411
  return sev, nil, err
387
412
  }
388
413
 
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.
389
417
  func parseExternalConfigRules(raw any) (RuleConfig, error) {
390
418
  store, err := parseExternalConfigStore(raw, "")
391
419
  if err != nil {
@@ -394,164 +422,175 @@ func parseExternalConfigRules(raw any) (RuleConfig, error) {
394
422
  return store.Flatten(), nil
395
423
  }
396
424
 
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.
397
428
  func parseExternalConfigStore(raw any, configDir string) (*ConfigStore, error) {
398
- return parseExternalConfigStoreWithRuntimeMode(raw, configDir, false)
429
+ return collectConfigStore(raw, configDir, "")
399
430
  }
400
431
 
401
- func parseExternalConfigStoreForFile(raw any, configDir string) (*ConfigStore, error) {
402
- return parseExternalConfigStoreWithRuntimeMode(raw, configDir, true)
403
- }
404
-
405
- 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) {
406
438
  store := &ConfigStore{}
407
- 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 {
408
444
  return nil, err
409
445
  }
410
446
  return store, nil
411
447
  }
412
448
 
413
- 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 {
414
497
  if raw == nil {
415
498
  return nil
416
499
  }
417
- switch typed := raw.(type) {
418
- case []any:
419
- for i, item := range typed {
420
- if err := collectExternalConfigEntries(store, item, baseDir, fmt.Sprintf("%s[%d]", path, i), allowRuntimeOnly); err != nil {
421
- return err
422
- }
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)
423
512
  }
424
- return nil
425
- case map[string]any:
426
- if isESLintConfigObject(typed) {
427
- if marker, ok := typed["__ttscLintEslintRuntime"].(bool); ok && marker {
428
- store.eslintRuntime = true
429
- store.eslintRuntimeRequired = true
430
- }
431
- localBaseDir := baseDir
432
- if rawBasePath, ok := typed["basePath"]; ok {
433
- basePath, ok := rawBasePath.(string)
434
- if !ok {
435
- return fmt.Errorf("@ttsc/lint: %s.basePath must be a string, got %T", path, rawBasePath)
436
- }
437
- if filepath.IsAbs(basePath) {
438
- localBaseDir = basePath
439
- } else {
440
- localBaseDir = filepath.Join(baseDir, basePath)
441
- }
442
- }
443
- if hasESLintRuntimeFields(typed) {
444
- store.eslintRuntime = true
445
- store.eslintRuntimeRequired = true
446
- }
447
- if extended, ok := typed["extends"]; ok {
448
- if err := collectExternalExtends(store, extended, localBaseDir, path+".extends", allowRuntimeOnly); err != nil {
449
- return err
450
- }
451
- }
452
- files, err := parsePatternList(typed["files"], path+".files")
453
- if err != nil {
454
- return err
455
- }
456
- ignores, err := parsePatternList(typed["ignores"], path+".ignores")
457
- if err != nil {
458
- return err
459
- }
460
- rulesValue, hasRules := typed["rules"]
461
- formatValue, hasFormat := typed["format"]
462
- if hasRules || hasFormat {
463
- // Expand the format block (if any) into a rules-shaped map,
464
- // then overlay any explicit `rules` entries. `rules`-wins
465
- // semantics match the inline path; the conflict-resolution
466
- // table is identical regardless of which surface a user
467
- // chose.
468
- var formatRulesRaw map[string]any
469
- if hasFormat {
470
- fmtMap, ok := formatValue.(map[string]any)
471
- if !ok {
472
- return fmt.Errorf("@ttsc/lint: %s.format must be an object, got %T", path, formatValue)
473
- }
474
- expanded, err := expandFormatBlock(fmtMap)
475
- if err != nil {
476
- return err
477
- }
478
- formatRulesRaw = expanded
479
- }
480
- var rulesMap map[string]any
481
- if hasRules {
482
- // `parseExternalRuleMapInto` accepts the raw map directly.
483
- // Coerce here to feed the same merge pipeline as the
484
- // inline path.
485
- typedMap, ok := rulesValue.(map[string]any)
486
- if !ok {
487
- return fmt.Errorf("@ttsc/lint: %s.rules must be a rule severity map, got %T", path, rulesValue)
488
- }
489
- rulesMap = typedMap
490
- }
491
- merged := mergeRuleMaps(formatRulesRaw, rulesMap)
492
- if len(merged) == 0 {
493
- return nil
494
- }
495
- parsed, err := parseExternalRuleMapInto(merged, path+".rules", store)
496
- if err != nil {
497
- return err
498
- }
499
- store.entries = append(store.entries, ConfigEntry{
500
- BaseDir: localBaseDir,
501
- Files: files,
502
- Ignores: ignores,
503
- Rules: parsed,
504
- })
505
- return nil
506
- }
507
- if len(files) == 0 && len(ignores) > 0 {
508
- store.entries = append(store.entries, ConfigEntry{
509
- BaseDir: localBaseDir,
510
- Ignores: ignores,
511
- IgnoreOnly: true,
512
- })
513
- }
514
- 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)
515
519
  }
516
- parsed, err := parseExternalRuleMapInto(typed, path, store)
520
+ location = filepath.Clean(location)
521
+ extendedChain, err := appendExtendsLink(chain, location)
517
522
  if err != nil {
518
523
  return err
519
524
  }
520
- store.entries = append(store.entries, ConfigEntry{
521
- BaseDir: baseDir,
522
- Rules: parsed,
523
- })
524
- return nil
525
- default:
526
- 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
+ }
527
532
  }
528
- }
529
533
 
530
- func collectExternalExtends(store *ConfigStore, raw any, baseDir, path string, allowRuntimeOnly bool) error {
531
- switch typed := raw.(type) {
532
- case string:
533
- if allowRuntimeOnly {
534
- store.eslintRuntime = true
535
- store.eslintRuntimeRequired = true
536
- 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
537
561
  }
538
- return fmt.Errorf("@ttsc/lint: %s must be an object or flat config array, got %T", path, raw)
539
- case []any:
540
- for i, item := range typed {
541
- itemPath := fmt.Sprintf("%s[%d]", path, i)
542
- if _, ok := item.(string); ok && allowRuntimeOnly {
543
- store.eslintRuntime = true
544
- store.eslintRuntimeRequired = true
545
- 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)
546
567
  }
547
- 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 {
548
574
  return err
549
575
  }
576
+ store.entries = append(store.entries, ConfigEntry{
577
+ BaseDir: baseDir,
578
+ Files: files,
579
+ Ignores: ignores,
580
+ Rules: parsed,
581
+ })
550
582
  }
551
583
  return nil
552
- default:
553
- return collectExternalConfigEntries(store, raw, baseDir, path, allowRuntimeOnly)
554
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
555
594
  }
556
595
 
557
596
  // parseExternalRuleMapInto parses the rules map and folds any
@@ -570,7 +609,7 @@ func parseExternalRuleMapInto(raw any, path string, store *ConfigStore) (RuleCon
570
609
 
571
610
  // collectExternalRuleMapWithOptions also records the rule's options blob
572
611
  // when the entry is a `[severity, options]` tuple. `opts` may be nil
573
- // when the caller does not need option capture (legacy paths).
612
+ // when the caller does not need option capture.
574
613
  func collectExternalRuleMapWithOptions(out RuleConfig, opts RuleOptionsMap, raw any, path string) error {
575
614
  dict, ok := raw.(map[string]any)
576
615
  if !ok {
@@ -590,110 +629,37 @@ func collectExternalRuleMapWithOptions(out RuleConfig, opts RuleOptionsMap, raw
590
629
  return nil
591
630
  }
592
631
 
593
- func isESLintConfigObject(value map[string]any) bool {
594
- for _, key := range []string{
595
- "basePath",
596
- "extends",
597
- "files",
598
- "format",
599
- "ignores",
600
- "languageOptions",
601
- "linterOptions",
602
- "name",
603
- "plugins",
604
- "processor",
605
- "rules",
606
- "settings",
607
- "__ttscLintEslintRuntime",
608
- } {
609
- if _, ok := value[key]; ok {
610
- return true
611
- }
612
- }
613
- return false
614
- }
615
-
616
- func hasESLintRuntimeFields(value map[string]any) bool {
617
- for _, key := range []string{
618
- "languageOptions",
619
- "linterOptions",
620
- "processor",
621
- "settings",
622
- } {
623
- if _, ok := value[key]; ok {
624
- return true
625
- }
626
- }
627
- if plugins, ok := value["plugins"]; ok {
628
- if !isNativePluginMap(plugins) {
629
- return true
630
- }
631
- }
632
- return false
633
- }
634
-
635
- // isNativePluginMap reports whether every entry in a flat-config
636
- // `plugins` map points at a ttsc-lint native contributor object
637
- // (carrying a non-empty string `source` field). Native contributors are
638
- // compiled into the lint binary at build time and require no JS ESLint
639
- // runtime; only mixed or pure-ESLint plugin maps require the runtime
640
- // fallback.
641
- func isNativePluginMap(value any) bool {
642
- dict, ok := value.(map[string]any)
643
- if !ok {
644
- 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": {},
645
643
  }
646
- if len(dict) == 0 {
647
- return true
648
- }
649
- for _, entry := range dict {
650
- if !isNativePluginValue(entry) {
651
- return false
652
- }
653
- }
654
- return true
655
- }
656
-
657
- func isNativePluginValue(entry any) bool {
658
- if entry == nil {
659
- return false
660
- }
661
- switch typed := entry.(type) {
662
- case string:
663
- // A non-empty string is a native npm specifier (matching the JS
664
- // factory's `normalizePluginValue` contract for `.js`/`.cjs`/`.ts`
665
- // configs and the JSON-only `readJsonConfigPlugins` path). The JS
666
- // factory resolves the specifier at load time and bakes the
667
- // contributor into the binary, so the Go sidecar should not flip
668
- // `eslintRuntimeRequired` for a file that already declared a
669
- // native specifier.
670
- return typed != ""
671
- case map[string]any:
672
- // Walk ESM-from-CJS `.default` indirection so a contributor authored
673
- // as `export default plugin` registers as native here, matching the
674
- // JS factory's `extractPluginSource` behavior.
675
- current := typed
676
- for i := 0; i < 4; i++ {
677
- if source, ok := current["source"].(string); ok && source != "" {
678
- return true
679
- }
680
- next, ok := current["default"].(map[string]any)
681
- if !ok {
682
- return false
683
- }
684
- 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)
685
647
  }
686
- return false
687
- default:
688
- return false
689
648
  }
649
+ return nil
690
650
  }
691
651
 
652
+ // normalizeExternalRuleName strips the standard typescript-eslint namespace
653
+ // prefixes so that rules like "@typescript-eslint/no-explicit-any" and the
654
+ // bare "no-explicit-any" key both resolve to the same engine-internal name.
692
655
  func normalizeExternalRuleName(name string) string {
693
656
  name = strings.TrimPrefix(name, "@typescript-eslint/")
694
657
  return strings.TrimPrefix(name, "typescript-eslint/")
695
658
  }
696
659
 
660
+ // parsePatternList coerces a raw config value to a string slice for use as a
661
+ // `files` or `ignores` pattern list. Accepts a bare string (single-pattern
662
+ // shorthand) or a string array. Empty patterns are rejected eagerly.
697
663
  func parsePatternList(raw any, path string) ([]string, error) {
698
664
  if raw == nil {
699
665
  return nil, nil
@@ -722,10 +688,9 @@ func parsePatternList(raw any, path string) ([]string, error) {
722
688
  }
723
689
  }
724
690
 
725
- // LoadRuleConfig resolves the lint config for one plugin entry. The only
726
- // accepted lint-specific tsconfig key is `config`; it may be either an inline
727
- // rules object or a string path to a standalone config file. Relative config
728
- // 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.
729
694
  func LoadRuleConfig(entry *PluginEntry, cwd, tsconfigPath string) (RuleConfig, error) {
730
695
  resolver, err := LoadConfigResolver(entry, cwd, tsconfigPath)
731
696
  if err != nil {
@@ -741,22 +706,17 @@ func LoadRuleConfig(entry *PluginEntry, cwd, tsconfigPath string) (RuleConfig, e
741
706
  }
742
707
  }
743
708
 
744
- // LoadConfigResolver resolves one plugin entry into the engine-facing
745
- // config model.
709
+ // LoadConfigResolver resolves one plugin entry into the engine-facing config
710
+ // model.
746
711
  //
747
- // 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.
748
717
  //
749
- // - `rules` (inline severity map) + `extends` (config file path) —
750
- // the canonical fields mirroring ESLint flat-config vocabulary.
751
- // - `config` (legacy) — accepts the same string-or-map values but
752
- // emits a one-time stderr deprecation notice. Removed in a future
753
- // minor.
754
- //
755
- // `rules` and `extends` are mutually exclusive on a single plugin
756
- // entry; mixing legacy `config` with either new field is rejected.
757
- // `configFile` and `configPath` remain reserved keywords surfaced with
758
- // a hint pointing at `extends`, in case a user mistakenly reaches for
759
- // 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.
760
720
  func LoadConfigResolver(entry *PluginEntry, cwd, tsconfigPath string) (RuleResolver, error) {
761
721
  if entry == nil {
762
722
  return RuleConfig{}, nil
@@ -765,102 +725,17 @@ func LoadConfigResolver(entry *PluginEntry, cwd, tsconfigPath string) (RuleResol
765
725
  if inline == nil {
766
726
  inline = map[string]any{}
767
727
  }
768
- for _, key := range []string{"configFile", "configPath"} {
769
- if _, ok := inline[key]; ok {
770
- return nil, fmt.Errorf("@ttsc/lint: %q is not supported; use \"extends\"", key)
771
- }
772
- }
773
-
774
- rulesValue, hasRules := inline["rules"]
775
- extendsValue, hasExtends := inline["extends"]
776
- legacyValue, hasLegacy := inline["config"]
777
- formatValue, hasFormat := inline["format"]
778
-
779
- if hasLegacy && (hasRules || hasExtends || hasFormat) {
780
- sibling := ""
781
- switch {
782
- case hasFormat && !hasRules && !hasExtends:
783
- sibling = "format"
784
- case hasExtends && !hasRules && !hasFormat:
785
- sibling = "extends"
786
- case hasRules && !hasExtends && !hasFormat:
787
- sibling = "rules"
788
- default:
789
- sibling = "rules/extends/format"
790
- }
791
- return nil, fmt.Errorf("@ttsc/lint: tsconfig plugin entry mixes legacy \"config\" with the new %q field; remove \"config\" (deprecated)", sibling)
792
- }
793
- if hasRules && hasExtends {
794
- 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")
795
- }
796
- if hasFormat && hasExtends {
797
- 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")
798
- }
799
-
800
- // Expand the format block (if any) into a rules-shaped map.
801
- var formatRulesRaw map[string]any
802
- if hasFormat {
803
- formatMap, ok := formatValue.(map[string]any)
804
- if !ok {
805
- return nil, fmt.Errorf("@ttsc/lint: \"format\" must be an object, got %T", formatValue)
806
- }
807
- expanded, err := expandFormatBlock(formatMap)
808
- if err != nil {
809
- return nil, err
810
- }
811
- formatRulesRaw = expanded
812
- }
813
728
 
814
- if hasRules || hasFormat {
815
- // Merge format defaults with inline rule overrides; the `rules`
816
- // entry wins on key collisions (more explicit surface).
817
- var rulesMap map[string]any
818
- if hasRules {
819
- typed, ok := rulesValue.(map[string]any)
820
- if !ok {
821
- return nil, fmt.Errorf("@ttsc/lint: \"rules\" must be a rule severity map, got %T", rulesValue)
822
- }
823
- rulesMap = typed
824
- }
825
- merged := mergeRuleMaps(formatRulesRaw, rulesMap)
826
- if len(merged) == 0 {
827
- return InlineRuleResolver{Rules: RuleConfig{}, Options: RuleOptionsMap{}}, nil
828
- }
829
- cfg, opts, err := ParseRulesWithOptions(merged)
830
- if err != nil {
831
- return nil, err
832
- }
833
- return InlineRuleResolver{Rules: cfg, Options: opts}, nil
834
- }
835
- if hasExtends {
836
- extendsStr, ok := extendsValue.(string)
729
+ if configFileValue, ok := inline["configFile"]; ok {
730
+ configFile, ok := configFileValue.(string)
837
731
  if !ok {
838
- 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)
839
733
  }
840
- if strings.TrimSpace(extendsStr) == "" {
841
- return nil, fmt.Errorf("@ttsc/lint: \"extends\" must not be empty")
842
- }
843
- location := resolveConfigFilePath(extendsStr, cwd, tsconfigPath)
844
- return loadExternalConfigResolver(location)
845
- }
846
- if hasLegacy {
847
- emitLegacyConfigDeprecation()
848
- switch typed := legacyValue.(type) {
849
- case string:
850
- if strings.TrimSpace(typed) == "" {
851
- return nil, fmt.Errorf("@ttsc/lint: legacy \"config\" must not be empty")
852
- }
853
- location := resolveConfigFilePath(typed, cwd, tsconfigPath)
854
- return loadExternalConfigResolver(location)
855
- case map[string]any:
856
- cfg, opts, err := ParseRulesWithOptions(typed)
857
- if err != nil {
858
- return nil, err
859
- }
860
- return InlineRuleResolver{Rules: cfg, Options: opts}, nil
861
- default:
862
- 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")
863
736
  }
737
+ location := resolveConfigFilePath(configFile, cwd, tsconfigPath)
738
+ return loadConfigResolver(location)
864
739
  }
865
740
 
866
741
  discovered, err := findLintConfigFile(cwd, tsconfigPath)
@@ -868,29 +743,22 @@ func LoadConfigResolver(entry *PluginEntry, cwd, tsconfigPath string) (RuleResol
868
743
  return nil, err
869
744
  }
870
745
  if discovered == "" {
871
- 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)
872
747
  }
873
- return loadExternalConfigResolver(discovered)
874
- }
875
-
876
- var legacyConfigDeprecationOnce sync.Once
877
-
878
- func emitLegacyConfigDeprecation() {
879
- legacyConfigDeprecationOnce.Do(func() {
880
- 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.")
881
- })
748
+ return loadConfigResolver(discovered)
882
749
  }
883
750
 
884
- 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) {
885
754
  raw, err := loadConfigFile(location)
886
755
  if err != nil {
887
756
  return nil, err
888
757
  }
889
- store, err := parseExternalConfigStoreForFile(raw, filepath.Dir(location))
758
+ store, err := collectConfigStore(raw, filepath.Dir(location), location)
890
759
  if err != nil {
891
760
  return nil, err
892
761
  }
893
- store.externalConfigPath = location
894
762
  return store, nil
895
763
  }
896
764
 
@@ -913,12 +781,6 @@ func findLintConfigFile(cwd, tsconfigPath string) (string, error) {
913
781
  "ttsc-lint.config.ts",
914
782
  "ttsc-lint.config.mts",
915
783
  "ttsc-lint.config.cts",
916
- "eslint.config.js",
917
- "eslint.config.mjs",
918
- "eslint.config.cjs",
919
- "eslint.config.ts",
920
- "eslint.config.mts",
921
- "eslint.config.cts",
922
784
  } {
923
785
  candidate := filepath.Join(dir, name)
924
786
  if stat, err := os.Stat(candidate); err == nil && !stat.IsDir() {
@@ -930,7 +792,7 @@ func findLintConfigFile(cwd, tsconfigPath string) (string, error) {
930
792
  for _, m := range matches {
931
793
  names = append(names, filepath.Base(m))
932
794
  }
933
- 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, ", "))
934
796
  }
935
797
  if len(matches) == 1 {
936
798
  return matches[0], nil
@@ -943,6 +805,9 @@ func findLintConfigFile(cwd, tsconfigPath string) (string, error) {
943
805
  }
944
806
  }
945
807
 
808
+ // resolveConfigFilePath resolves a user-supplied config path to an absolute
809
+ // path. Absolute paths are returned unchanged; relative paths are joined to the
810
+ // tsconfig directory (or cwd when no tsconfig is set).
946
811
  func resolveConfigFilePath(configPath, cwd, tsconfigPath string) string {
947
812
  if filepath.IsAbs(configPath) {
948
813
  return configPath
@@ -950,6 +815,10 @@ func resolveConfigFilePath(configPath, cwd, tsconfigPath string) string {
950
815
  return filepath.Join(tsconfigBaseDir(cwd, tsconfigPath), configPath)
951
816
  }
952
817
 
818
+ // discoveryConfigBaseDir returns the directory from which auto-discovery walks
819
+ // upward when no explicit config path is provided. Prefer the tsconfig
820
+ // directory over cwd so that nested package configs are found relative to the
821
+ // tsconfig that triggered the lint run.
953
822
  func discoveryConfigBaseDir(cwd, tsconfigPath string) string {
954
823
  if tsconfigPath != "" {
955
824
  resolvedTsconfig := tsconfigPath
@@ -961,6 +830,9 @@ func discoveryConfigBaseDir(cwd, tsconfigPath string) string {
961
830
  return cwd
962
831
  }
963
832
 
833
+ // tsconfigBaseDir returns the directory that contains the tsconfig file, or
834
+ // cwd when tsconfigPath is empty. Used as the base for relative config paths
835
+ // supplied in the tsconfig plugin entry.
964
836
  func tsconfigBaseDir(cwd, tsconfigPath string) string {
965
837
  if tsconfigPath == "" {
966
838
  return cwd
@@ -972,20 +844,171 @@ func tsconfigBaseDir(cwd, tsconfigPath string) string {
972
844
  return filepath.Dir(resolvedTsconfig)
973
845
  }
974
846
 
847
+ // loadConfigFile loads and deserializes a lint config file at `location`.
848
+ // The file format is determined by extension: .json is parsed natively;
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.
975
853
  func loadConfigFile(location string) (any, error) {
976
854
  ext := strings.ToLower(filepath.Ext(location))
977
855
  switch ext {
978
856
  case ".json":
979
857
  return loadJSONConfigFile(location)
980
858
  case ".js", ".cjs", ".mjs":
981
- return loadScriptConfigFile(location)
859
+ return loadCachedConfigFile(location, loadScriptConfigFile)
982
860
  case ".ts", ".cts", ".mts":
983
- return loadTypeScriptConfigFile(location)
861
+ return loadCachedConfigFile(location, loadTypeScriptConfigFile)
984
862
  default:
985
863
  return nil, fmt.Errorf("@ttsc/lint: unsupported config file extension %q for %s", ext, location)
986
864
  }
987
865
  }
988
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
+
1010
+ // loadJSONConfigFile reads and JSON-parses a lint config file. A leading UTF-8
1011
+ // BOM is stripped before parsing so files saved by Windows editors are accepted.
989
1012
  func loadJSONConfigFile(location string) (any, error) {
990
1013
  body, err := os.ReadFile(location)
991
1014
  if err != nil {
@@ -1000,12 +1023,17 @@ func loadJSONConfigFile(location string) (any, error) {
1000
1023
  if err := json.Unmarshal(body, &out); err != nil {
1001
1024
  return nil, fmt.Errorf("@ttsc/lint: parse config file %s: %w", location, err)
1002
1025
  }
1003
- if !isConfigContainer(out) {
1004
- 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)
1005
1028
  }
1006
1029
  return out, nil
1007
1030
  }
1008
1031
 
1032
+ // loadScriptConfigFile evaluates a .js/.cjs/.mjs config file by running a
1033
+ // Node subprocess that dynamic-imports the file, resolves the exported config
1034
+ // through the same 8-hop default/config unwrap used by the TS loader, and
1035
+ // serializes the result as JSON to stdout. The subprocess has a
1036
+ // configLoaderTimeout deadline to prevent user code from hanging indefinitely.
1009
1037
  func loadScriptConfigFile(location string) (any, error) {
1010
1038
  const script = `
1011
1039
  const { pathToFileURL } = require("node:url");
@@ -1032,8 +1060,8 @@ const { pathToFileURL } = require("node:url");
1032
1060
  break;
1033
1061
  }
1034
1062
  const value = typeof current === "function" ? await current() : current;
1035
- if (value === null || typeof value !== "object") {
1036
- 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");
1037
1065
  }
1038
1066
  process.stdout.write(JSON.stringify(toSerializableConfig(value)));
1039
1067
  })().catch((error) => {
@@ -1041,114 +1069,18 @@ const { pathToFileURL } = require("node:url");
1041
1069
  process.exit(1);
1042
1070
  });
1043
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.
1044
1076
  function toSerializableConfig(value) {
1045
- if (Array.isArray(value)) {
1046
- return value.map((item) => toSerializableConfig(item));
1047
- }
1048
- if (value === null || typeof value !== "object") {
1049
- return value;
1050
- }
1051
- if (isESLintConfigObject(value)) {
1052
- const out = {};
1053
- if (hasESLintRuntimeFields(value)) {
1054
- out.__ttscLintEslintRuntime = true;
1055
- }
1056
- if (Object.prototype.hasOwnProperty.call(value, "basePath")) {
1057
- out.basePath = value.basePath;
1058
- }
1059
- if (Object.prototype.hasOwnProperty.call(value, "extends")) {
1060
- out.extends = toSerializableConfig(value.extends);
1061
- }
1062
- if (Object.prototype.hasOwnProperty.call(value, "files")) {
1063
- out.files = toSerializablePatterns(value.files, "files");
1064
- }
1065
- if (Object.prototype.hasOwnProperty.call(value, "ignores")) {
1066
- out.ignores = toSerializablePatterns(value.ignores, "ignores");
1067
- }
1068
- if (Object.prototype.hasOwnProperty.call(value, "rules")) {
1069
- out.rules = toSerializableRules(value.rules);
1070
- }
1071
- return out;
1072
- }
1073
- return { rules: toSerializableRules(value) };
1074
- }
1075
-
1076
- function toSerializableRules(value) {
1077
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
1078
- throw new Error("rules must be an object");
1079
- }
1080
- return Object.fromEntries(Object.entries(value));
1081
- }
1082
-
1083
- function toSerializablePatterns(value, key) {
1084
- if (typeof value === "string") {
1085
- return value;
1086
- }
1087
- if (Array.isArray(value)) {
1088
- return value.map((item, index) => {
1089
- if (typeof item !== "string") {
1090
- throw new Error(key + "[" + index + "] must be a string");
1091
- }
1092
- return item;
1093
- });
1094
- }
1095
- throw new Error(key + " must be a string or string array");
1096
- }
1097
-
1098
- function isESLintConfigObject(value) {
1099
- return [
1100
- "basePath",
1101
- "extends",
1102
- "files",
1103
- "ignores",
1104
- "languageOptions",
1105
- "linterOptions",
1106
- "name",
1107
- "plugins",
1108
- "processor",
1109
- "rules",
1110
- "settings",
1111
- ].some((key) => Object.prototype.hasOwnProperty.call(value, key));
1112
- }
1113
-
1114
- function hasESLintRuntimeFields(value) {
1115
- for (const key of ["languageOptions", "linterOptions", "processor", "settings"]) {
1116
- if (Object.prototype.hasOwnProperty.call(value, key)) return true;
1117
- }
1118
- if (Object.prototype.hasOwnProperty.call(value, "plugins")) {
1119
- if (!isNativePluginMap(value.plugins)) return true;
1120
- }
1121
- return false;
1122
- }
1123
-
1124
- function isNativePluginMap(value) {
1125
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
1126
- return false;
1127
- }
1128
- const entries = Object.values(value);
1129
- if (entries.length === 0) return true;
1130
- for (const entry of entries) {
1131
- if (!isNativePluginValue(entry)) return false;
1132
- }
1133
- return true;
1134
- }
1135
-
1136
- function isNativePluginValue(entry) {
1137
- // A non-empty string is a native specifier — JS factory resolves it
1138
- // at load time, so the loader must not flip the ESLint-runtime flag.
1139
- if (typeof entry === "string") return entry.length > 0;
1140
- if (entry === null || typeof entry !== "object") return false;
1141
- let current = entry;
1142
- for (let i = 0; i < 4; i++) {
1143
- if (typeof current.source === "string" && current.source.length > 0) {
1144
- return true;
1145
- }
1146
- if (current.default === null || typeof current.default !== "object") {
1147
- return false;
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];
1148
1081
  }
1149
- current = current.default;
1150
1082
  }
1151
- return false;
1083
+ return out;
1152
1084
  }
1153
1085
  `
1154
1086
  node := os.Getenv("TTSC_NODE_BINARY")
@@ -1176,12 +1108,17 @@ function isNativePluginValue(entry) {
1176
1108
  if err := json.Unmarshal(output, &out); err != nil {
1177
1109
  return nil, fmt.Errorf("@ttsc/lint: parse config file %s output: %w", location, err)
1178
1110
  }
1179
- if !isConfigContainer(out) {
1180
- 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)
1181
1113
  }
1182
1114
  return out, nil
1183
1115
  }
1184
1116
 
1117
+ // loadTypeScriptConfigFile evaluates a .ts/.cts/.mts config file by writing
1118
+ // an ephemeral loader script and tsconfig into a temp directory, symlinking the
1119
+ // nearest node_modules, then running `ttsx` with a configLoaderTimeout deadline.
1120
+ // The loader script imports the config file, resolves it through the same
1121
+ // unwrap chain used by loadScriptConfigFile, and writes JSON to stdout.
1185
1122
  func loadTypeScriptConfigFile(location string) (any, error) {
1186
1123
  tempDir, err := os.MkdirTemp("", "ttsc-lint-config-")
1187
1124
  if err != nil {
@@ -1214,6 +1151,15 @@ func loadTypeScriptConfigFile(location string) (any, error) {
1214
1151
  "--project", tsconfig,
1215
1152
  "--cwd", tempDir,
1216
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",
1217
1163
  }
1218
1164
  if tsgo := os.Getenv("TTSC_TSGO_BINARY"); tsgo != "" {
1219
1165
  args = append(args, "--binary", tsgo)
@@ -1242,21 +1188,25 @@ func loadTypeScriptConfigFile(location string) (any, error) {
1242
1188
  if err := json.Unmarshal(output, &out); err != nil {
1243
1189
  return nil, fmt.Errorf("@ttsc/lint: parse TypeScript config file %s output: %w", location, err)
1244
1190
  }
1245
- if !isConfigContainer(out) {
1246
- 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)
1247
1193
  }
1248
1194
  return out, nil
1249
1195
  }
1250
1196
 
1251
- func isConfigContainer(value any) bool {
1252
- switch value.(type) {
1253
- case []any, map[string]any:
1254
- return true
1255
- default:
1256
- return false
1257
- }
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
1258
1204
  }
1259
1205
 
1206
+ // relativeImportSpecifier computes the ESM import specifier for `location`
1207
+ // relative to `fromDir`. The result always starts with "./" or "../" so it is
1208
+ // treated as a relative path by the ESM loader rather than as a bare package
1209
+ // name.
1260
1210
  func relativeImportSpecifier(fromDir, location string) (string, error) {
1261
1211
  relative, err := filepath.Rel(fromDir, location)
1262
1212
  if err != nil {
@@ -1269,6 +1219,11 @@ func relativeImportSpecifier(fromDir, location string) (string, error) {
1269
1219
  return "./" + relative, nil
1270
1220
  }
1271
1221
 
1222
+ // typeScriptConfigLoaderSource returns the TypeScript source of the ephemeral
1223
+ // loader script that ttsx executes to evaluate a TypeScript lint config file.
1224
+ // `importLiteral` is a JSON-encoded relative import path (e.g. `"./lint.config.ts"`)
1225
+ // that is spliced directly into the `import * as` statement, so it must
1226
+ // already be a valid JSON string (produced by json.Marshal).
1272
1227
  func typeScriptConfigLoaderSource(importLiteral string) string {
1273
1228
  return fmt.Sprintf(`import * as importedConfig from %s;
1274
1229
 
@@ -1280,8 +1235,8 @@ declare const process: {
1280
1235
 
1281
1236
  try {
1282
1237
  const value = await resolveConfig(importedConfig, true);
1283
- if (!isObject(value)) {
1284
- 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");
1285
1240
  }
1286
1241
  process.stdout.write(JSON.stringify(toSerializableConfig(value)));
1287
1242
  } catch (error) {
@@ -1318,130 +1273,26 @@ function hasOwn(value: Record<string, unknown>, key: string): boolean {
1318
1273
  return Object.prototype.hasOwnProperty.call(value, key);
1319
1274
  }
1320
1275
 
1321
- function toSerializableConfig(value: unknown): unknown {
1322
- if (Array.isArray(value)) {
1323
- return value.map((item) => toSerializableConfig(item));
1324
- }
1325
- if (!isObject(value)) {
1326
- return value;
1327
- }
1328
- if (isESLintConfigObject(value)) {
1329
- const out: Record<string, unknown> = {};
1330
- if (hasESLintRuntimeFields(value)) {
1331
- out.__ttscLintEslintRuntime = 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];
1332
1285
  }
1333
- if (hasOwn(value, "basePath")) {
1334
- out.basePath = value.basePath;
1335
- }
1336
- if (hasOwn(value, "extends")) {
1337
- out.extends = toSerializableConfig(value.extends);
1338
- }
1339
- if (hasOwn(value, "files")) {
1340
- out.files = toSerializablePatterns(value.files, "files");
1341
- }
1342
- if (hasOwn(value, "ignores")) {
1343
- out.ignores = toSerializablePatterns(value.ignores, "ignores");
1344
- }
1345
- if (hasOwn(value, "rules")) {
1346
- out.rules = toSerializableRules(value.rules);
1347
- }
1348
- return out;
1349
- }
1350
- return { rules: toSerializableRules(value) };
1351
- }
1352
-
1353
- function toSerializableRules(value: unknown): Record<string, unknown> {
1354
- if (!isObject(value) || Array.isArray(value)) {
1355
- throw new Error("rules must be an object");
1356
- }
1357
- return Object.fromEntries(Object.entries(value));
1358
- }
1359
-
1360
- function toSerializablePatterns(value: unknown, key: string): string | string[] {
1361
- if (typeof value === "string") {
1362
- return value;
1363
- }
1364
- if (Array.isArray(value)) {
1365
- return value.map((item, index) => {
1366
- if (typeof item !== "string") {
1367
- throw new Error(key + "[" + index + "] must be a string");
1368
- }
1369
- return item;
1370
- });
1371
- }
1372
- throw new Error(key + " must be a string or string array");
1373
- }
1374
-
1375
- function isESLintConfigObject(value: Record<string, unknown>): boolean {
1376
- return [
1377
- "basePath",
1378
- "extends",
1379
- "files",
1380
- "ignores",
1381
- "languageOptions",
1382
- "linterOptions",
1383
- "name",
1384
- "plugins",
1385
- "processor",
1386
- "rules",
1387
- "settings",
1388
- ].some((key) => hasOwn(value, key));
1389
- }
1390
-
1391
- function hasESLintRuntimeFields(value: Record<string, unknown>): boolean {
1392
- for (const key of ["languageOptions", "linterOptions", "processor", "settings"]) {
1393
- if (hasOwn(value, key)) return true;
1394
1286
  }
1395
- if (hasOwn(value, "plugins")) {
1396
- const plugins = value.plugins;
1397
- if (!isNativePluginMap(plugins)) return true;
1398
- }
1399
- return false;
1400
- }
1401
-
1402
- // isNativePluginMap reports whether every entry of a plugins map points
1403
- // at a ttsc-lint native contributor (an object with a string "source"
1404
- // field). Native plugins are compiled into the lint binary at build
1405
- // time, so their presence does NOT require the JavaScript ESLint
1406
- // runtime; only mixed or pure-ESLint plugin maps do.
1407
- function isNativePluginMap(value: unknown): boolean {
1408
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
1409
- return false;
1410
- }
1411
- const entries = Object.values(value as Record<string, unknown>);
1412
- if (entries.length === 0) return true;
1413
- for (const entry of entries) {
1414
- if (!isNativePluginValue(entry)) return false;
1415
- }
1416
- return true;
1417
- }
1418
-
1419
- function isNativePluginValue(entry: unknown): boolean {
1420
- // A non-empty string is a native specifier — see the matching Go-side
1421
- // and JS-loader implementations.
1422
- if (typeof entry === "string") return entry.length > 0;
1423
- if (entry === null || typeof entry !== "object") return false;
1424
- // ESM-from-CJS interop wraps CJS modules' "exports.default" so a
1425
- // contributor authored as "export default plugin" lands under a
1426
- // ".default" indirection. Walk a few hops so both "export default"
1427
- // and plain "module.exports = plugin" contributors register as
1428
- // native here.
1429
- let current = entry as Record<string, unknown>;
1430
- for (let i = 0; i < 4; i++) {
1431
- if (typeof current.source === "string" && (current.source as string).length > 0) {
1432
- return true;
1433
- }
1434
- const next = current.default;
1435
- if (next === null || typeof next !== "object" || Array.isArray(next)) {
1436
- return false;
1437
- }
1438
- current = next as Record<string, unknown>;
1439
- }
1440
- return false;
1287
+ return out;
1441
1288
  }
1442
1289
  `, importLiteral)
1443
1290
  }
1444
1291
 
1292
+ // typeScriptConfigLoaderTsconfig generates the JSON content of the ephemeral
1293
+ // tsconfig that compiles the loader script. Settings mirror the JS-factory
1294
+ // loader's lenient baseline so identical user configs evaluate the same way
1295
+ // from both the JS and Go sides.
1445
1296
  func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
1446
1297
  // Mirror the JS-factory loader's lenient settings (see the matching
1447
1298
  // tsconfig synthesis in `packages/lint/src/index.ts::readTtsxConfigPlugins`).
@@ -1478,6 +1329,8 @@ func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
1478
1329
  return string(body)
1479
1330
  }
1480
1331
 
1332
+ // ttsxCommand returns a ttsx exec.Cmd bound to a background context. Use
1333
+ // ttsxCommandContext when a deadline is needed (e.g. config file loading).
1481
1334
  func ttsxCommand(args ...string) *exec.Cmd {
1482
1335
  return ttsxCommandContext(context.Background(), args...)
1483
1336
  }
@@ -1501,6 +1354,9 @@ func ttsxCommandContext(ctx context.Context, args ...string) *exec.Cmd {
1501
1354
  return exec.CommandContext(ctx, ttsx, args...)
1502
1355
  }
1503
1356
 
1357
+ // shouldRunTtsxThroughNode reports whether the resolved ttsx binary is a
1358
+ // script (JS or TS extension) rather than a compiled native executable.
1359
+ // Scripts must be executed via `node <binary> <args>` instead of directly.
1504
1360
  func shouldRunTtsxThroughNode(binary string) bool {
1505
1361
  switch strings.ToLower(filepath.Ext(binary)) {
1506
1362
  case ".js", ".cjs", ".mjs", ".ts", ".cts", ".mts":
@@ -1510,6 +1366,10 @@ func shouldRunTtsxThroughNode(binary string) bool {
1510
1366
  }
1511
1367
  }
1512
1368
 
1369
+ // nodeConfigLoaderEnv builds the environment for a Node.js config-loader
1370
+ // subprocess. It prepends the nearest node_modules directory to NODE_PATH so
1371
+ // that imports in .js/.cjs/.mjs config files resolve correctly even when the
1372
+ // subprocess's cwd differs from the config file's location.
1513
1373
  func nodeConfigLoaderEnv(location string) []string {
1514
1374
  env := os.Environ()
1515
1375
  parts := make([]string, 0, 2)
@@ -1525,6 +1385,11 @@ func nodeConfigLoaderEnv(location string) []string {
1525
1385
  return setEnv(env, "NODE_PATH", strings.Join(parts, string(os.PathListSeparator)))
1526
1386
  }
1527
1387
 
1388
+ // linkNearestNodeModules creates a node_modules symlink (or Windows junction)
1389
+ // inside `tempDir` that points at the nearest node_modules directory found
1390
+ // upward from `sourceDir`. This lets the TypeScript config loader resolve
1391
+ // imports from the user's project without copying the entire module tree.
1392
+ // If no node_modules directory exists, the function is a no-op.
1528
1393
  func linkNearestNodeModules(tempDir, sourceDir string) error {
1529
1394
  nodeModules := findNearestNodeModules(sourceDir)
1530
1395
  if nodeModules == "" {
@@ -1550,6 +1415,10 @@ func linkNearestNodeModules(tempDir, sourceDir string) error {
1550
1415
  return fmt.Errorf("@ttsc/lint: link config node_modules %s: %w", nodeModules, err)
1551
1416
  }
1552
1417
 
1418
+ // createWindowsJunction creates a directory junction at `link` pointing at
1419
+ // `target` using `cmd /c mklink /J`. Junctions do not require elevated
1420
+ // privileges (unlike symlinks on Windows), making them the right fallback when
1421
+ // os.Symlink fails.
1553
1422
  func createWindowsJunction(link, target string) error {
1554
1423
  // `cmd /c mklink /J link target` is the standard recipe and works
1555
1424
  // without elevated privileges. Both arguments must be absolute paths
@@ -1561,6 +1430,9 @@ func createWindowsJunction(link, target string) error {
1561
1430
  return nil
1562
1431
  }
1563
1432
 
1433
+ // findNearestNodeModules walks upward from `start` and returns the first
1434
+ // node_modules directory found, or the empty string if the filesystem root is
1435
+ // reached without a match.
1564
1436
  func findNearestNodeModules(start string) string {
1565
1437
  dir := filepath.Clean(start)
1566
1438
  for {
@@ -1576,6 +1448,9 @@ func findNearestNodeModules(start string) string {
1576
1448
  }
1577
1449
  }
1578
1450
 
1451
+ // setEnv updates an existing key=value entry in `env` (in-place) or appends
1452
+ // a new one. It is intentionally a pure-slice helper — no os.Setenv side
1453
+ // effects — so callers can pass it directly to exec.Cmd.Env.
1579
1454
  func setEnv(env []string, key, value string) []string {
1580
1455
  prefix := key + "="
1581
1456
  for i, entry := range env {
@@ -1587,44 +1462,16 @@ func setEnv(env []string, key, value string) []string {
1587
1462
  return append(env, prefix+value)
1588
1463
  }
1589
1464
 
1590
- // parseExternalRuleEntry mirrors parseRuleEntry for ESLint-style config
1591
- // inputs. The contract is identical to the inline path: severity
1592
- // literal, `[severity]`, or `[severity, options]`. Tuples longer than
1593
- // two elements are rejected because no built-in rule's option struct
1594
- // models positional ESLint args, so silently encoding the tail as a
1595
- // JSON array would land in `DecodeOptions` and fall back to defaults
1596
- // — a silent fallback the standard parser deliberately avoids.
1465
+ // parseExternalRuleEntry delegates to parseRuleEntry. It is kept under this
1466
+ // name because test files in the same package call it directly.
1597
1467
  func parseExternalRuleEntry(v any) (Severity, json.RawMessage, error) {
1598
- if tuple, ok := v.([]any); ok {
1599
- if len(tuple) == 0 {
1600
- return SeverityOff, nil, fmt.Errorf("severity tuple must not be empty")
1601
- }
1602
- sev, err := parseSeverity(tuple[0])
1603
- if err != nil {
1604
- return SeverityOff, nil, err
1605
- }
1606
- if len(tuple) == 1 {
1607
- return sev, nil, nil
1608
- }
1609
- if len(tuple) > 2 {
1610
- return SeverityOff, nil, fmt.Errorf("severity tuple must be [severity] or [severity, options], got %d elements", len(tuple))
1611
- }
1612
- if tuple[1] == nil {
1613
- return sev, nil, nil
1614
- }
1615
- if _, ok := tuple[1].(map[string]any); !ok {
1616
- return SeverityOff, nil, fmt.Errorf("severity tuple's options slot must be an object, got %T", tuple[1])
1617
- }
1618
- encoded, err := json.Marshal(tuple[1])
1619
- if err != nil {
1620
- return SeverityOff, nil, fmt.Errorf("encode options: %w", err)
1621
- }
1622
- return sev, encoded, nil
1623
- }
1624
- sev, err := parseSeverity(v)
1625
- return sev, nil, err
1468
+ return parseRuleEntry(v)
1626
1469
  }
1627
1470
 
1471
+ // parseSeverity converts a raw config value to a Severity. Accepts the string
1472
+ // literals "off", "warn"/"warning", "error" and the numeric equivalents 0, 1,
1473
+ // 2 (the ESLint convention). Any other value is a hard error — there is no
1474
+ // silent fallback so typos are surfaced immediately.
1628
1475
  func parseSeverity(v any) (Severity, error) {
1629
1476
  switch x := v.(type) {
1630
1477
  case string:
@@ -1651,6 +1498,9 @@ func parseSeverity(v any) (Severity, error) {
1651
1498
  return SeverityOff, fmt.Errorf("severity must be one of: off | warn | warning | error | 0 | 1 | 2, got %T", v)
1652
1499
  }
1653
1500
 
1501
+ // sortedRuleNames returns the sorted slice of rule names from `config` for
1502
+ // which `include` returns true. Sorting ensures deterministic dispatch-table
1503
+ // ordering so test output and diagnostic ordering are stable across runs.
1654
1504
  func sortedRuleNames(config RuleConfig, include func(Severity) bool) []string {
1655
1505
  names := make([]string, 0, len(config))
1656
1506
  for name, sev := range config {
@@ -1662,6 +1512,12 @@ func sortedRuleNames(config RuleConfig, include func(Severity) bool) []string {
1662
1512
  return names
1663
1513
  }
1664
1514
 
1515
+ // matchAnyPattern reports whether `fileName` matches at least one of the
1516
+ // provided glob patterns. If baseDir is non-empty, both paths are made
1517
+ // absolute before computing a relative path so that glob patterns rooted at
1518
+ // the config file's directory match correctly regardless of the process cwd.
1519
+ // Files outside the base directory never match (the relative path would start
1520
+ // with "..").
1665
1521
  func matchAnyPattern(baseDir string, patterns []string, fileName string) bool {
1666
1522
  rel := filepath.ToSlash(fileName)
1667
1523
  if baseDir != "" {
@@ -1689,6 +1545,10 @@ func matchAnyPattern(baseDir string, patterns []string, fileName string) bool {
1689
1545
  return false
1690
1546
  }
1691
1547
 
1548
+ // normalizeGlobPattern normalizes a user-supplied glob pattern to forward
1549
+ // slashes and strips a leading "./". Patterns that contain no slash are treated
1550
+ // as basename-only globs by prepending "**/" so that `*.ts` matches any
1551
+ // TypeScript file regardless of directory depth, matching ESLint's behavior.
1692
1552
  func normalizeGlobPattern(pattern string) string {
1693
1553
  pattern = filepath.ToSlash(pattern)
1694
1554
  pattern = strings.TrimPrefix(pattern, "./")
@@ -1698,20 +1558,120 @@ func normalizeGlobPattern(pattern string) string {
1698
1558
  return pattern
1699
1559
  }
1700
1560
 
1561
+ // matchGlob tests whether `name` matches `pattern` using the ESLint-compatible
1562
+ // glob semantics implemented by matchGlobParts. Both strings are trimmed of
1563
+ // leading/trailing slashes before splitting on "/" so that empty segments do
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.
1701
1567
  func matchGlob(pattern, name string) bool {
1702
1568
  pattern = strings.Trim(pattern, "/")
1703
1569
  name = strings.Trim(name, "/")
1704
1570
  if pattern == "" {
1705
1571
  return name == ""
1706
1572
  }
1707
- patternParts := strings.Split(pattern, "/")
1708
1573
  nameParts := []string{}
1709
1574
  if name != "" {
1710
1575
  nameParts = strings.Split(name, "/")
1711
1576
  }
1712
- 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
1713
1669
  }
1714
1670
 
1671
+ // matchGlobParts recursively matches path segments against pattern segments.
1672
+ // A "**" segment matches zero or more path segments (greedy: tries zero first,
1673
+ // then each successive prefix) so that `**/*.ts` matches both `a.ts` and
1674
+ // `dir/a.ts`.
1715
1675
  func matchGlobParts(patternParts, nameParts []string) bool {
1716
1676
  if len(patternParts) == 0 {
1717
1677
  return len(nameParts) == 0