@ttsc/lint 0.18.4 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/README.md +125 -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 +47 -8
  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 +159 -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 +231 -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 +206 -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
@@ -0,0 +1,77 @@
1
+ // escape_string.go ports eslint-plugin-unicorn's shared `escapeString` helper
2
+ // (`rules/utils/escape-string.js`), which delegates to the `quote-js-string`
3
+ // package. Every unicorn rule that rewrites a string literal from its cooked
4
+ // value routes the replacement through it — `unicorn/better-regex`'s
5
+ // `new RegExp("pattern", "flags")` branch and `unicorn/string-content`'s
6
+ // literal branch — so the two share one port here rather than drifting apart.
7
+ //
8
+ // quote-js-string source: https://github.com/sindresorhus/quote-js-string
9
+ package linthost
10
+
11
+ import (
12
+ "strconv"
13
+ "strings"
14
+ )
15
+
16
+ // escapeStringIsUnsafeRune reports the code points quote-js-string treats as
17
+ // unsafe to write raw into a JavaScript string literal: the C0 control range
18
+ // (<= U+001F), DEL (U+007F), the U+2028 / U+2029 line and paragraph separators
19
+ // (legal raw since ES2019, but line-oriented tooling still renders the literal
20
+ // as broken across lines), and lone surrogates (no valid UTF-8 encoding).
21
+ func escapeStringIsUnsafeRune(codePoint rune) bool {
22
+ return codePoint <= 0x1F ||
23
+ codePoint == 0x7F ||
24
+ codePoint == 0x2028 ||
25
+ codePoint == 0x2029 ||
26
+ (codePoint >= 0xD800 && codePoint <= 0xDFFF)
27
+ }
28
+
29
+ // escapeString wraps `value` in `quote` (`'` or `"`) the way upstream's
30
+ // escapeString helper does. It escapes the backslash, the delimiter quote, and
31
+ // every unsafe code point — writing the named escapes `\n \r \t \b \f \v` where
32
+ // they apply and a braced ES6 code-point escape `\u{HEX}` (lowercase hex,
33
+ // `\u{0}` for NUL) for the rest. Escaping the line terminators is what keeps a
34
+ // cooked LF or CR from closing the literal early and corrupting the file.
35
+ //
36
+ // All other text passes through raw, exactly as upstream writes it: the
37
+ // non-delimiter quote characters, C1 controls above DEL, non-ASCII letters,
38
+ // exotic Unicode whitespace such as NBSP and the U+2000-U+200A group, and
39
+ // astral symbols. Lone surrogates cannot reach this function — the tsgo scanner
40
+ // cooks them to U+FFFD, which is safe and passes through like any other text.
41
+ func escapeString(value string, quote byte) string {
42
+ var out strings.Builder
43
+ out.Grow(len(value) + 2)
44
+ out.WriteByte(quote)
45
+ for _, character := range value {
46
+ switch {
47
+ case character == '\\':
48
+ out.WriteString(`\\`)
49
+ case character == rune(quote):
50
+ out.WriteByte('\\')
51
+ out.WriteByte(quote)
52
+ case escapeStringIsUnsafeRune(character):
53
+ switch character {
54
+ case '\n':
55
+ out.WriteString(`\n`)
56
+ case '\r':
57
+ out.WriteString(`\r`)
58
+ case '\t':
59
+ out.WriteString(`\t`)
60
+ case '\b':
61
+ out.WriteString(`\b`)
62
+ case '\f':
63
+ out.WriteString(`\f`)
64
+ case '\v':
65
+ out.WriteString(`\v`)
66
+ default:
67
+ out.WriteString(`\u{`)
68
+ out.WriteString(strconv.FormatInt(int64(character), 16))
69
+ out.WriteString(`}`)
70
+ }
71
+ default:
72
+ out.WriteRune(character)
73
+ }
74
+ }
75
+ out.WriteByte(quote)
76
+ return out.String()
77
+ }
package/linthost/fix.go CHANGED
@@ -1,7 +1,7 @@
1
1
  // Autofix orchestration for the `@ttsc/lint fix` subcommand.
