@ttsc/lint 0.18.3 → 0.19.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 (188) hide show
  1. package/README.md +95 -43
  2. package/go.mod +5 -4
  3. package/internal/rulecode/rulecode.go +88 -0
  4. package/lib/index.d.ts +10 -1
  5. package/lib/index.js +45 -17
  6. package/lib/index.js.map +1 -1
  7. package/lib/structures/ITtscLintConfig.d.ts +3 -0
  8. package/lib/structures/TtscLintRuleSetting.d.ts +3 -2
  9. package/lib/structures/format/ITtscLintFormat.d.ts +3 -2
  10. package/lib/structures/format/ITtscLintFormatSortImports.d.ts +20 -4
  11. package/lib/structures/rules/ITtscLintBoundariesRuleOptions.d.ts +80 -8
  12. package/lib/structures/rules/ITtscLintBoundariesRules.d.ts +6 -8
  13. package/lib/structures/rules/ITtscLintContributorRules.d.ts +18 -2
  14. package/lib/structures/rules/ITtscLintCoreRuleOptions.d.ts +347 -0
  15. package/lib/structures/rules/ITtscLintCoreRuleOptions.js +3 -0
  16. package/lib/structures/rules/ITtscLintCoreRuleOptions.js.map +1 -0
  17. package/lib/structures/rules/ITtscLintCoreRules.d.ts +101 -67
  18. package/lib/structures/rules/ITtscLintRegexpRules.d.ts +7 -2
  19. package/lib/structures/rules/ITtscLintRuleOptionsMap.d.ts +53 -7
  20. package/lib/structures/rules/ITtscLintRules.d.ts +6 -2
  21. package/lib/structures/rules/ITtscLintSolidRules.d.ts +0 -8
  22. package/lib/structures/rules/ITtscLintTypeScriptRuleOptions.d.ts +206 -0
  23. package/lib/structures/rules/ITtscLintTypeScriptRuleOptions.js +8 -0
  24. package/lib/structures/rules/ITtscLintTypeScriptRuleOptions.js.map +1 -0
  25. package/lib/structures/rules/ITtscLintTypeScriptRules.d.ts +55 -42
  26. package/lib/structures/rules/ITtscLintUnicornRuleOptions.d.ts +276 -0
  27. package/lib/structures/rules/ITtscLintUnicornRuleOptions.js +3 -0
  28. package/lib/structures/rules/ITtscLintUnicornRuleOptions.js.map +1 -0
  29. package/lib/structures/rules/ITtscLintUnicornRules.d.ts +44 -36
  30. package/lib/structures/rules/index.d.ts +3 -0
  31. package/lib/structures/rules/index.js +3 -0
  32. package/lib/structures/rules/index.js.map +1 -1
  33. package/linthost/ast_helpers.go +521 -23
  34. package/linthost/ast_selector_matcher.go +1628 -0
  35. package/linthost/ast_selector_parser.go +628 -0
  36. package/linthost/comment_scan.go +154 -0
  37. package/linthost/compile.go +129 -79
  38. package/linthost/config.go +352 -88
  39. package/linthost/config_format.go +15 -1
  40. package/linthost/contrib_adapter.go +129 -25
  41. package/linthost/declaration_rules.go +12 -11
  42. package/linthost/directives.go +9 -67
  43. package/linthost/dispatch.go +37 -3
  44. package/linthost/engine.go +368 -53
  45. package/linthost/escape_string.go +77 -0
  46. package/linthost/fix.go +133 -23
  47. package/linthost/flags_gen.go +18 -17
  48. package/linthost/format.go +73 -167
  49. package/linthost/format_editor_settings.go +134 -26
  50. package/linthost/grapheme_tables_gen.go +1920 -0
  51. package/linthost/host.go +160 -61
  52. package/linthost/literal_escape_scan.go +117 -0
  53. package/linthost/lsp.go +545 -98
  54. package/linthost/numeric_literal_precision.go +228 -0
  55. package/linthost/polyfill_browserslist.go +1439 -0
  56. package/linthost/polyfill_browserslist_config.go +603 -0
  57. package/linthost/polyfill_corejs.go +750 -0
  58. package/linthost/polyfill_data.go +113 -0
  59. package/linthost/polyfill_data_gen.json +39868 -0
  60. package/linthost/print_nodes_array.go +7 -1
  61. package/linthost/print_nodes_object.go +7 -1
  62. package/linthost/project_engine.go +151 -0
  63. package/linthost/project_rules.go +78 -0
  64. package/linthost/regex_clean.go +191 -0
  65. package/linthost/regex_tree.go +1544 -0
  66. package/linthost/regex_tree_optimizer.go +1685 -0
  67. package/linthost/regexp_cache.go +44 -0
  68. package/linthost/rule_codes.go +105 -0
  69. package/linthost/rule_codes.json +745 -0
  70. package/linthost/rules_ban_ts_comment.go +312 -0
  71. package/linthost/rules_boundaries.go +128 -43
  72. package/linthost/rules_boundaries_dependencies.go +1013 -0
  73. package/linthost/rules_core_extra.go +62 -92
  74. package/linthost/rules_cypress.go +1 -1
  75. package/linthost/rules_default_case.go +87 -11
  76. package/linthost/rules_dupes.go +38 -13
  77. package/linthost/rules_empty.go +188 -20
  78. package/linthost/rules_escape.go +62 -48
  79. package/linthost/rules_format_arrow_parens.go +19 -5
  80. package/linthost/rules_format_bracket_spacing.go +1 -1
  81. package/linthost/rules_format_clause_join.go +1 -1
  82. package/linthost/rules_format_declaration_header.go +31 -19
  83. package/linthost/rules_format_indent.go +1 -1
  84. package/linthost/rules_format_jsdoc.go +1 -1
  85. package/linthost/rules_format_orphan_semi.go +1 -1
  86. package/linthost/rules_format_parameter_properties.go +17 -7
  87. package/linthost/rules_format_print_width.go +1 -1
  88. package/linthost/rules_format_quote_props.go +1 -1
  89. package/linthost/rules_format_quotes.go +1 -1
  90. package/linthost/rules_format_semi.go +106 -79
  91. package/linthost/rules_format_sort_imports.go +79 -45
  92. package/linthost/rules_format_statement_split.go +1 -1
  93. package/linthost/rules_format_ternary_nullish_parens.go +1 -1
  94. package/linthost/rules_format_trailing_comma.go +64 -1
  95. package/linthost/rules_format_whitespace.go +1 -1
  96. package/linthost/rules_functional.go +21 -22
  97. package/linthost/rules_gap.go +80 -3
  98. package/linthost/rules_grouped_accessor_pairs.go +84 -25
  99. package/linthost/rules_guard_for_in.go +64 -147
  100. package/linthost/rules_jsx_a11y.go +232 -22
  101. package/linthost/rules_logic.go +84 -38
  102. package/linthost/rules_nextjs.go +105 -16
  103. package/linthost/rules_no_class_assign.go +70 -0
  104. package/linthost/rules_no_duplicate_imports.go +266 -0
  105. package/linthost/rules_no_else_return.go +156 -38
  106. package/linthost/rules_no_extend_native.go +113 -17
  107. package/linthost/rules_no_fallthrough.go +1013 -0
  108. package/linthost/rules_no_func_assign.go +70 -0
  109. package/linthost/rules_no_import_assign.go +278 -86
  110. package/linthost/rules_no_inner_declarations.go +186 -0
  111. package/linthost/rules_no_loop_func.go +438 -53
  112. package/linthost/rules_no_mixed_operators.go +208 -52
  113. package/linthost/rules_no_param_reassign.go +376 -61
  114. package/linthost/rules_no_promise_executor_return.go +156 -0
  115. package/linthost/rules_no_restricted_imports.go +1013 -57
  116. package/linthost/rules_no_restricted_syntax.go +190 -26
  117. package/linthost/rules_no_useless_constructor.go +6 -36
  118. package/linthost/rules_problems.go +154 -311
  119. package/linthost/rules_promise.go +2578 -109
  120. package/linthost/rules_react_extras.go +71 -45
  121. package/linthost/rules_react_hooks.go +18 -2
  122. package/linthost/rules_react_perf.go +1 -0
  123. package/linthost/rules_react_refresh.go +1 -1
  124. package/linthost/rules_regexp.go +230 -14
  125. package/linthost/rules_security.go +45 -19
  126. package/linthost/rules_self.go +21 -1
  127. package/linthost/rules_solid.go +13 -7
  128. package/linthost/rules_storybook.go +1 -1
  129. package/linthost/rules_suggestions.go +490 -84
  130. package/linthost/rules_testing_library.go +12 -1
  131. package/linthost/rules_ts.go +227 -54
  132. package/linthost/rules_ts_async.go +0 -131
  133. package/linthost/rules_ts_no_base_to_string.go +3 -4
  134. package/linthost/rules_ts_no_misused_promises.go +817 -0
  135. package/linthost/rules_ts_no_restricted_types.go +320 -34
  136. package/linthost/rules_ts_no_unnecessary_qualifier.go +132 -55
  137. package/linthost/rules_ts_no_unsafe_assignment.go +608 -60
  138. package/linthost/rules_ts_require_array_sort_compare.go +5 -5
  139. package/linthost/rules_ts_switch_exhaustiveness_check.go +556 -86
  140. package/linthost/rules_unicorn_better_regex.go +224 -16
  141. package/linthost/rules_unicorn_consistent_destructuring.go +1002 -7
  142. package/linthost/rules_unicorn_consistent_existence_index_check.go +208 -29
  143. package/linthost/rules_unicorn_consistent_function_scoping.go +1041 -8
  144. package/linthost/rules_unicorn_consistent_template_literal_escape.go +178 -7
  145. package/linthost/rules_unicorn_escape_case.go +30 -19
  146. package/linthost/rules_unicorn_filename_case.go +763 -16
  147. package/linthost/rules_unicorn_import_style.go +783 -17
  148. package/linthost/rules_unicorn_isolated_functions.go +934 -14
  149. package/linthost/rules_unicorn_no_hex_escape.go +26 -11
  150. package/linthost/rules_unicorn_no_typeof_undefined.go +234 -25
  151. package/linthost/rules_unicorn_no_unnecessary_polyfills.go +376 -21
  152. package/linthost/rules_unicorn_no_unused_properties.go +756 -19
  153. package/linthost/rules_unicorn_number_literal_case.go +58 -39
  154. package/linthost/rules_unicorn_prefer_at.go +7 -3
  155. package/linthost/rules_unicorn_prefer_number_properties.go +381 -48
  156. package/linthost/rules_unicorn_prefer_optional_catch_binding.go +24 -24
  157. package/linthost/rules_unicorn_prefer_simple_condition_first.go +597 -19
  158. package/linthost/rules_unicorn_prefer_string_raw.go +147 -16
  159. package/linthost/rules_unicorn_prevent_abbreviations.go +1963 -95
  160. package/linthost/rules_unicorn_string_content.go +611 -15
  161. package/linthost/rules_unicorn_switch_case_break_position.go +172 -15
  162. package/linthost/rules_unicorn_template_indent.go +923 -18
  163. package/linthost/rules_unicorn_text_encoding_identifier_case.go +202 -42
  164. package/linthost/rules_unicorn_throw_new_error.go +218 -26
  165. package/linthost/rules_var.go +829 -142
  166. package/linthost/string_length.go +222 -0
  167. package/package.json +4 -3
  168. package/rule/project.go +145 -0
  169. package/rule/rule.go +58 -1
  170. package/src/index.ts +58 -17
  171. package/src/structures/ITtscLintConfig.ts +3 -0
  172. package/src/structures/TtscLintRuleSetting.ts +3 -2
  173. package/src/structures/format/ITtscLintFormat.ts +3 -2
  174. package/src/structures/format/ITtscLintFormatSortImports.ts +21 -4
  175. package/src/structures/rules/ITtscLintBoundariesRuleOptions.ts +122 -8
  176. package/src/structures/rules/ITtscLintBoundariesRules.ts +6 -8
  177. package/src/structures/rules/ITtscLintContributorRules.ts +18 -2
  178. package/src/structures/rules/ITtscLintCoreRuleOptions.ts +465 -0
  179. package/src/structures/rules/ITtscLintCoreRules.ts +122 -67
  180. package/src/structures/rules/ITtscLintRegexpRules.ts +7 -2
  181. package/src/structures/rules/ITtscLintRuleOptionsMap.ts +88 -7
  182. package/src/structures/rules/ITtscLintRules.ts +7 -2
  183. package/src/structures/rules/ITtscLintSolidRules.ts +0 -9
  184. package/src/structures/rules/ITtscLintTypeScriptRuleOptions.ts +248 -0
  185. package/src/structures/rules/ITtscLintTypeScriptRules.ts +64 -42
  186. package/src/structures/rules/ITtscLintUnicornRuleOptions.ts +350 -0
  187. package/src/structures/rules/ITtscLintUnicornRules.ts +60 -36
  188. package/src/structures/rules/index.ts +3 -0
