@ttsc/lint 0.12.3 → 0.12.4

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 (62) 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 +10 -4
  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/ITtscLintPluginMeta.d.ts +9 -1
  9. package/lib/structures/TtscLintRule.d.ts +10 -1
  10. package/lib/structures/TtscLintRuleMap.d.ts +2 -2
  11. package/linthost/ast_helpers.go +17 -0
  12. package/linthost/compile.go +37 -0
  13. package/linthost/config.go +184 -35
  14. package/linthost/config_format.go +257 -244
  15. package/linthost/directives.go +72 -0
  16. package/linthost/dispatch.go +33 -33
  17. package/linthost/engine.go +4 -0
  18. package/linthost/eslint_runtime.go +31 -0
  19. package/linthost/fix.go +29 -0
  20. package/linthost/format.go +23 -0
  21. package/linthost/host.go +11 -0
  22. package/linthost/print_dispatch.go +7 -5
  23. package/linthost/print_doc.go +4 -0
  24. package/linthost/print_nodes_call.go +9 -0
  25. package/linthost/rules_arrays.go +5 -2
  26. package/linthost/rules_debugger.go +3 -2
  27. package/linthost/rules_dupes.go +7 -4
  28. package/linthost/rules_empty.go +3 -2
  29. package/linthost/rules_escape.go +15 -0
  30. package/linthost/rules_eval.go +3 -0
  31. package/linthost/rules_finally.go +11 -0
  32. package/linthost/rules_format_jsdoc.go +7 -0
  33. package/linthost/rules_format_print_width.go +3 -0
  34. package/linthost/rules_format_quotes.go +3 -0
  35. package/linthost/rules_format_sort_imports.go +4 -0
  36. package/linthost/rules_gap.go +20 -0
  37. package/linthost/rules_imports.go +5 -7
  38. package/linthost/rules_logic.go +11 -0
  39. package/linthost/rules_loops.go +4 -0
  40. package/linthost/rules_misc.go +4 -2
  41. package/linthost/rules_params.go +7 -11
  42. package/linthost/rules_problems.go +24 -1
  43. package/linthost/rules_promise.go +12 -0
  44. package/linthost/rules_protos.go +3 -2
  45. package/linthost/rules_self.go +6 -0
  46. package/linthost/rules_strings.go +10 -0
  47. package/linthost/rules_suggestions.go +14 -4
  48. package/linthost/rules_throw.go +2 -0
  49. package/linthost/rules_ts.go +13 -0
  50. package/linthost/rules_ts_extra.go +25 -8
  51. package/linthost/rules_var.go +14 -1
  52. package/package.json +3 -3
  53. package/plugin/main.go +4 -4
  54. package/rule/astutil/astutil.go +12 -0
  55. package/rule/rule.go +123 -123
  56. package/src/defaultFormat.ts +10 -11
  57. package/src/index.ts +16 -4
  58. package/src/structures/ITtscLintConfig.ts +2 -2
  59. package/src/structures/ITtscLintFormatConfig.ts +53 -55
  60. package/src/structures/ITtscLintPluginMeta.ts +9 -1
  61. package/src/structures/TtscLintRule.ts +10 -1
  62. package/src/structures/TtscLintRuleMap.ts +17 -18
@@ -98,9 +98,20 @@ type ResolvedRuleConfig struct {
98
98
  Ignored bool
99
99
  }
100
100
 