2
2
  //
3
3
  // RunFix drives the fix cascade: it repeatedly runs the native lint engine
4
- // and applies any emitted TextEdit suggestions until no more fixable
4
+ // and applies any emitted automatic TextEdit fixes until no more fixable
5
5
  // findings remain or maxFixPasses is reached. After the cascade settles, it
6
6
  // runs a final diagnostic pass so remaining issues are surfaced in the
7
7
  // normal error stream.
@@ -43,14 +43,24 @@ func runFix(opts *subcommandOpts) int {
43
43
  fmt.Fprintln(os.Stderr, err)
44
44
  return 2
45
45
  }
46
- // `ttsc fix` is the run-everything entry point: lint fixes AND formatter
47
- // edits. Formatting is configured solely through the `format` block (a
48
- // format/* key in `rules` is dropped), so wrap the resolver the same way
49
- // `ttsc format` and the LSP paths do formatCommandResolver force-
50
- // activates the format rules the format block configured. A project with
51
- // no format block populates no format options, so nothing extra is enabled
52
- // and fix stays a pure lint pass.
46
+ // `ttsc fix` runs lint autofixes AND format-rule edits in one pass. Formatting
47
+ // is configured solely through the `format` block (a format/* key in `rules`
48
+ // is dropped), so wrap the resolver in formatCommandResolver to force-activate
49
+ // the format rules that block configuredthe same promotion `ttsc format`
50
+ // applies to a configured block.
51
+ //
52
+ // Deliberately NOT newFormatCommandResolver: fix supplies no defaultOptions, so
53
+ // a project with no `format` block enables no format rules and fix stays a pure
54
+ // lint pass. Loading the always-on default formatter is `ttsc format`'s job;
55
+ // doing it here would reformat every file during a plain lint fix. This
56
+ // divergence is the contract pinned by
57
+ // command_fix_skips_default_formatting_that_format_applies_test.go and the
58
+ // website lint docs.
53
59
  engine := NewEngineWithResolver(formatCommandResolver{inner: rules})
60
+ if err := engine.ConfigError(); err != nil {
61
+ fmt.Fprintln(os.Stderr, err)
62
+ return 2
63
+ }
54
64
  engine.SetSerial(opts.singleThreaded)
55
65
  needsRuleChecker := engine.NeedsTypeChecker()
56
66
 