@@ -86,30 +86,64 @@ func FindLintEntry(entries []PluginEntry) (*PluginEntry, error) {
86
86
  // rule name (e.g. "no-var").
87
87
  type RuleConfig map[string]Severity
88
88
 
89
- // RuleOptionsMap captures the rule-specific options blob, keyed by rule
90
- // name. Severity-only rules never appear here. The values are the raw
91
- // JSON the user wrote in the second tuple slot of a `["error", { ... }]`
92
- // rule setting; each rule decodes the blob into its own option struct on
93
- // demand.
89
+ // RuleOptionsMap captures the rule-specific options payload, keyed by rule
90
+ // name. Severity-only rules never appear here. A single option slot preserves
91
+ // its JSON shape; multiple positional slots are encoded as an array. Each rule
92
+ // decodes the payload according to its public option type on demand.
94
93
  type RuleOptionsMap map[string]json.RawMessage
95
94
 
96
- // ResolvedRuleConfig is the rule map that applies to one source file.
95
+ // ProjectRuleSetting is the global declaration resolved for one registered
96
+ // project rule. Declared distinguishes a missing entry from an explicit off.
97
+ type ProjectRuleSetting struct {
98
+ Declared bool
99
+ Severity Severity
100
+ Options json.RawMessage
101
+ }
102
+
103
+ // ResolvedRuleConfig is the complete rule setting that applies to one source
104
+ // file. Rules and Options are folded from the same matching config entries so
105
+ // an option tuple can never cross a files/ignores boundary independently of
106
+ // its severity.
107
+ //
97
108
  // `Ignored` means an `ignores`-only config entry matched the file and the
98
- // engine should skip linting it entirely.
109
+ // engine should skip linting it entirely. `OutOfScope` means the store has at
110
+ // least one rule-bearing entry but none applies to this file. Keeping the two
111
+ // states distinct lets wrappers preserve entry-local ignores: one entry may
112
+ // reject a file while another matching entry still contributes rules.
99
113
  type ResolvedRuleConfig struct {
100
114
  Rules RuleConfig
101
- Ignored bool
115
+ Options RuleOptionsMap
116
+ // OptionsResolved distinguishes an authoritative empty per-file option map
117
+ // from a legacy custom resolver that still supplies options exclusively via
118
+ // RuleResolver.RuleOptions.
119
+ OptionsResolved bool
120
+ Ignored bool
121
+ OutOfScope bool
122
+ }
123
+
124
+ // RuleOptions returns the file-resolved option payload for name. Built-in
125
+ // aliases are normalized on lookup so the same key selects both severity and
126
+ // options.
127
+ func (r ResolvedRuleConfig) RuleOptions(name string) json.RawMessage {
128
+ if raw := r.Options[name]; len(raw) > 0 {
129
+ return raw
130
+ }
131
+ if raw := r.Options[normalizeBuiltinRuleName(name)]; len(raw) > 0 {
132
+ return raw
133
+ }
134
+ return nil
102
135
  }
103
136
 
104
137
  // RuleResolver is the engine-facing view of a resolved lint configuration.
105
138
  // Implementations include RuleConfig (severity-only, no options),
106
139
  // InlineRuleResolver (a severity map plus per-rule options), and *ConfigStore
107
- // (a parsed lint config file, with per-file glob resolution and a unified
108
- // options map).
140
+ // (a parsed lint config file, with per-file glob resolution for both severity
141
+ // and options).
109
142
  type RuleResolver interface {
110
- // ResolveRules returns the effective severity map for the given source file.
111
- // Implementations that support `files`/`ignores` patterns apply them here;
112
- // flat RuleConfig always returns all rules unchanged.
143
+ // ResolveRules returns the effective severities and option payloads for the
144
+ // given source file. Implementations that support `files`/`ignores`
145
+ // patterns apply both halves of each rule setting here; flat RuleConfig
146
+ // always returns all severities unchanged and no options.
113
147
  ResolveRules(fileName string) ResolvedRuleConfig
114
148
  // ActiveRuleNames returns the sorted names of every rule that is not SeverityOff
115
149
  // in at least one config entry. Used to build the engine's dispatch table.
@@ -117,16 +151,78 @@ type RuleResolver interface {
117
151
  // EnabledRuleConfig returns the project-wide severity map for rules that are
118
152
  // not SeverityOff. Where multiple entries disagree, SeverityError wins.
119
153
  EnabledRuleConfig() RuleConfig
120
- // RuleOptions returns the raw JSON options for `name`, or nil when the
121
- // rule was configured with a severity alone. Returns nil for unknown
122
- // rule names too rules treat that as "use defaults".
154
+ // RuleOptions is the file-agnostic compatibility lookup used by flat and
155
+ // metadata-only consumers. Runtime file binding reads
156
+ // ResolveRules(fileName).RuleOptions(name), which is authoritative for
157
+ // scoped resolvers. Returns nil for severity-only and unknown rules.
123
158
  RuleOptions(name string) json.RawMessage
159
+ // ResolveProjectRules folds global declarations for registered project-rule
160
+ // names. A mention under a files selector is rejected because project state
161
+ // has no file identity.
162
+ ResolveProjectRules(names []string) (map[string]ProjectRuleSetting, error)
163
+ }
164
+
165
+ // RuleOptionsVariantsResolver is an optional extension for resolvers that can
166
+ // declare more than one option payload for a rule. Engine construction uses
167
+ // it to validate every files/extends variant before any file is visited.
168
+ // Custom resolvers that omit this interface remain compatible through the
169
+ // single RuleResolver.RuleOptions fallback.
170
+ type RuleOptionsVariantsResolver interface {
171
+ RuleOptionsVariants(name string) []json.RawMessage
172
+ }
173
+
174
+ // resolvedRuleOptionsVariants returns every option shape a rule may receive.
175
+ // File-scoped resolvers expose all declarations through the internal
176
+ // extension; flat and external resolvers retain the RuleOptions fallback.
177
+ func resolvedRuleOptionsVariants(resolver RuleResolver, name string) []json.RawMessage {
178
+ if variants, ok := resolver.(RuleOptionsVariantsResolver); ok {
179
+ if values := variants.RuleOptionsVariants(name); len(values) > 0 {
180
+ return values
181
+ }
182
+ }
183
+ return []json.RawMessage{append(json.RawMessage(nil), resolver.RuleOptions(name)...)}
184
+ }
185
+
186
+ // boundProjectRuleResolver retains the one project-wide resolution performed
187
+ // while loading a config. Engine construction reads a defensive copy instead
188
+ // of folding the extends chain a second time.
189
+ type boundProjectRuleResolver struct {
190
+ RuleResolver
191
+ settings map[string]ProjectRuleSetting
192
+ }
193
+
194
+ func bindProjectRuleResolver(resolver RuleResolver) (RuleResolver, error) {
195
+ if resolver == nil {
196
+ resolver = RuleConfig{}
197
+ }
198
+ settings, err := resolver.ResolveProjectRules(allProjectRuleNames())
199
+ if err != nil {
200
+ return nil, err
201
+ }
202
+ return boundProjectRuleResolver{RuleResolver: resolver, settings: settings}, nil
203
+ }
204
+
205
+ func (r boundProjectRuleResolver) ResolveProjectRules(names []string) (map[string]ProjectRuleSetting, error) {
206
+ settings := make(map[string]ProjectRuleSetting, len(names))
207
+ for _, name := range names {
208
+ setting := r.settings[name]
209
+ setting.Options = append(json.RawMessage(nil), setting.Options...)
210
+ settings[name] = setting
211
+ }
212
+ return settings, nil
213
+ }
214
+
215
+ func (r boundProjectRuleResolver) RuleOptionsVariants(name string) []json.RawMessage {
216
+ return resolvedRuleOptionsVariants(r.RuleResolver, name)
124
217
  }
125
218
 
126
219
  // ResolveRules implements RuleResolver. A flat RuleConfig has no glob scoping,
127
220
  // so every file receives the full map unchanged.
128
221
  func (c RuleConfig) ResolveRules(string) ResolvedRuleConfig {
129
- return ResolvedRuleConfig{Rules: normalizeRuleConfigKeys(c)}
222
+ return ResolvedRuleConfig{
223
+ Rules: normalizeRuleConfigKeys(c),
224
+ OptionsResolved: true,
225
+ }
130
226
  }
131
227
 
132
228
  // ActiveRuleNames implements RuleResolver. Returns rule names whose severity
@@ -152,6 +248,17 @@ func (c RuleConfig) EnabledRuleConfig() RuleConfig {
152
248
  // predate option support.
153
249
  func (RuleConfig) RuleOptions(string) json.RawMessage { return nil }
154
250
 
251
+ // ResolveProjectRules treats a flat RuleConfig as one global declaration.
252
+ func (c RuleConfig) ResolveProjectRules(names []string) (map[string]ProjectRuleSetting, error) {
253
+ normalized := normalizeRuleConfigKeys(c)
254
+ out := make(map[string]ProjectRuleSetting, len(names))
255
+ for _, name := range names {
256
+ severity, declared := normalized[normalizeBuiltinRuleName(name)]
257
+ out[name] = ProjectRuleSetting{Declared: declared, Severity: severity}
258
+ }
259
+ return out, nil
260
+ }
261
+
155
262
  func normalizeRuleConfigKeys(c RuleConfig) RuleConfig {
156
263
  if len(c) == 0 {
157
264
  return c
@@ -174,7 +281,22 @@ type InlineRuleResolver struct {
174
281
  // ResolveRules implements RuleResolver. Inline rules have no glob scoping;
175
282
  // the full map applies to every file.
176
283
  func (r InlineRuleResolver) ResolveRules(string) ResolvedRuleConfig {
177
- return ResolvedRuleConfig{Rules: normalizeRuleConfigKeys(r.Rules)}
284
+ return ResolvedRuleConfig{
285
+ Rules: normalizeRuleConfigKeys(r.Rules),
286
+ Options: normalizeRuleOptionsKeys(r.Options),
287
+ OptionsResolved: true,
288
+ }
289
+ }
290
+
291
+ func normalizeRuleOptionsKeys(options RuleOptionsMap) RuleOptionsMap {
292
+ if len(options) == 0 {
293
+ return nil
294
+ }
295
+ normalized := make(RuleOptionsMap, len(options))
296
+ for name, raw := range options {
297
+ normalized[normalizeBuiltinRuleName(name)] = append(json.RawMessage(nil), raw...)
298
+ }
299
+ return normalized
178
300
  }
179
301
 
180
302
  // ActiveRuleNames implements RuleResolver by delegating to the inner RuleConfig.
@@ -204,11 +326,25 @@ func (r InlineRuleResolver) RuleOptions(name string) json.RawMessage {
204
326
  return nil
205
327
  }
206
328
 
329
+ // ResolveProjectRules treats inline rules as global and preserves their
330
+ // explicit options tuple.
331
+ func (r InlineRuleResolver) ResolveProjectRules(names []string) (map[string]ProjectRuleSetting, error) {
332
+ settings, _ := r.Rules.ResolveProjectRules(names)
333
+ for _, name := range names {
334
+ setting := settings[name]
335
+ setting.Options = append(json.RawMessage(nil), r.RuleOptions(name)...)
336
+ settings[name] = setting
337
+ }
338
+ return settings, nil
339
+ }
340
+
207
341
  // ConfigStore holds the parsed representation of a lint config file. It
208
342
  // implements RuleResolver with per-file glob scoping: ResolveRules walks the
209
- // entries in declaration order and the last matching entry wins. Options are
210
- // intentionally NOT per-file one project-wide options map is kept so rule
211
- // behavior is uniform across the codebase even when severity varies by glob.
343
+ // entries in declaration order and folds each matching rule's severity and
344
+ // options together. A later option tuple replaces the inherited payload; a
345
+ // later severity-only declaration preserves options from an earlier matching
346
+ // entry and cannot borrow them from an entry that did not match the file.
347
+ // https://eslint.org/docs/latest/use/configure/rules#using-configuration-files
212
348
  //
213
349
  // A config file is a single `ITtscLintConfig` object. Its `extends` field
214
350
  // names another config file to fold in first; the extends chain produces one
@@ -216,27 +352,64 @@ func (r InlineRuleResolver) RuleOptions(name string) json.RawMessage {
216
352
  // extending file's own entry so local rules win on collision.
217
353
  type ConfigStore struct {
218
354
  entries []ConfigEntry
219
- // options is a flat rule-name → JSON map. Options are not scoped by
220
- // `files` / `ignores`: a rule's behavior is a single project-wide
221
- // configuration even when its severity is per-file. The simplification
222
- // matches prettier-style options (one setting per project) while
223
- // keeping severity layering intact.
224
- options RuleOptionsMap
225
355
  }
226
356
 
227
- // RuleOptions implements RuleResolver.RuleOptions on ConfigStore.
357
+ // RuleOptions implements the file-agnostic RuleResolver compatibility method.
358
+ // Engine execution does not use this representative value: ResolveRules
359
+ // carries the matching file's options. Callers that only understand the older
360
+ // interface observe the final declared tuple, preserving the former flat
361
+ // resolver behavior without storing a second source of truth.
228
362
  func (s *ConfigStore) RuleOptions(name string) json.RawMessage {
229
363
  if s == nil {
230
364
  return nil
231
365
  }
232
- if raw := s.options[name]; len(raw) > 0 {
233
- return raw
366
+ canonical := normalizeBuiltinRuleName(name)
367
+ var selected json.RawMessage
368
+ for _, entry := range s.entries {
369
+ if raw := entry.Options[canonical]; len(raw) > 0 {
370
+ selected = raw
371
+ }
372
+ }
373
+ return append(json.RawMessage(nil), selected...)
374
+ }
375
+
376
+ // flattenOptions returns the final declared payload for each rule without
377
+ // claiming that it applies to any particular file. Metadata-only consumers
378
+ // use this to enumerate option-bearing rules; execution always uses
379
+ // ResolveRules instead.
380
+ func (s *ConfigStore) flattenOptions() RuleOptionsMap {
381
+ if s == nil {
382
+ return nil
383
+ }
384
+ options := RuleOptionsMap{}
385
+ for _, entry := range s.entries {
386
+ for name, raw := range entry.Options {
387
+ options[normalizeBuiltinRuleName(name)] = append(json.RawMessage(nil), raw...)
388
+ }
389
+ }
390
+ return options
391
+ }
392
+
393
+ // RuleOptionsVariants exposes every entry-local payload (including a nil
394
+ // severity-only declaration) so engine construction validates the full
395
+ // files/extends surface rather than whichever tuple happened to be parsed
396
+ // last.
397
+ func (s *ConfigStore) RuleOptionsVariants(name string) []json.RawMessage {
398
+ if s == nil {
399
+ return nil
234
400
  }
235
401
  canonical := normalizeBuiltinRuleName(name)
236
- if raw := s.options[canonical]; len(raw) > 0 {
237
- return raw
402
+ variants := make([]json.RawMessage, 0)
403
+ for _, entry := range s.entries {
404
+ if entry.IgnoreOnly {
405
+ continue
406
+ }
407
+ if _, declared := entry.Rules[canonical]; !declared {
408
+ continue
409
+ }
410
+ variants = append(variants, append(json.RawMessage(nil), entry.Options[canonical]...))
238
411
  }
239
- return nil
412
+ return variants
240
413
  }
241
414
 
242
415
  // ConfigEntry is the parsed form of one config file in the extends chain.
@@ -245,11 +418,13 @@ func (s *ConfigStore) RuleOptions(name string) json.RawMessage {
245
418
  // — these are evaluated first in ResolveRules and short-circuit the walk when
246
419
  // matched.
247
420
  type ConfigEntry struct {
248
- BaseDir string
249
- Files []string
250
- Ignores []string
251
- Rules RuleConfig
252
- IgnoreOnly bool
421
+ BaseDir string
422
+ Files []string
423
+ HasFilesSelector bool
424
+ Ignores []string
425
+ Rules RuleConfig
426
+ Options RuleOptionsMap
427
+ IgnoreOnly bool
253
428
  }
254
429
 
255
430
  // ResolveRules implements RuleResolver. Ignore-only entries are checked first;
@@ -258,23 +433,40 @@ type ConfigEntry struct {
258
433
  // entry wins (later entries shadow earlier ones for the same rule name).
259
434
  func (s *ConfigStore) ResolveRules(fileName string) ResolvedRuleConfig {
260
435
  if s == nil {
261
- return ResolvedRuleConfig{Rules: RuleConfig{}}
436
+ return ResolvedRuleConfig{Rules: RuleConfig{}, OptionsResolved: true}
262
437
  }
263
438
  for _, entry := range s.entries {
264
439
  if entry.IgnoreOnly && entry.matchesIgnores(fileName) {
265
- return ResolvedRuleConfig{Rules: RuleConfig{}, Ignored: true}
440
+ return ResolvedRuleConfig{Rules: RuleConfig{}, OptionsResolved: true, Ignored: true}
266
441
  }
267
442
  }
268
443
  out := RuleConfig{}
444
+ options := RuleOptionsMap{}
445
+ hasEntries := false
446
+ matchedEntry := false
269
447
  for _, entry := range s.entries {
270
- if entry.IgnoreOnly || !entry.matchesFile(fileName) {
448
+ if entry.IgnoreOnly {
449
+ continue
450
+ }
451
+ hasEntries = true
452
+ if !entry.matchesFile(fileName) {
271
453
  continue
272
454
  }
455
+ matchedEntry = true
273
456
  for name, sev := range entry.Rules {
274
- out[normalizeBuiltinRuleName(name)] = sev
457
+ canonical := normalizeBuiltinRuleName(name)
458
+ out[canonical] = sev
459
+ if raw := entry.Options[canonical]; len(raw) > 0 {
460
+ options[canonical] = append(json.RawMessage(nil), raw...)
461
+ }
275
462
  }
276
463
  }
277
- return ResolvedRuleConfig{Rules: out}
464
+ return ResolvedRuleConfig{
465
+ Rules: out,
466
+ Options: options,
467
+ OptionsResolved: true,
468
+ OutOfScope: hasEntries && !matchedEntry,
469
+ }
278
470
  }
279
471
 
280
472
  // ActiveRuleNames implements RuleResolver. Returns the sorted union of all rule
@@ -324,6 +516,47 @@ func (s *ConfigStore) EnabledRuleConfig() RuleConfig {
324
516
  return out
325
517
  }
326
518
 
519
+ // ResolveProjectRules folds the extends-expanded entries base-first. Only
520
+ // global entries participate; any project-rule mention under files is an
521
+ // invalid configuration, including off declarations and option tuples.
522
+ func (s *ConfigStore) ResolveProjectRules(names []string) (map[string]ProjectRuleSetting, error) {
523
+ out := make(map[string]ProjectRuleSetting, len(names))
524
+ wanted := make(map[string]string, len(names))
525
+ for _, name := range names {
526
+ canonical := normalizeBuiltinRuleName(name)
527
+ wanted[canonical] = name
528
+ out[name] = ProjectRuleSetting{}
529
+ }
530
+ if s == nil {
531
+ return out, nil
532
+ }
533
+ for _, entry := range s.entries {
534
+ if entry.IgnoreOnly {
535
+ continue
536
+ }
537
+ for configuredName, severity := range entry.Rules {
538
+ name, projectRule := wanted[normalizeBuiltinRuleName(configuredName)]
539
+ if !projectRule {
540
+ continue
541
+ }
542
+ if entry.HasFilesSelector {
543
+ return nil, fmt.Errorf(
544
+ "@ttsc/lint: project rule %q cannot be configured in an entry with files",
545
+ name,
546
+ )
547
+ }
548
+ setting := out[name]
549
+ setting.Declared = true
550
+ setting.Severity = severity
551
+ if raw := entry.Options[normalizeBuiltinRuleName(configuredName)]; len(raw) > 0 {
552
+ setting.Options = append(json.RawMessage(nil), raw...)
553
+ }
554
+ out[name] = setting
555
+ }
556
+ }
557
+ return out, nil
558
+ }
559
+
327
560
  // Flatten returns the unconstrained union of all non-ignore-only entries,
328
561
  // including SeverityOff rules. Used by LoadRuleConfig (callers that expect a
329
562
  // plain RuleConfig). Later entries shadow earlier ones for the same rule name.
@@ -372,9 +605,9 @@ func ParseRules(raw any) (RuleConfig, error) {
372
605
  }
373
606
 
374
607
  // ParseRulesWithOptions accepts either a severity literal or a
375
- // `[severity, options]` tuple per rule and returns the severity map
608
+ // `[severity, ...options]` tuple per rule and returns the severity map
376
609
  // alongside an options map keyed by rule name. The options map only
377
- // contains entries for rules whose configuration was the tuple form.
610
+ // contains entries for rules whose configuration carries option slots.
378
611
  func ParseRulesWithOptions(raw any) (RuleConfig, RuleOptionsMap, error) {
379
612
  if raw == nil {
380
613
  return RuleConfig{}, RuleOptionsMap{}, nil
@@ -398,10 +631,10 @@ func ParseRulesWithOptions(raw any) (RuleConfig, RuleOptionsMap, error) {
398
631
  return cfg, opts, nil
399
632
  }
400
633
 
401
- // parseRuleEntry splits a rule entry into its severity and (optional) options
402
- // payload. Bare severity literals produce a nil options blob; `[severity]`
403
- // (no options) does the same; `[severity, options]` re-serializes the options
404
- // to JSON so each rule can decode it into its own struct later.
634
+ // parseRuleEntry splits a rule entry into its severity and optional positional
635
+ // options. A single option keeps its JSON shape for existing object-option
636
+ // rules. Two or more options become a JSON array so canonical ESLint rules
637
+ // with several positional slots can decode them without parser special cases.
405
638
  func parseRuleEntry(value any) (Severity, json.RawMessage, error) {
406
639
  if tuple, ok := value.([]any); ok {
407
640
  if len(tuple) == 0 {
@@ -414,21 +647,14 @@ func parseRuleEntry(value any) (Severity, json.RawMessage, error) {
414
647
  if len(tuple) == 1 {
415
648
  return sev, nil, nil
416
649
  }
417
- if len(tuple) > 2 {
418
- return SeverityOff, nil, fmt.Errorf("severity tuple must be [severity] or [severity, options], got %d elements", len(tuple))
419
- }
420
- if tuple[1] == nil {
650
+ if len(tuple) == 2 && tuple[1] == nil {
421
651
  return sev, nil, nil
422
652
  }
423
- if _, ok := tuple[1].(map[string]any); !ok {
424
- // A positional string option (e.g. `["error", "single"]`) is
425
- // rejected: every option struct in TtscLintRuleOptions is an
426
- // object, and silently encoding a non-object slot would land in
427
- // DecodeOptions as a decode error that every rule discards. Fail
428
- // loudly so users discover the proper `["error", { … }]` form.
429
- return SeverityOff, nil, fmt.Errorf("severity tuple's options slot must be an object, got %T", tuple[1])
653
+ var payload any = tuple[1]
654
+ if len(tuple) > 2 {
655
+ payload = tuple[1:]
430
656
  }
431
- encoded, err := json.Marshal(tuple[1])
657
+ encoded, err := json.Marshal(payload)
432
658
  if err != nil {
433
659
  return SeverityOff, nil, fmt.Errorf("encode options: %w", err)
434
660
  }
@@ -558,6 +784,7 @@ func collectConfigObject(store *ConfigStore, raw any, baseDir, path string, chai
558
784
  }
559
785
  }
560
786
 
787
+ _, hasFilesSelector := obj["files"]
561
788
  files, err := parsePatternList(obj["files"], path+".files")
562
789
  if err != nil {
563
790
  return err
@@ -626,38 +853,38 @@ func collectConfigObject(store *ConfigStore, raw any, baseDir, path string, chai
626
853
  }
627
854
  merged := mergeRuleMaps(formatRulesRaw, rulesMap)
628
855
  if len(merged) > 0 {
629
- parsed, err := parseExternalRuleMapInto(merged, path+".rules", store)
856
+ parsed, entryOptions, err := parseExternalRuleMapInto(merged, path+".rules")
630
857
  if err != nil {
631
858
  return err
632
859
  }
633
860
  store.entries = append(store.entries, ConfigEntry{
634
- BaseDir: baseDir,
635
- Files: files,
636
- Ignores: ignores,
637
- Rules: parsed,
861
+ BaseDir: baseDir,
862
+ Files: files,
863
+ HasFilesSelector: hasFilesSelector,
864
+ Ignores: ignores,
865
+ Rules: parsed,
866
+ Options: entryOptions,
638
867
  })
639
868
  }
640
869
  }
641
870
  return nil
642
871
  }
643
872
 
644
- // parseExternalRuleMapInto parses the rules map and folds any
645
- // option blobs into `store.options`. Used by entry-creation paths so
646
- // the store ends with a unified options map for RuleResolver consumers.
647
- func parseExternalRuleMapInto(raw any, path string, store *ConfigStore) (RuleConfig, error) {
873
+ // parseExternalRuleMapInto parses one entry's rules and option tuples. Options
874
+ // stay on the ConfigEntry that owns their files/ignores scope; no project-wide
875
+ // mirror is created.
876
+ func parseExternalRuleMapInto(raw any, path string) (RuleConfig, RuleOptionsMap, error) {
648
877
  out := RuleConfig{}
649
- if store.options == nil {
650
- store.options = RuleOptionsMap{}
651
- }
652
- if err := collectExternalRuleMapWithOptions(out, store.options, raw, path); err != nil {
653
- return nil, err
878
+ entryOptions := RuleOptionsMap{}
879
+ if err := collectExternalRuleMapWithOptions(out, entryOptions, raw, path); err != nil {
880
+ return nil, nil, err
654
881
  }
655
- return out, nil
882
+ return out, entryOptions, nil
656
883
  }
657
884
 
658
- // collectExternalRuleMapWithOptions also records the rule's options blob
659
- // when the entry is a `[severity, options]` tuple. `opts` may be nil
660
- // when the caller does not need option capture.
885
+ // collectExternalRuleMapWithOptions also records the rule's option payload
886
+ // when the entry is a `[severity, ...options]` tuple. `opts` may be nil when
887
+ // the caller does not need option capture.
661
888
  func collectExternalRuleMapWithOptions(out RuleConfig, opts RuleOptionsMap, raw any, path string) error {
662
889
  dict, ok := raw.(map[string]any)
663
890
  if !ok {
@@ -865,6 +1092,30 @@ func findLintConfigFileFrom(dir string) (string, error) {
865
1092
  }
866
1093
  }
867
1094
 
1095
+ // pluginConfigDirEnv mirrors driver.PluginConfigDirEnv from
1096
+ // `packages/ttsc/driver`: the environment variable through which the ttsc
1097
+ // launcher passes the project root that plugin config-file discovery and
1098
+ // relative "configFile" resolution anchor at. It matters when the compiled
1099
+ // tsconfig is a generated wrapper outside the project (e.g. @ttsc/unplugin's
1100
+ // compiler-options overlay in the system temp directory) — the tsconfig
1101
+ // directory then no longer identifies the project. The constant is inlined
1102
+ // here instead of imported because @ttsc/lint deliberately avoids a
1103
+ // dependency on the in-tree ttsc module (see host.go).
1104
+ const pluginConfigDirEnv = "TTSC_PLUGIN_CONFIG_DIR"
1105
+
1106
+ // explicitPluginConfigDir returns the launcher-provided config anchor from
1107
+ // pluginConfigDirEnv, resolved against cwd, or "" when the channel is unset.
1108
+ func explicitPluginConfigDir(cwd string) string {
1109
+ dir := strings.TrimSpace(os.Getenv(pluginConfigDirEnv))
1110
+ if dir == "" {
1111
+ return ""
1112
+ }
1113
+ if !filepath.IsAbs(dir) && cwd != "" {
1114
+ dir = filepath.Join(cwd, dir)
1115
+ }
1116
+ return filepath.Clean(dir)
1117
+ }
1118
+
868
1119
  // resolveConfigFilePath resolves a user-supplied config path to an absolute
869
1120
  // path. Absolute paths are returned unchanged; relative paths are joined to the
870
1121
  // tsconfig directory (or cwd when no tsconfig is set).
@@ -877,13 +1128,18 @@ func resolveConfigFilePath(configPath, cwd, tsconfigPath string) string {
877
1128
 
878
1129
  // discoveryConfigBaseDirs returns the ordered directories from which
879
1130
  // auto-discovery walks upward when no explicit config path is provided. The
880
- // tsconfig directory comes first so that nested package configs are found
881
- // relative to the tsconfig that triggered the lint run; the working directory
882
- // follows as a fallback so a caller that points at an out-of-tree tsconfig
883
- // (e.g. a TtscCompiler invocation whose wrapper tsconfig lives in the system
884
- // temp dir but whose cwd/projectRoot is the real project) still discovers the
885
- // project's lint config instead of failing on the temp dir's empty ancestry.
1131
+ // launcher's explicit project-root channel (pluginConfigDirEnv) is the single
1132
+ // origin when set: it names the real project even when the tsconfig is a
1133
+ // generated wrapper in a temp directory, and keeps the wrapper's temp-tree
1134
+ // ancestry out of the walk. Otherwise the tsconfig directory comes first so
1135
+ // that nested package configs are found relative to the tsconfig that
1136
+ // triggered the lint run; the working directory follows as a fallback so a
1137
+ // caller that points at an out-of-tree tsconfig still discovers the project's
1138
+ // lint config instead of failing on the tsconfig dir's empty ancestry.
886
1139
  func discoveryConfigBaseDirs(cwd, tsconfigPath string) []string {
1140
+ if explicit := explicitPluginConfigDir(cwd); explicit != "" {
1141
+ return []string{explicit}
1142
+ }
887
1143
  origins := make([]string, 0, 2)
888
1144
  if tsconfigPath != "" {
889
1145
  resolvedTsconfig := tsconfigPath
@@ -914,10 +1170,16 @@ func containsPath(paths []string, candidate string) bool {
914
1170
  return false
915
1171
  }
916
1172
 
917
- // tsconfigBaseDir returns the directory that contains the tsconfig file, or
918
- // cwd when tsconfigPath is empty. Used as the base for relative config paths
919
- // supplied in the tsconfig plugin entry.
1173
+ // tsconfigBaseDir returns the base directory for relative config paths
1174
+ // supplied in the tsconfig plugin entry. The launcher's explicit project-root
1175
+ // channel (pluginConfigDirEnv) wins when set — the tsconfig may be a
1176
+ // generated wrapper in a temp directory that no longer identifies the
1177
+ // project — otherwise the directory containing the tsconfig is used, falling
1178
+ // back to cwd when tsconfigPath is empty.
920
1179
  func tsconfigBaseDir(cwd, tsconfigPath string) string {
1180
+ if explicit := explicitPluginConfigDir(cwd); explicit != "" {
1181
+ return explicit
1182
+ }
921
1183
  if tsconfigPath == "" {
922
1184
  return cwd
923
1185
  }
@@ -1804,10 +2066,12 @@ func matchAnyPattern(baseDir string, patterns []string, fileName string) bool {
1804
2066
  if abs, err := filepath.Abs(base); err == nil {
1805
2067
  base = abs
1806
2068
  }
2069
+ base = realProjectPath(base)
1807
2070
  file := fileName
1808
2071
  if abs, err := filepath.Abs(file); err == nil {
1809
2072
  file = abs
1810
2073
  }
2074
+ file = realProjectPath(file)
1811
2075
  if candidate, err := filepath.Rel(base, file); err == nil {
1812
2076
  if candidate == ".." || strings.HasPrefix(candidate, ".."+string(filepath.Separator)) {
1813
2077
  return false
@@ -221,12 +221,20 @@ func expandFormatBlock(raw map[string]any) (map[string]any, error) {
221
221
  out["format/whitespace"] = ruleEntry(wsOpts)
222
222
 
223
223
  // formatSortImports, opt-in by `sortImports` (a boolean or options object).
224
+ // The top-level `endOfLine` is threaded in (like the other layout rules)
225
+ // so the rebuilt import block joins declarations with the file's line
226
+ // ending instead of a hard-coded LF. `endOfLine` is not a sortImports
227
+ // sub-key, so it is injected here rather than validated by
228
+ // expandSortImportsBlock.
224
229
  if v, ok := raw["sortImports"]; ok && v != nil {
225
230
  siOpts, enabled, err := expandSortImportsBlock(v)
226
231
  if err != nil {
227
232
  return nil, err
228
233
  }
229
234
  if enabled {
235
+ if eol, ok := layoutOpts["endOfLine"]; ok {
236
+ siOpts["endOfLine"] = eol
237
+ }
230
238
  out["format/sort-imports"] = ruleEntry(siOpts)
231
239
  }
232
240
  }
@@ -316,8 +324,14 @@ func expandSortImportsBlock(v any) (map[string]any, bool, error) {
316
324
  return nil, false, err
317
325
  }
318
326
  opts["combineTypeAndValue"] = b
327
+ case "unsafeSortRuntimeImports":
328
+ b, err := asBool("format.sortImports.unsafeSortRuntimeImports", val)
329
+ if err != nil {
330
+ return nil, false, err
331
+ }
332
+ opts["unsafeSortRuntimeImports"] = b
319
333
  default:
320
- return nil, false, fmt.Errorf("@ttsc/lint: format.sortImports unknown key %q (allowed: order, caseSensitive, combineTypeAndValue)", key)
334
+ return nil, false, fmt.Errorf("@ttsc/lint: format.sortImports unknown key %q (allowed: order, caseSensitive, combineTypeAndValue, unsafeSortRuntimeImports)", key)
321
335
  }
322
336
  }
323
337
  return opts, true, nil