101
+ // 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).
101
105
  type RuleResolver interface {
106
+ // ResolveRules returns the effective severity map for the given source file.
107
+ // Implementations that support `files`/`ignores` patterns apply them here;
108
+ // flat RuleConfig always returns all rules unchanged.
102
109
  ResolveRules(fileName string) ResolvedRuleConfig
110
+ // ActiveRuleNames returns the sorted names of every rule that is not SeverityOff
111
+ // in at least one config entry. Used to build the engine's dispatch table.
103
112
  ActiveRuleNames() []string
113
+ // EnabledRuleConfig returns the project-wide severity map for rules that are
114
+ // not SeverityOff. Where multiple entries disagree, SeverityError wins.
104
115
  EnabledRuleConfig() RuleConfig
105
116
  // RuleOptions returns the raw JSON options for `name`, or nil when the
106
117
  // rule was configured with a severity alone. Returns nil for unknown
@@ -108,14 +119,20 @@ type RuleResolver interface {
108
119
  RuleOptions(name string) json.RawMessage
109
120
  }
110
121
 
122
+ // ResolveRules implements RuleResolver. A flat RuleConfig has no glob scoping,
123
+ // so every file receives the full map unchanged.
111
124
  func (c RuleConfig) ResolveRules(string) ResolvedRuleConfig {
112
125
  return ResolvedRuleConfig{Rules: c}
113
126
  }
114
127
 
128
+ // ActiveRuleNames implements RuleResolver. Returns rule names whose severity
129
+ // is not SeverityOff, sorted for deterministic engine dispatch-table construction.
115
130
  func (c RuleConfig) ActiveRuleNames() []string {
116
131
  return sortedRuleNames(c, func(sev Severity) bool { return sev != SeverityOff })
117
132
  }
118
133
 
134
+ // EnabledRuleConfig implements RuleResolver. Returns a copy containing only the
135
+ // non-off entries; used to populate engine state and diagnostic reporting.
119
136
  func (c RuleConfig) EnabledRuleConfig() RuleConfig {
120
137
  out := RuleConfig{}
121
138
  for name, sev := range c {
@@ -139,18 +156,25 @@ type InlineRuleResolver struct {
139
156
  Options RuleOptionsMap
140
157
  }
141
158
 
159
+ // ResolveRules implements RuleResolver. Inline rules have no glob scoping;
160
+ // the full map applies to every file.
142
161
  func (r InlineRuleResolver) ResolveRules(string) ResolvedRuleConfig {
143
162
  return ResolvedRuleConfig{Rules: r.Rules}
144
163
  }
145
164
 
165
+ // ActiveRuleNames implements RuleResolver by delegating to the inner RuleConfig.
146
166
  func (r InlineRuleResolver) ActiveRuleNames() []string {
147
167
  return r.Rules.ActiveRuleNames()
148
168
  }
149
169
 
170
+ // EnabledRuleConfig implements RuleResolver by delegating to the inner RuleConfig.
150
171
  func (r InlineRuleResolver) EnabledRuleConfig() RuleConfig {
151
172
  return r.Rules.EnabledRuleConfig()
152
173
  }
153
174
 
175
+ // RuleOptions implements RuleResolver. Returns the raw JSON options blob for
176
+ // `name`, or nil when the rule was configured without options or the name is
177
+ // unknown.
154
178
  func (r InlineRuleResolver) RuleOptions(name string) json.RawMessage {
155
179
  if r.Options == nil {
156
180
  return nil
@@ -158,6 +182,11 @@ func (r InlineRuleResolver) RuleOptions(name string) json.RawMessage {
158
182
  return r.Options[name]
159
183
  }
160
184
 
185
+ // ConfigStore holds the parsed representation of an external flat-config file.
186
+ // It implements RuleResolver with per-file glob scoping: ResolveRules walks the
187
+ // entries in declaration order and the last matching entry wins. Options are
188
+ // intentionally NOT per-file — one project-wide options map is kept so rule
189
+ // behavior is uniform across the codebase even when severity varies by glob.
161
190
  type ConfigStore struct {
162
191
  entries []ConfigEntry
163
192
  externalConfigPath string
@@ -179,6 +208,10 @@ func (s *ConfigStore) RuleOptions(name string) json.RawMessage {
179
208
  return s.options[name]
180
209
  }
181
210
 
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.
182
215
  type ConfigEntry struct {
183
216
  BaseDir string
184
217
  Files []string
@@ -187,6 +220,10 @@ type ConfigEntry struct {
187
220
  IgnoreOnly bool
188
221
  }
189
222
 
223
+ // ResolveRules implements RuleResolver. Ignore-only entries are checked first;
224
+ // if one matches, the file is marked Ignored and linting is skipped entirely.
225
+ // Otherwise the entries are walked in declaration order and the last matching
226
+ // entry wins (later entries shadow earlier ones for the same rule name).
190
227
  func (s *ConfigStore) ResolveRules(fileName string) ResolvedRuleConfig {
191
228
  if s == nil {
192
229
  return ResolvedRuleConfig{Rules: RuleConfig{}}
@@ -208,6 +245,10 @@ func (s *ConfigStore) ResolveRules(fileName string) ResolvedRuleConfig {
208
245
  return ResolvedRuleConfig{Rules: out}
209
246
  }
210
247
 
248
+ // ActiveRuleNames implements RuleResolver. Returns the sorted union of all rule
249
+ // names that are not SeverityOff across every non-ignore-only config entry,
250
+ // regardless of which files they apply to. The engine uses this to build the
251
+ // per-rule dispatch table before file iteration begins.
211
252
  func (s *ConfigStore) ActiveRuleNames() []string {
212
253
  if s == nil {
213
254
  return nil
@@ -226,6 +267,9 @@ func (s *ConfigStore) ActiveRuleNames() []string {
226
267
  return sortedRuleNames(active, func(Severity) bool { return true })
227
268
  }
228
269
 
270
+ // EnabledRuleConfig implements RuleResolver. Returns the project-wide severity
271
+ // map for non-off rules. Where multiple entries configure the same rule,
272
+ // SeverityError is sticky — it cannot be downgraded by a later warning entry.
229
273
  func (s *ConfigStore) EnabledRuleConfig() RuleConfig {
230
274
  out := RuleConfig{}
231
275
  if s == nil {
@@ -247,6 +291,10 @@ func (s *ConfigStore) EnabledRuleConfig() RuleConfig {
247
291
  return out
248
292
  }
249
293
 
294
+ // 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.
250
298
  func (s *ConfigStore) Flatten() RuleConfig {
251
299
  out := RuleConfig{}
252
300
  if s == nil {
@@ -263,6 +311,9 @@ func (s *ConfigStore) Flatten() RuleConfig {
263
311
  return out
264
312
  }
265
313
 
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.
266
317
  func (s *ConfigStore) ExternalConfigPath() string {
267
318
  if s == nil {
268
319
  return ""
@@ -270,6 +321,11 @@ func (s *ConfigStore) ExternalConfigPath() string {
270
321
  return s.externalConfigPath
271
322
  }
272
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.
273
329
  func (s *ConfigStore) WantsESLintRuntime() bool {
274
330
  if s == nil {
275
331
  return false
@@ -281,6 +337,10 @@ func (s *ConfigStore) WantsESLintRuntime() bool {
281
337
  return strings.HasPrefix(base, "eslint.config.")
282
338
  }
283
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.
284
344
  func (s *ConfigStore) RequiresESLintRuntime() bool {
285
345
  if s == nil {
286
346
  return false
@@ -386,6 +446,9 @@ func parseRuleEntry(value any) (Severity, json.RawMessage, error) {
386
446
  return sev, nil, err
387
447
  }
388
448
 
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.
389
452
  func parseExternalConfigRules(raw any) (RuleConfig, error) {
390
453
  store, err := parseExternalConfigStore(raw, "")
391
454
  if err != nil {
@@ -394,14 +457,24 @@ func parseExternalConfigRules(raw any) (RuleConfig, error) {
394
457
  return store.Flatten(), nil
395
458
  }
396
459
 
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.
397
462
  func parseExternalConfigStore(raw any, configDir string) (*ConfigStore, error) {
398
463
  return parseExternalConfigStoreWithRuntimeMode(raw, configDir, false)
399
464
  }
400
465
 
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.
401
470
  func parseExternalConfigStoreForFile(raw any, configDir string) (*ConfigStore, error) {
402
471
  return parseExternalConfigStoreWithRuntimeMode(raw, configDir, true)
403
472
  }
404
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.
405
478
  func parseExternalConfigStoreWithRuntimeMode(raw any, configDir string, allowRuntimeOnly bool) (*ConfigStore, error) {
406
479
  store := &ConfigStore{}
407
480
  if err := collectExternalConfigEntries(store, raw, configDir, "config", allowRuntimeOnly); err != nil {
@@ -590,6 +663,9 @@ func collectExternalRuleMapWithOptions(out RuleConfig, opts RuleOptionsMap, raw
590
663
  return nil
591
664
  }
592
665
 
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.
593
669
  func isESLintConfigObject(value map[string]any) bool {
594
670
  for _, key := range []string{
595
671
  "basePath",
@@ -613,6 +689,9 @@ func isESLintConfigObject(value map[string]any) bool {
613
689
  return false
614
690
  }
615
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.
616
695
  func hasESLintRuntimeFields(value map[string]any) bool {
617
696
  for _, key := range []string{
618
697
  "languageOptions",
@@ -689,11 +768,17 @@ func isNativePluginValue(entry any) bool {
689
768
  }
690
769
  }
691
770
 
771
+ // normalizeExternalRuleName strips the standard typescript-eslint namespace
772
+ // prefixes so that rules like "@typescript-eslint/no-explicit-any" and the
773
+ // bare "no-explicit-any" key both resolve to the same engine-internal name.
692
774
  func normalizeExternalRuleName(name string) string {
693
775
  name = strings.TrimPrefix(name, "@typescript-eslint/")
694
776
  return strings.TrimPrefix(name, "typescript-eslint/")
695
777
  }
696
778
 
779
+ // parsePatternList coerces a raw config value to a string slice for use as a
780
+ // `files` or `ignores` pattern list. Accepts a bare string (single-pattern
781
+ // shorthand) or a string array. Empty patterns are rejected eagerly.
697
782
  func parsePatternList(raw any, path string) ([]string, error) {
698
783
  if raw == nil {
699
784
  return nil, nil
@@ -881,6 +966,9 @@ func emitLegacyConfigDeprecation() {
881
966
  })
882
967
  }
883
968
 
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.
884
972
  func loadExternalConfigResolver(location string) (RuleResolver, error) {
885
973
  raw, err := loadConfigFile(location)
886
974
  if err != nil {
@@ -943,6 +1031,9 @@ func findLintConfigFile(cwd, tsconfigPath string) (string, error) {
943
1031
  }
944
1032
  }
945
1033
 
1034
+ // resolveConfigFilePath resolves a user-supplied config path to an absolute
1035
+ // path. Absolute paths are returned unchanged; relative paths are joined to the
1036
+ // tsconfig directory (or cwd when no tsconfig is set).
946
1037
  func resolveConfigFilePath(configPath, cwd, tsconfigPath string) string {
947
1038
  if filepath.IsAbs(configPath) {
948
1039
  return configPath
@@ -950,6 +1041,10 @@ func resolveConfigFilePath(configPath, cwd, tsconfigPath string) string {
950
1041
  return filepath.Join(tsconfigBaseDir(cwd, tsconfigPath), configPath)
951
1042
  }
952
1043
 
1044
+ // discoveryConfigBaseDir returns the directory from which auto-discovery walks
1045
+ // upward when no explicit config path is provided. Prefer the tsconfig
1046
+ // directory over cwd so that nested package configs are found relative to the
1047
+ // tsconfig that triggered the lint run.
953
1048
  func discoveryConfigBaseDir(cwd, tsconfigPath string) string {
954
1049
  if tsconfigPath != "" {
955
1050
  resolvedTsconfig := tsconfigPath
@@ -961,6 +1056,9 @@ func discoveryConfigBaseDir(cwd, tsconfigPath string) string {
961
1056
  return cwd
962
1057
  }
963
1058
 
1059
+ // tsconfigBaseDir returns the directory that contains the tsconfig file, or
1060
+ // cwd when tsconfigPath is empty. Used as the base for relative config paths
1061
+ // supplied in the tsconfig plugin entry.
964
1062
  func tsconfigBaseDir(cwd, tsconfigPath string) string {
965
1063
  if tsconfigPath == "" {
966
1064
  return cwd
@@ -972,6 +1070,9 @@ func tsconfigBaseDir(cwd, tsconfigPath string) string {
972
1070
  return filepath.Dir(resolvedTsconfig)
973
1071
  }
974
1072
 
1073
+ // loadConfigFile loads and deserializes a lint config file at `location`.
1074
+ // The file format is determined by extension: .json is parsed natively;
1075
+ // .js/.cjs/.mjs run through a Node subprocess; .ts/.cts/.mts run through ttsx.
975
1076
  func loadConfigFile(location string) (any, error) {
976
1077
  ext := strings.ToLower(filepath.Ext(location))
977
1078
  switch ext {
@@ -986,6 +1087,8 @@ func loadConfigFile(location string) (any, error) {
986
1087
  }
987
1088
  }
988
1089
 
1090
+ // loadJSONConfigFile reads and JSON-parses a lint config file. A leading UTF-8
1091
+ // BOM is stripped before parsing so files saved by Windows editors are accepted.
989
1092
  func loadJSONConfigFile(location string) (any, error) {
990
1093
  body, err := os.ReadFile(location)
991
1094
  if err != nil {
@@ -1006,6 +1109,11 @@ func loadJSONConfigFile(location string) (any, error) {
1006
1109
  return out, nil
1007
1110
  }
1008
1111
 
1112
+ // loadScriptConfigFile evaluates a .js/.cjs/.mjs config file by running a
1113
+ // Node subprocess that dynamic-imports the file, resolves the exported config
1114
+ // through the same 8-hop default/config unwrap used by the TS loader, and
1115
+ // serializes the result as JSON to stdout. The subprocess has a
1116
+ // configLoaderTimeout deadline to prevent user code from hanging indefinitely.
1009
1117
  func loadScriptConfigFile(location string) (any, error) {
1010
1118
  const script = `
1011
1119
  const { pathToFileURL } = require("node:url");
@@ -1182,6 +1290,11 @@ function isNativePluginValue(entry) {
1182
1290
  return out, nil
1183
1291
  }
1184
1292
 
1293
+ // loadTypeScriptConfigFile evaluates a .ts/.cts/.mts config file by writing
1294
+ // an ephemeral loader script and tsconfig into a temp directory, symlinking the
1295
+ // nearest node_modules, then running `ttsx` with a configLoaderTimeout deadline.
1296
+ // The loader script imports the config file, resolves it through the same
1297
+ // unwrap chain used by loadScriptConfigFile, and writes JSON to stdout.
1185
1298
  func loadTypeScriptConfigFile(location string) (any, error) {
1186
1299
  tempDir, err := os.MkdirTemp("", "ttsc-lint-config-")
1187
1300
  if err != nil {
@@ -1248,6 +1361,9 @@ func loadTypeScriptConfigFile(location string) (any, error) {
1248
1361
  return out, nil
1249
1362
  }
1250
1363
 
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.
1251
1367
  func isConfigContainer(value any) bool {
1252
1368
  switch value.(type) {
1253
1369
  case []any, map[string]any:
@@ -1257,6 +1373,10 @@ func isConfigContainer(value any) bool {
1257
1373
  }
1258
1374
  }
1259
1375
 
1376
+ // relativeImportSpecifier computes the ESM import specifier for `location`
1377
+ // relative to `fromDir`. The result always starts with "./" or "../" so it is
1378
+ // treated as a relative path by the ESM loader rather than as a bare package
1379
+ // name.
1260
1380
  func relativeImportSpecifier(fromDir, location string) (string, error) {
1261
1381
  relative, err := filepath.Rel(fromDir, location)
1262
1382
  if err != nil {
@@ -1269,6 +1389,11 @@ func relativeImportSpecifier(fromDir, location string) (string, error) {
1269
1389
  return "./" + relative, nil
1270
1390
  }
1271
1391
 
1392
+ // typeScriptConfigLoaderSource returns the TypeScript source of the ephemeral
1393
+ // loader script that ttsx executes to evaluate a TypeScript lint config file.
1394
+ // `importLiteral` is a JSON-encoded relative import path (e.g. `"./lint.config.ts"`)
1395
+ // that is spliced directly into the `import * as` statement, so it must
1396
+ // already be a valid JSON string (produced by json.Marshal).
1272
1397
  func typeScriptConfigLoaderSource(importLiteral string) string {
1273
1398
  return fmt.Sprintf(`import * as importedConfig from %s;
1274
1399
 
@@ -1442,6 +1567,10 @@ function isNativePluginValue(entry: unknown): boolean {
1442
1567
  `, importLiteral)
1443
1568
  }
1444
1569
 
1570
+ // typeScriptConfigLoaderTsconfig generates the JSON content of the ephemeral
1571
+ // tsconfig that compiles the loader script. Settings mirror the JS-factory
1572
+ // loader's lenient baseline so identical user configs evaluate the same way
1573
+ // from both the JS and Go sides.
1445
1574
  func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
1446
1575
  // Mirror the JS-factory loader's lenient settings (see the matching
1447
1576
  // tsconfig synthesis in `packages/lint/src/index.ts::readTtsxConfigPlugins`).
@@ -1478,6 +1607,8 @@ func typeScriptConfigLoaderTsconfig(loader, location, outDir string) string {
1478
1607
  return string(body)
1479
1608
  }
1480
1609
 
1610
+ // ttsxCommand returns a ttsx exec.Cmd bound to a background context. Use
1611
+ // ttsxCommandContext when a deadline is needed (e.g. config file loading).
1481
1612
  func ttsxCommand(args ...string) *exec.Cmd {
1482
1613
  return ttsxCommandContext(context.Background(), args...)
1483
1614
  }
@@ -1501,6 +1632,9 @@ func ttsxCommandContext(ctx context.Context, args ...string) *exec.Cmd {
1501
1632
  return exec.CommandContext(ctx, ttsx, args...)
1502
1633
  }
1503
1634
 
1635
+ // shouldRunTtsxThroughNode reports whether the resolved ttsx binary is a
1636
+ // script (JS or TS extension) rather than a compiled native executable.
1637
+ // Scripts must be executed via `node <binary> <args>` instead of directly.
1504
1638
  func shouldRunTtsxThroughNode(binary string) bool {
1505
1639
  switch strings.ToLower(filepath.Ext(binary)) {
1506
1640
  case ".js", ".cjs", ".mjs", ".ts", ".cts", ".mts":
@@ -1510,6 +1644,10 @@ func shouldRunTtsxThroughNode(binary string) bool {
1510
1644
  }
1511
1645
  }
1512
1646
 
1647
+ // nodeConfigLoaderEnv builds the environment for a Node.js config-loader
1648
+ // subprocess. It prepends the nearest node_modules directory to NODE_PATH so
1649
+ // that imports in .js/.cjs/.mjs config files resolve correctly even when the
1650
+ // subprocess's cwd differs from the config file's location.
1513
1651
  func nodeConfigLoaderEnv(location string) []string {
1514
1652
  env := os.Environ()
1515
1653
  parts := make([]string, 0, 2)
@@ -1525,6 +1663,11 @@ func nodeConfigLoaderEnv(location string) []string {
1525
1663
  return setEnv(env, "NODE_PATH", strings.Join(parts, string(os.PathListSeparator)))
1526
1664
  }
1527
1665
 
1666
+ // linkNearestNodeModules creates a node_modules symlink (or Windows junction)
1667
+ // inside `tempDir` that points at the nearest node_modules directory found
1668
+ // upward from `sourceDir`. This lets the TypeScript config loader resolve
1669
+ // imports from the user's project without copying the entire module tree.
1670
+ // If no node_modules directory exists, the function is a no-op.
1528
1671
  func linkNearestNodeModules(tempDir, sourceDir string) error {
1529
1672
  nodeModules := findNearestNodeModules(sourceDir)
1530
1673
  if nodeModules == "" {
@@ -1550,6 +1693,10 @@ func linkNearestNodeModules(tempDir, sourceDir string) error {
1550
1693
  return fmt.Errorf("@ttsc/lint: link config node_modules %s: %w", nodeModules, err)
1551
1694
  }
1552
1695
 
1696
+ // createWindowsJunction creates a directory junction at `link` pointing at
1697
+ // `target` using `cmd /c mklink /J`. Junctions do not require elevated
1698
+ // privileges (unlike symlinks on Windows), making them the right fallback when
1699
+ // os.Symlink fails.
1553
1700
  func createWindowsJunction(link, target string) error {
1554
1701
  // `cmd /c mklink /J link target` is the standard recipe and works
1555
1702
  // without elevated privileges. Both arguments must be absolute paths
@@ -1561,6 +1708,9 @@ func createWindowsJunction(link, target string) error {
1561
1708
  return nil
1562
1709
  }
1563
1710
 
1711
+ // findNearestNodeModules walks upward from `start` and returns the first
1712
+ // node_modules directory found, or the empty string if the filesystem root is
1713
+ // reached without a match.
1564
1714
  func findNearestNodeModules(start string) string {
1565
1715
  dir := filepath.Clean(start)
1566
1716
  for {
@@ -1576,6 +1726,9 @@ func findNearestNodeModules(start string) string {
1576
1726
  }
1577
1727
  }
1578
1728
 
1729
+ // setEnv updates an existing key=value entry in `env` (in-place) or appends
1730
+ // a new one. It is intentionally a pure-slice helper — no os.Setenv side
1731
+ // effects — so callers can pass it directly to exec.Cmd.Env.
1579
1732
  func setEnv(env []string, key, value string) []string {
1580
1733
  prefix := key + "="
1581
1734
  for i, entry := range env {
@@ -1587,44 +1740,19 @@ func setEnv(env []string, key, value string) []string {
1587
1740
  return append(env, prefix+value)
1588
1741
  }
1589
1742
 
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.
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.
1597
1748
  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
1749
+ return parseRuleEntry(v)
1626
1750
  }
1627
1751
 
1752
+ // parseSeverity converts a raw config value to a Severity. Accepts the string
1753
+ // literals "off", "warn"/"warning", "error" and the numeric equivalents 0, 1,
1754
+ // 2 (the ESLint convention). Any other value is a hard error — there is no
1755
+ // silent fallback so typos are surfaced immediately.
1628
1756
  func parseSeverity(v any) (Severity, error) {
1629
1757
  switch x := v.(type) {
1630
1758
  case string:
@@ -1651,6 +1779,9 @@ func parseSeverity(v any) (Severity, error) {
1651
1779
  return SeverityOff, fmt.Errorf("severity must be one of: off | warn | warning | error | 0 | 1 | 2, got %T", v)
1652
1780
  }
1653
1781
 
1782
+ // sortedRuleNames returns the sorted slice of rule names from `config` for
1783
+ // which `include` returns true. Sorting ensures deterministic dispatch-table
1784
+ // ordering so test output and diagnostic ordering are stable across runs.
1654
1785
  func sortedRuleNames(config RuleConfig, include func(Severity) bool) []string {
1655
1786
  names := make([]string, 0, len(config))
1656
1787
  for name, sev := range config {
@@ -1662,6 +1793,12 @@ func sortedRuleNames(config RuleConfig, include func(Severity) bool) []string {
1662
1793
  return names
1663
1794
  }
1664
1795
 
1796
+ // matchAnyPattern reports whether `fileName` matches at least one of the
1797
+ // provided glob patterns. If baseDir is non-empty, both paths are made
1798
+ // absolute before computing a relative path so that glob patterns rooted at
1799
+ // the config file's directory match correctly regardless of the process cwd.
1800
+ // Files outside the base directory never match (the relative path would start
1801
+ // with "..").
1665
1802
  func matchAnyPattern(baseDir string, patterns []string, fileName string) bool {
1666
1803
  rel := filepath.ToSlash(fileName)
1667
1804
  if baseDir != "" {
@@ -1689,6 +1826,10 @@ func matchAnyPattern(baseDir string, patterns []string, fileName string) bool {
1689
1826
  return false
1690
1827
  }
1691
1828
 
1829
+ // normalizeGlobPattern normalizes a user-supplied glob pattern to forward
1830
+ // slashes and strips a leading "./". Patterns that contain no slash are treated
1831
+ // as basename-only globs by prepending "**/" so that `*.ts` matches any
1832
+ // TypeScript file regardless of directory depth, matching ESLint's behavior.
1692
1833
  func normalizeGlobPattern(pattern string) string {
1693
1834
  pattern = filepath.ToSlash(pattern)
1694
1835
  pattern = strings.TrimPrefix(pattern, "./")
@@ -1698,6 +1839,10 @@ func normalizeGlobPattern(pattern string) string {
1698
1839
  return pattern
1699
1840
  }
1700
1841
 
1842
+ // matchGlob tests whether `name` matches `pattern` using the ESLint-compatible
1843
+ // glob semantics implemented by matchGlobParts. Both strings are trimmed of
1844
+ // leading/trailing slashes before splitting on "/" so that empty segments do
1845
+ // not appear in the part slices.
1701
1846
  func matchGlob(pattern, name string) bool {
1702
1847
  pattern = strings.Trim(pattern, "/")
1703
1848
  name = strings.Trim(name, "/")
@@ -1712,6 +1857,10 @@ func matchGlob(pattern, name string) bool {
1712
1857
  return matchGlobParts(patternParts, nameParts)
1713
1858
  }
1714
1859
 
1860
+ // matchGlobParts recursively matches path segments against pattern segments.
1861
+ // A "**" segment matches zero or more path segments (greedy: tries zero first,
1862
+ // then each successive prefix) so that `**/*.ts` matches both `a.ts` and
1863
+ // `dir/a.ts`.
1715
1864
  func matchGlobParts(patternParts, nameParts []string) bool {
1716
1865
  if len(patternParts) == 0 {
1717
1866
  return len(nameParts) == 0