@@ -73,7 +83,7 @@ func runFix(opts *subcommandOpts) int {
73
83
  // kinds of findings in one pass — no filtering needed here.
74
84
  cascadeConverged := false
75
85
  for pass := 0; pass < maxFixPasses; pass++ {
76
- findings := engine.Run(prog.userSourceFiles(), prog.checker)
86
+ findings := prog.runLintCycle(engine)
77
87
  fixed, err := applyFindingFixes(opts.cwd, findings)
78
88
  if err != nil {
79
89
  fmt.Fprintln(os.Stderr, err)
@@ -133,6 +143,7 @@ func loadFixProgram(opts *subcommandOpts, needsRuleChecker bool) (*program, int)
133
143
  singleThreaded: opts.singleThreaded,
134
144
  checkers: opts.checkers,
135
145
  tsgoArgs: opts.tsgoArgs,
146
+ projectIdentity: opts.projectIdentity,
136
147
  })
137
148
  if err != nil {
138
149
  fmt.Fprintf(os.Stderr, "@ttsc/lint: %v\n", err)
@@ -155,13 +166,14 @@ func reloadFixProgram(current *program, opts *subcommandOpts, needsRuleChecker b
155
166
  return loadFixProgram(opts, needsRuleChecker)
156
167
  }
157
168
 
158
- // fileFixes groups all pending TextEdit suggestions for a single file.
159
- // `text` is the source content at the time the findings were collected;
160
- // byte offsets in `edits` are relative to this snapshot.
169
+ // fileFixes groups all pending automatic TextEdit fixes for a single file,
170
+ // one edit group per finding so a multi-edit atomic fix stays all-or-nothing
171
+ // during selection. `text` is the source content at the time the findings were
172
+ // collected; byte offsets in `groups` are relative to this snapshot.
161
173
  type fileFixes struct {
162
- path string
163
- text string
164
- edits []TextEdit
174
+ path string
175
+ text string
176
+ groups [][]TextEdit
165
177
  }
166
178
 
167
179
  // applyFindingFixes groups all fixable findings by file, resolves each
@@ -189,7 +201,7 @@ func applyFindingFixes(cwd string, findings []*Finding) (int, error) {
189
201
  bucket = &fileFixes{path: path, text: finding.File.Text()}
190
202
  byFile[path] = bucket
191
203
  }
192
- bucket.edits = append(bucket.edits, finding.Fix...)
204
+ bucket.groups = append(bucket.groups, finding.Fix)
193
205
  }
194
206
 
195
207
  paths := make([]string, 0, len(byFile))
@@ -200,7 +212,7 @@ func applyFindingFixes(cwd string, findings []*Finding) (int, error) {
200
212
  total := 0
201
213
  for _, p := range paths {
202
214
  bucket := byFile[p]
203
- fixed, err := applyTextEditsToFile(bucket.path, bucket.text, bucket.edits)
215
+ fixed, err := applyTextEditsToFile(bucket.path, bucket.text, bucket.groups)
204
216
  if err != nil {
205
217
  return total, err
206
218
  }
@@ -209,12 +221,13 @@ func applyFindingFixes(cwd string, findings []*Finding) (int, error) {
209
221
  return total, nil
210
222
  }
211
223
 
212
- // applyTextEditsToFile selects the non-overlapping edits from `edits`, applies
213
- // them to `source` in reverse order (right-to-left) to preserve earlier
214
- // offsets, and writes the result to `path`. Returns the number of edits
215
- // applied, or 0 when no edits survive selection.
216
- func applyTextEditsToFile(path, source string, edits []TextEdit) (int, error) {
217
- selected := selectTextEdits(len(source), edits)
224
+ // applyTextEditsToFile selects a non-overlapping, per-finding-atomic set of
225
+ // edits from `groups` (one group per finding), applies them to `source` in
226
+ // reverse order (right-to-left) to preserve earlier offsets, and writes the
227
+ // result to `path`. Returns the number of edits applied, or 0 when no edits
228
+ // survive selection.
229
+ func applyTextEditsToFile(path, source string, groups [][]TextEdit) (int, error) {
230
+ selected := selectTextEditGroups(len(source), groups)
218
231
  if len(selected) == 0 {
219
232
  return 0, nil
220
233
  }
@@ -292,3 +305,100 @@ func selectTextEdits(sourceLen int, edits []TextEdit) []TextEdit {
292
305
  }
293
306
  return selected
294
307
  }
308
+
309
+ // selectTextEditGroups selects a non-overlapping application sequence from
310
+ // per-finding edit GROUPS, applying each group all-or-nothing. Unlike
311
+ // selectTextEdits, which resolves conflicts edit-by-edit, this keeps a
312
+ // finding's multi-edit fix atomic: a fix such as noImportTypeSideEffects emits
313
+ // an `import type` insert paired with an inline `type` deletion, and applying
314
+ // only one member emits invalid code (`import type { type A }`). Groups are
315
+ // considered earliest-edit-first, and a group is accepted only when every one
316
+ // of its edits coexists — under the same disjointness rules as selectTextEdits
317
+ // — with the edits already accepted from earlier groups. If any member would be
318
+ // dropped (it overlaps an already-selected group, duplicates an already-
319
+ // selected edit, is a coincident zero-width insert, or is out of bounds), the
320
+ // WHOLE group is skipped so the owning finding re-fires on the next cascade
321
+ // pass rather than half-applying (samchon/ttsc#605).
322
+ func selectTextEditGroups(sourceLen int, groups [][]TextEdit) []TextEdit {
323
+ order := make([]int, 0, len(groups))
324
+ for i := range groups {
325
+ if len(groups[i]) > 0 {
326
+ order = append(order, i)
327
+ }
328
+ }
329
+ sort.SliceStable(order, func(a, b int) bool {
330
+ return textEditGroupLess(groups[order[a]], groups[order[b]])
331
+ })
332
+ selected := make([]TextEdit, 0)
333
+ for _, index := range order {
334
+ group := dedupeTextEdits(groups[index])
335
+ candidate := make([]TextEdit, 0, len(selected)+len(group))
336
+ candidate = append(candidate, selected...)
337
+ candidate = append(candidate, group...)
338
+ kept := selectTextEdits(sourceLen, candidate)
339
+ // Accept the group only if nothing was dropped: a shorter result means one
340
+ // of its edits collided with an already-selected edit (or a sibling edit of
341
+ // its own group), so the group cannot apply atomically here.
342
+ if len(kept) == len(candidate) {
343
+ selected = kept
344
+ }
345
+ }
346
+ return selected
347
+ }
348
+
349
+ // dedupeTextEdits removes exact-duplicate edits while preserving order. A
350
+ // finding that emits the same edit twice still applies its distinct edits;
351
+ // deduping here keeps such a finding from being rejected as "internally
352
+ // inconsistent" by selectTextEditGroups, since an exact duplicate is not a
353
+ // conflict the way an overlap is.
354
+ func dedupeTextEdits(edits []TextEdit) []TextEdit {
355
+ if len(edits) == 0 {
356
+ return nil
357
+ }
358
+ seen := make(map[TextEdit]struct{}, len(edits))
359
+ out := make([]TextEdit, 0, len(edits))
360
+ for _, edit := range edits {
361
+ if _, ok := seen[edit]; ok {
362
+ continue
363
+ }
364
+ seen[edit] = struct{}{}
365
+ out = append(out, edit)
366
+ }
367
+ return out
368
+ }
369
+
370
+ // textEditGroupLess orders edit groups by their earliest member under the same
371
+ // (Pos, End, Text) key selectTextEdits sorts by, so selection is deterministic
372
+ // and the earliest-starting finding wins a contested range.
373
+ func textEditGroupLess(a, b []TextEdit) bool {
374
+ ea, eb := minTextEdit(a), minTextEdit(b)
375
+ if ea.Pos != eb.Pos {
376
+ return ea.Pos < eb.Pos
377
+ }
378
+ if ea.End != eb.End {
379
+ return ea.End < eb.End
380
+ }
381
+ return ea.Text < eb.Text
382
+ }
383
+
384
+ // minTextEdit returns the least edit of a non-empty group under the
385
+ // (Pos, End, Text) order. Panics on an empty slice; callers filter empties out.
386
+ func minTextEdit(edits []TextEdit) TextEdit {
387
+ least := edits[0]
388
+ for _, edit := range edits[1:] {
389
+ if textEditLess(edit, least) {
390
+ least = edit
391
+ }
392
+ }
393
+ return least
394
+ }
395
+
396
+ func textEditLess(a, b TextEdit) bool {
397
+ if a.Pos != b.Pos {
398
+ return a.Pos < b.Pos
399
+ }
400
+ if a.End != b.End {
401
+ return a.End < b.End
402
+ }
403
+ return a.Text < b.Text
404
+ }
@@ -13,21 +13,22 @@ package linthost
13
13
  // Generated from packages/ttsc/src/flags/schema.ts. Edit the schema, not
14
14
  // this file.
15
15
  var LintFlagAllowList = map[string]bool{
16
- "checkers": true,
17
- "cwd": true,
18
- "diagnostics": false,
19
- "emit": false,
20
- "extendedDiagnostics": false,
21
- "file": true,
22
- "noEmit": false,
23
- "out": true,
24
- "outDir": true,
25
- "p": true,
26
- "plugins-json": true,
27
- "project": true,
28
- "quiet": false,
29
- "singleThreaded": false,
30
- "tsconfig": true,
31
- "tsgo-args": true,
32
- "verbose": false,
16
+ "checkers": true,
17
+ "cwd": true,
18
+ "diagnostics": false,
19
+ "emit": false,
20
+ "extendedDiagnostics": false,
21
+ "file": true,
22
+ "noEmit": false,
23
+ "out": true,
24
+ "outDir": true,
25
+ "p": true,
26
+ "plugins-json": true,
27
+ "project": true,
28
+ "project-context-json": true,
29
+ "quiet": false,
30
+ "singleThreaded": false,
31
+ "tsconfig": true,
32
+ "tsgo-args": true,
33
+ "verbose": false,
33
34
  }
@@ -48,6 +48,10 @@ func runFormat(opts *subcommandOpts) int {
48
48
  return 2
49
49
  }
50
50
  engine := NewEngineWithResolver(resolver)
51
+ if err := engine.ConfigError(); err != nil {
52
+ fmt.Fprintln(os.Stderr, err)
53
+ return 2
54
+ }
51
55
  engine.SetSerial(opts.singleThreaded)
52
56
  needsRuleChecker := engine.NeedsTypeChecker()
53
57
 
@@ -64,7 +68,7 @@ func runFormat(opts *subcommandOpts) int {
64
68
  totalFixes := 0
65
69
  cascadeConverged := false
66
70
  for pass := 0; pass < maxFormatPasses; pass++ {
67
- findings := engine.Run(prog.userSourceFiles(), prog.checker)
71
+ findings := prog.runLintCycle(engine)
68
72
  fixed, err := applyFindingFixes(opts.cwd, filterFormatFindings(findings))
69
73
  if err != nil {
70
74
  fmt.Fprintln(os.Stderr, err)
@@ -115,15 +119,6 @@ type formatCommandResolver struct {
115
119
  // around as a value) shares the same underlying cache rather than copying a
116
120
  // sync.Once lock.
117
121
  ruleNames *formatRuleNamesCache
118
- // entryDecisions memoizes the per-fileName ignored/matches decision that
119
- // ResolveRules derives from fileIsIgnoredByEntry and fileMatchesAnyEntry.
120
- // Both walk store.entries with glob matching and are recomputed identically
121
- // for the same fileName on every format pass, so the result is cached once
122
- // per file and reused. The field is a pointer (a *sync.Map) so the nil zero
123
- // value stays valid for construction sites that omit it, and copying the
124
- // resolver by value shares the same underlying map. sync.Map handles the
125
- // engine's concurrent per-file ResolveRules calls without an extra lock.
126
- entryDecisions *sync.Map
127
122
  // defaultOptions holds the always-on format rules' options (from
128
123
  // expandFormatBlock) to apply when the project config declares no `format`
129
124
  // block — no block in lint.config.*, or no config file at all. nil when a
@@ -142,9 +137,8 @@ type formatCommandResolver struct {
142
137
  // defaultOptions nil so the block stays authoritative.
143
138
  func newFormatCommandResolver(inner RuleResolver, startDir string, language string) (formatCommandResolver, error) {
144
139
  r := formatCommandResolver{
145
- inner: inner,
146
- ruleNames: &formatRuleNamesCache{},
147
- entryDecisions: &sync.Map{},
140
+ inner: inner,
141
+ ruleNames: &formatRuleNamesCache{},
148
142
  }
149
143
  if !hasInnerFormatRules(inner) {
150
144
  opts, err := defaultFormatOptions(editorFormatOverrides(startDir, language))
@@ -204,176 +198,52 @@ type formatRuleNamesCache struct {
204
198
  names []string
205
199
  }
206
200
 
207
- // formatEntryDecision is the cached per-fileName outcome of the two entry-scope
208
- // checks ResolveRules runs before applying its format-rule upgrade.
209
- type formatEntryDecision struct {
210
- ignoredByEntry bool
211
- matchesEntry bool
212
- }
213
-
214
201
  // ResolveRules implements RuleResolver. It delegates to the inner resolver
215
202
  // and then upgrades format-rule entries from off to warn so they are applied
216
203
  // even when the project config omits them.
217
204
  //
218
- // A user-authored entry whose `ignores` list matches `fileName` is honored
219
- // here even when the entry also carries a `rules` block: the engine already
220
- // skips that entry's rule contributions via `ConfigEntry.matchesFile`, so the
221
- // format command must skip its blanket format-rule upgrade as well. Without
222
- // this guard, `ttsc format` would rewrite files that the user explicitly
223
- // asked the lint config to ignore — the engine's lint walk would skip the
224
- // file but the formatter would still touch it.
225
- //
226
- // The symmetric guard applies to `files`: if every non-IgnoreOnly entry
227
- // carries a `files` filter and `fileName` matches none of them, the engine
228
- // would not run any rules on the file (`ConfigEntry.matchesFile` returns
229
- // false for every entry). The format command must skip its blanket
230
- // format-rule upgrade for the same reason — otherwise `ttsc format` would
231
- // rewrite files that fall outside every entry's scope, e.g. a `.json`
232
- // resolved into the program via `resolveJsonModule` when the only entry
233
- // targets `src/**/*.ts`.
205
+ // ConfigStore resolves entry applicability together with rules and options.
206
+ // A global ignore skips the file, while OutOfScope skips only files rejected
207
+ // by every rule-bearing entry. An ignore on one entry therefore cannot erase a
208
+ // separate matching entry's contribution. The flags also survive resolver
209
+ // wrappers, unlike inspecting a concrete ConfigStore from this outer layer.
234
210
  func (r formatCommandResolver) ResolveRules(fileName string) ResolvedRuleConfig {
235
211
  resolved := r.inner.ResolveRules(fileName)
236
- if resolved.Ignored {
237
- return resolved
238
- }
239
- decision := r.entryDecision(fileName)
240
- if decision.ignoredByEntry {
241
- return resolved
242
- }
243
- if !decision.matchesEntry {
212
+ if resolved.Ignored || resolved.OutOfScope {
244
213
  return resolved
245
214
  }
246
215
  if resolved.Rules == nil {
247
216
  resolved.Rules = RuleConfig{}
248
217
  }
218
+ if resolved.Options == nil {
219
+ resolved.Options = RuleOptionsMap{}
220
+ }
249
221
  for _, name := range r.formatOptionRuleNames() {
222
+ _, declaredForFile := resolved.Rules[normalizeBuiltinRuleName(name)]
223
+ if r.defaultOptions == nil && !declaredForFile {
224
+ // A configured format block is still a normal config entry: its files
225
+ // and ignores selectors scope the complete rule setting. Only the
226
+ // synthetic default set is allowed to introduce an undeclared rule.
227
+ continue
228
+ }
250
229
  if resolved.Rules.Severity(name) == SeverityOff {
251
230
  resolved.Rules[name] = SeverityWarn
252
231
  }
253
- }
254
- return resolved
255
- }
256
-
257
- // entryDecision returns the per-fileName ignored/matches outcome that
258
- // ResolveRules needs, computing it from fileIsIgnoredByEntry and
259
- // fileMatchesAnyEntry on first use and reusing it on later passes. When the
260
- // memo is absent (a resolver built without entryDecisions, e.g. in tests or
261
- // lsp) it computes the decision directly so behavior is identical, just
262
- // uncached.
263
- func (r formatCommandResolver) entryDecision(fileName string) formatEntryDecision {
264
- if r.entryDecisions == nil {
265
- return r.computeEntryDecision(fileName)
266
- }
267
- if cached, ok := r.entryDecisions.Load(fileName); ok {
268
- return cached.(formatEntryDecision)
269
- }
270
- decision := r.computeEntryDecision(fileName)
271
- // LoadOrStore keeps the first writer's value so concurrent passes over the
272
- // same file agree; the decision is a pure function of fileName, so either
273
- // value is correct.
274
- actual, _ := r.entryDecisions.LoadOrStore(fileName, decision)
275
- return actual.(formatEntryDecision)
276
- }
277
-
278
- // computeEntryDecision runs the two uncached entry-scope checks behind
279
- // entryDecision.
280
- func (r formatCommandResolver) computeEntryDecision(fileName string) formatEntryDecision {
281
- return formatEntryDecision{
282
- ignoredByEntry: r.fileIsIgnoredByEntry(fileName),
283
- matchesEntry: r.fileMatchesAnyEntry(fileName),
284
- }
285
- }
286
-
287
- // fileIsIgnoredByEntry reports whether any non-IgnoreOnly entry in the inner
288
- // ConfigStore has an `ignores` glob that matches `fileName`. IgnoreOnly
289
- // entries are already handled by ResolvedRuleConfig.Ignored — they are
290
- // checked first by ConfigStore.ResolveRules and short-circuit the walk. This
291
- // helper covers the complementary case: an entry that carries both `rules`
292
- // and `ignores`, where the engine skips the entry's rule contributions via
293
- // `ConfigEntry.matchesFile` but the format command must learn the same fact
294
- // independently because its job is to upgrade format rules to `warn`, not to
295
- // read the engine's resolved severity map.
296
- func (r formatCommandResolver) fileIsIgnoredByEntry(fileName string) bool {
297
- matched, _ := r.anyNonIgnoreEntry(func(entry *ConfigEntry) bool {
298
- return entry.matchesIgnores(fileName)
299
- })
300
- return matched
301
- }
302
-
303
- // anyNonIgnoreEntry resolves the inner resolver to its concrete *ConfigStore
304
- // once and reports whether any non-IgnoreOnly entry satisfies `pred`. The
305
- // second return value is false when the inner resolver is not a *ConfigStore
306
- // (or is a nil one), letting each caller pick its own non-store default. This
307
- // collapses the shared store-resolution and entry-walk that fileIsIgnoredByEntry
308
- // and fileMatchesAnyEntry would otherwise duplicate; the per-caller default and
309
- // the empty-entries base case stay in the callers where they differ.
310
- func (r formatCommandResolver) anyNonIgnoreEntry(pred func(*ConfigEntry) bool) (matched bool, hasStore bool) {
311
- store, ok := r.inner.(*ConfigStore)
312
- if !ok || store == nil {
313
- return false, false
314
- }
315
- for i := range store.entries {
316
- entry := &store.entries[i]
317
- if entry.IgnoreOnly {
318
- continue
232
+ if len(resolved.RuleOptions(name)) == 0 {
233
+ var raw json.RawMessage
234
+ if resolved.OptionsResolved {
235
+ raw = r.defaultOptions[name]
236
+ } else {
237
+ // Legacy custom resolvers have no authoritative per-file options, so
238
+ // retain their file-agnostic override before consulting defaults.
239
+ raw = r.RuleOptions(name)
240
+ }
241
+ if len(raw) > 0 {
242
+ resolved.Options[name] = append(json.RawMessage(nil), raw...)
243
+ }
319
244
  }
320
- if pred(entry) {
321
- return true, true
322
- }
323
- }
324
- return false, true
325
- }
326
-
327
- // fileMatchesAnyEntry reports whether `fileName` falls inside the `files`
328
- // scope of at least one non-IgnoreOnly entry. An entry without an explicit
329
- // `files` list matches every file by definition (eslint flat-config
330
- // semantics), so a config with any unrestricted non-IgnoreOnly entry returns
331
- // true for every file. The format command treats a `false` result the same
332
- // way the engine does: no entry contributes rules, so the blanket
333
- // format-rule upgrade must be skipped.
334
- //
335
- // Base cases:
336
- //
337
- // - Empty entries slice: returns `true`. A store with no entries cannot
338
- // skip a file by scope (the engine has nothing to walk), so format
339
- // mode must be allowed to apply its default upgrade. Matching engine
340
- // behavior at `ConfigStore.ResolveRules` for the same input
341
- // (`Ignored = false`, empty rule map).
342
- // - All entries are IgnoreOnly: returns `false`. No non-IgnoreOnly
343
- // entry contributes a `files` scope, so the file is out-of-scope by
344
- // construction.
345
- // - Inner resolver is not a *ConfigStore: returns `true` (conservative
346
- // default). The `files` concept is store-specific; an in-process
347
- // custom resolver that does not surface a scope cannot have its
348
- // rule-eligibility reasoned about from the outside, so the format
349
- // upgrade applies the same as it does for an entry without `files`.
350
- func (r formatCommandResolver) fileMatchesAnyEntry(fileName string) bool {
351
- matched, hasStore := r.anyNonIgnoreEntry(func(entry *ConfigEntry) bool {
352
- return entry.matchesFile(fileName)
353
- })
354
- if !hasStore {
355
- // Not a *ConfigStore: conservative default, see the doc comment above.
356
- return true
357
- }
358
- // A store with no entries (or only IgnoreOnly ones that the walk skipped)
359
- // produces matched == false; an empty entries slice must still return true
360
- // so format mode applies its default upgrade, matching ConfigStore behavior.
361
- // The all-IgnoreOnly case is distinguished by a non-empty entries slice.
362
- if !matched && len(r.storeEntries()) == 0 {
363
- return true
364
- }
365
- return matched
366
- }
367
-
368
- // storeEntries returns the inner resolver's config entries, or nil when the
369
- // inner resolver is not a *ConfigStore. Used by fileMatchesAnyEntry to tell the
370
- // empty-entries base case (return true) apart from the all-IgnoreOnly case
371
- // (return false) after anyNonIgnoreEntry reports no match.
372
- func (r formatCommandResolver) storeEntries() []ConfigEntry {
373
- if store, ok := r.inner.(*ConfigStore); ok && store != nil {
374
- return store.entries
375
245
  }
376
- return nil
246
+ return resolved
377
247
  }
378
248
 
379
249
  // ActiveRuleNames implements RuleResolver. Returns the union of the inner
@@ -420,6 +290,40 @@ func (r formatCommandResolver) RuleOptions(name string) json.RawMessage {
420
290
  return nil
421
291
  }
422
292
 
293
+ func (r formatCommandResolver) RuleOptionsVariants(name string) []json.RawMessage {
294
+ variants := resolvedRuleOptionsVariants(r.inner, name)
295
+ defaultOptions := r.defaultOptions[name]
296
+ if len(defaultOptions) == 0 {
297
+ return variants
298
+ }
299
+ effective := make([]json.RawMessage, 0, len(variants)+1)
300
+ defaultIncluded := false
301
+ for _, raw := range variants {
302
+ if len(raw) == 0 {
303
+ raw = defaultOptions
304
+ defaultIncluded = true
305
+ } else if string(raw) == string(defaultOptions) {
306
+ defaultIncluded = true
307
+ }
308
+ effective = append(effective, append(json.RawMessage(nil), raw...))
309
+ }
310
+ if !defaultIncluded {
311
+ // A scoped custom resolver may enumerate only its explicit tuples even
312
+ // though the default remains reachable for every other file.
313
+ effective = append(effective, append(json.RawMessage(nil), defaultOptions...))
314
+ }
315
+ return effective
316
+ }
317
+
318
+ // ResolveProjectRules forwards project declarations unchanged. Format defaults
319
+ // are file rules and cannot create or scope project-rule state.
320
+ func (r formatCommandResolver) ResolveProjectRules(names []string) (map[string]ProjectRuleSetting, error) {
321
+ if r.inner == nil {
322
+ return RuleConfig{}.ResolveProjectRules(names)
323
+ }
324
+ return r.inner.ResolveProjectRules(names)
325
+ }
326
+
423
327
  // formatOptionRuleNames returns the sorted list of rule names from the inner
424
328
  // resolver's options that are registered as format rules. These are the rules
425
329
  // that formatCommandResolver promotes from off to warn.
@@ -464,10 +368,12 @@ func (r formatCommandResolver) computeFormatOptionRuleNames() []string {
464
368
  // options (e.g. bare RuleConfig).
465
369
  func resolverOptions(resolver RuleResolver) RuleOptionsMap {
466
370
  switch r := resolver.(type) {
371
+ case boundProjectRuleResolver:
372
+ return resolverOptions(r.RuleResolver)
467
373
  case InlineRuleResolver:
468
374
  return r.Options
469
375
  case *ConfigStore:
470
- return r.options
376
+ return r.flattenOptions()
471
377
  default:
472
378
  return nil
473
379
  }