@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
@@ -19,6 +19,7 @@ package linthost
19
19
 
20
20
  import (
21
21
  "encoding/json"
22
+ "errors"
22
23
  "fmt"
23
24
  "os"
24
25
  "runtime"
@@ -27,7 +28,9 @@ import (
27
28
 
28
29
  shimast "github.com/microsoft/typescript-go/shim/ast"
29
30
  shimchecker "github.com/microsoft/typescript-go/shim/checker"
31
+ shimdw "github.com/microsoft/typescript-go/shim/diagnosticwriter"
30
32
  shimscanner "github.com/microsoft/typescript-go/shim/scanner"
33
+ publicrule "github.com/samchon/ttsc/packages/lint/rule"
31
34
  )
32
35
 
33
36
  // Rule is the contract every lint rule satisfies.
@@ -70,6 +73,29 @@ type typeAwareRule interface {
70
73
  NeedsTypeChecker() bool
71
74
  }
72
75
 
76
+ // ruleOptionsValidator is an optional rule capability for rejecting malformed
77
+ // configuration before the rule enters the dispatch table. The exported method
78
+ // lets rule implementations opt in without coupling the engine to specific rule
79
+ // names; a nil payload represents the rule's default options.
80
+ type ruleOptionsValidator interface {
81
+ ValidateOptions(json.RawMessage) error
82
+ }
83
+
84
+ // ruleOptionsAcceptor is the structural declaration that a rule owns an
85
+ // options schema. The engine uses the capability instead of a rule-name list,
86
+ // so adding an options payload to a rule requires the implementation itself to
87
+ // opt in. A false result is equivalent to omitting the interface.
88
+ type ruleOptionsAcceptor interface {
89
+ AcceptsTtscLintOptions() bool
90
+ }
91
+
92
+ // optionsRule is embedded by built-in rules whose public configuration accepts
93
+ // an options slot. Keeping the marker next to each implementation makes the
94
+ // registry the runtime source of truth without a parallel name table.
95
+ type optionsRule struct{}
96
+
97
+ func (optionsRule) AcceptsTtscLintOptions() bool { return true }
98
+
73
99
  // isFormatRule reports whether `r` opts into the format category.
74
100
  func isFormatRule(r Rule) bool {
75
101
  fr, ok := r.(FormatRule)
@@ -81,22 +107,83 @@ func ruleNeedsTypeChecker(r Rule) bool {
81
107
  return ok && tr.NeedsTypeChecker()
82
108
  }
83
109
 
110
+ func ruleAcceptsOptions(r Rule) bool {
111
+ acceptor, ok := r.(ruleOptionsAcceptor)
112
+ return ok && acceptor.AcceptsTtscLintOptions()
113
+ }
114
+
115
+ func validateRuleOptions(r Rule, options json.RawMessage) error {
116
+ if len(options) > 0 && !ruleAcceptsOptions(r) {
117
+ return errors.New("rule does not accept options")
118
+ }
119
+ validator, ok := r.(ruleOptionsValidator)
120
+ if !ok {
121
+ return nil
122
+ }
123
+ return validator.ValidateOptions(options)
124
+ }
125
+
84
126
  // Context is the per-(file, rule) handle the engine passes to `Check`.
85
127
  //
86
- // `Options` is the raw JSON blob the user wrote in their rule
87
- // configuration's second tuple slot (`["warning", { ... }]`). It is nil
88
- // when the rule was configured with a bare severity literal. Rules that
89
- // accept options decode the blob into their own struct via
90
- // `(*Context).DecodeOptions` and fall back to defaults on nil.
128
+ // `Options` is the raw JSON payload resolved for this source file from the
129
+ // same config entries as Severity. One option slot preserves its scalar or
130
+ // object shape; multiple positional options are an array. It is nil for a
131
+ // bare severity. Rules decode the payload according to their public option
132
+ // type and fall back to defaults on nil.
91
133
  type Context struct {
92
- File *shimast.SourceFile
93
- Checker *shimchecker.Checker
94
- Severity Severity
95
- Options json.RawMessage
96
-
97
- rule Rule
98
- isFormat bool
99
- collect func(*Finding)
134
+ File *shimast.SourceFile
135
+ Checker *shimchecker.Checker
136
+ CurrentDirectory string
137
+ Severity Severity
138
+ Options json.RawMessage
139
+
140
+ rule Rule
141
+ isFormat bool
142
+ quarantined bool
143
+ collect func(*Finding)
144
+ projectResults publicrule.ProjectResultReader
145
+ fileMemo *fileMemo
146
+ }
147
+
148
+ // fileMemo caches file-invariant values that rules would otherwise
149
+ // recompute once per visited node. The engine binds one instance per
150
+ // source file and shares it across every Context it builds for that
151
+ // file's rules, so a whole-file table — the security binding table, the
152
+ // set of top-level declared JSX names — is computed once per file
153
+ // instead of once per matching node, collapsing an O(nodes × matches)
154
+ // rule to O(nodes). Each file's walk is serial and gets its own
155
+ // instance, so the map needs no locking.
156
+ //
157
+ // Keys are sentinel zero-size struct values whose distinct types make
158
+ // collisions impossible without a central registry; the engine never
159
+ // inspects them, keeping the hook general.
160
+ type fileMemo struct {
161
+ values map[any]any
162
+ }
163
+
164
+ // fileValue returns the cached value stored under key, reporting whether
165
+ // one was present. A nil memo (a Context built outside the engine, e.g.
166
+ // in a focused unit test) always misses, so callers transparently fall
167
+ // back to recomputing.
168
+ func (c *Context) fileValue(key any) (any, bool) {
169
+ if c == nil || c.fileMemo == nil || c.fileMemo.values == nil {
170
+ return nil, false
171
+ }
172
+ value, ok := c.fileMemo.values[key]
173
+ return value, ok
174
+ }
175
+
176
+ // setFileValue records value under key for the rest of this file's walk.
177
+ // A nil memo drops the write, leaving the caller to recompute on the next
178
+ // request — behavior-preserving, just uncached.
179
+ func (c *Context) setFileValue(key, value any) {
180
+ if c == nil || c.fileMemo == nil {
181
+ return
182
+ }
183
+ if c.fileMemo.values == nil {
184
+ c.fileMemo.values = map[any]any{}
185
+ }
186
+ c.fileMemo.values[key] = value
100
187
  }
101
188
 
102
189
  // DecodeOptions unmarshals the rule's options blob into `out`. Returns
@@ -120,17 +207,20 @@ func (c *Context) DecodeOptions(out interface{}) error {
120
207
  // both categories — no filter — because `ttsc fix` is the
121
208
  // run-everything entry point.
122
209
  type Finding struct {
123
- Rule string
124
- Severity Severity
125
- File *shimast.SourceFile
126
- Pos int
127
- End int
128
- Message string
129
- Fix []TextEdit
130
- IsFormat bool
210
+ Rule string
211
+ Severity Severity
212
+ File *shimast.SourceFile
213
+ Pos int
214
+ End int
215
+ Message string
216
+ Fix []TextEdit
217
+ Suggestions []Suggestion
218
+ IsFormat bool
219
+
220
+ engineFailure bool
131
221
  }
132
222
 
133
- // TextEdit is one byte-range replacement offered by an autofixable finding.
223
+ // TextEdit is one byte-range source replacement used by a fix or suggestion.
134
224
  // Positions use the same byte offsets as shim AST nodes and must point inside
135
225
  // the finding's source file.
136
226
  type TextEdit struct {
@@ -139,6 +229,14 @@ type TextEdit struct {
139
229
  Text string
140
230
  }
141
231
 
232
+ // Suggestion is an opt-in editor action attached to a finding. Unlike Fix,
233
+ // suggestion edits are never consumed by `ttsc fix` or source.fixAll.ttsc;
234
+ // the LSP host exposes them as individual quick fixes selected by the user.
235
+ type Suggestion struct {
236
+ Title string
237
+ Edits []TextEdit
238
+ }
239
+
142
240
  // Report records a finding at the given node's source range. The pos is
143
241
  // trimmed past leading trivia (whitespace + comments) so the renderer's
144
242
  // `path:line:col` banner points at the offending token, not the start of
@@ -154,22 +252,80 @@ func (c *Context) ReportFix(node *shimast.Node, message string, edits ...TextEdi
154
252
  if c.Severity == SeverityOff || node == nil {
155
253
  return
156
254
  }
157
- pos := node.Pos()
158
- if c.File != nil {
159
- pos = shimscanner.SkipTrivia(c.File.Text(), pos)
160
- }
255
+ pos, end := c.nodeFindingRange(node)
161
256
  c.collect(&Finding{
162
257
  Rule: c.rule.Name(),
163
258
  Severity: c.Severity,
164
259
  File: c.File,
165
260
  Pos: pos,
166
- End: node.End(),
261
+ End: end,
167
262
  Message: message,
168
263
  Fix: cloneTextEdits(edits),
169
264
  IsFormat: c.isFormat,
170
265
  })
171
266
  }
172
267
 
268
+ // ReportSuggestion records a node-scoped finding with one opt-in editor
269
+ // action. The diagnostic is still reported when edits is empty, but no quick
270
+ // fix is advertised.
271
+ func (c *Context) ReportSuggestion(node *shimast.Node, message string, title string, edits ...TextEdit) {
272
+ if c.Severity == SeverityOff || node == nil {
273
+ return
274
+ }
275
+ pos, end := c.nodeFindingRange(node)
276
+ c.collect(&Finding{
277
+ Rule: c.rule.Name(),
278
+ Severity: c.Severity,
279
+ File: c.File,
280
+ Pos: pos,
281
+ End: end,
282
+ Message: message,
283
+ Suggestions: newSuggestions(title, edits),
284
+ IsFormat: c.isFormat,
285
+ })
286
+ }
287
+
288
+ // ReportFixSuggestions records one node-scoped diagnostic with an optional
289
+ // automatic fix and any number of opt-in editor suggestions. Each slice is
290
+ // cloned before collection so a rule cannot mutate a previously reported
291
+ // finding through retained backing storage.
292
+ func (c *Context) ReportFixSuggestions(
293
+ node *shimast.Node,
294
+ message string,
295
+ fix []TextEdit,
296
+ suggestions ...Suggestion,
297
+ ) {
298
+ if c.Severity == SeverityOff || node == nil {
299
+ return
300
+ }
301
+ pos, end := c.nodeFindingRange(node)
302
+ c.collect(&Finding{
303
+ Rule: c.rule.Name(),
304
+ Severity: c.Severity,
305
+ File: c.File,
306
+ Pos: pos,
307
+ End: end,
308
+ Message: message,
309
+ Fix: cloneTextEdits(fix),
310
+ Suggestions: cloneSuggestions(suggestions),
311
+ IsFormat: c.isFormat,
312
+ })
313
+ }
314
+
315
+ // nodeFindingRange bounds an arbitrary rule-supplied node before reading the
316
+ // current file's source text. Contributors can accidentally report a node from
317
+ // another file, whose otherwise valid Pos may exceed this Context's source.
318
+ func (c *Context) nodeFindingRange(node *shimast.Node) (int, int) {
319
+ if node == nil {
320
+ return shimdw.NormalizeLintRange(c.File, 0, 0)
321
+ }
322
+ pos, end := shimdw.NormalizeLintRange(c.File, node.Pos(), node.End())
323
+ if c.File != nil {
324
+ pos = shimscanner.SkipTrivia(c.File.Text(), pos)
325
+ }
326
+ return shimdw.NormalizeLintRange(c.File, pos, end)
327
+ }
328
+
173
329
  // ReportRange records a finding at an explicit byte range inside the
174
330
  // current file. Use this when the rule wants to highlight a sub-token of
175
331
  // a node (e.g. an operator inside a BinaryExpression).
@@ -182,9 +338,7 @@ func (c *Context) ReportRangeFix(pos, end int, message string, edits ...TextEdit
182
338
  if c.Severity == SeverityOff || c.File == nil {
183
339
  return
184
340
  }
185
- if end <= pos {
186
- end = pos + 1
187
- }
341
+ pos, end = shimdw.NormalizeLintRange(c.File, pos, end)
188
342
  c.collect(&Finding{
189
343
  Rule: c.rule.Name(),
190
344
  Severity: c.Severity,
@@ -197,6 +351,26 @@ func (c *Context) ReportRangeFix(pos, end int, message string, edits ...TextEdit
197
351
  })
198
352
  }
199
353
 
354
+ // ReportRangeSuggestion records an explicit-range finding with one opt-in
355
+ // editor action. Suggestion edits stay separate from automatic fixes and are
356
+ // ignored by `ttsc fix` and source.fixAll.ttsc.
357
+ func (c *Context) ReportRangeSuggestion(pos, end int, message string, title string, edits ...TextEdit) {
358
+ if c.Severity == SeverityOff || c.File == nil {
359
+ return
360
+ }
361
+ pos, end = shimdw.NormalizeLintRange(c.File, pos, end)
362
+ c.collect(&Finding{
363
+ Rule: c.rule.Name(),
364
+ Severity: c.Severity,
365
+ File: c.File,
366
+ Pos: pos,
367
+ End: end,
368
+ Message: message,
369
+ Suggestions: newSuggestions(title, edits),
370
+ IsFormat: c.isFormat,
371
+ })
372
+ }
373
+
200
374
  // cloneTextEdits returns a shallow copy of `edits` so that the caller's
201
375
  // variadic slice cannot be mutated through the stored Finding. Returns nil
202
376
  // when the input is empty, keeping the Finding.Fix field nil rather than
@@ -210,6 +384,32 @@ func cloneTextEdits(edits []TextEdit) []TextEdit {
210
384
  return out
211
385
  }
212
386
 
387
+ func newSuggestions(title string, edits []TextEdit) []Suggestion {
388
+ cloned := cloneTextEdits(edits)
389
+ if title == "" || len(cloned) == 0 {
390
+ return nil
391
+ }
392
+ return []Suggestion{{Title: title, Edits: cloned}}
393
+ }
394
+
395
+ func cloneSuggestions(suggestions []Suggestion) []Suggestion {
396
+ if len(suggestions) == 0 {
397
+ return nil
398
+ }
399
+ cloned := make([]Suggestion, 0, len(suggestions))
400
+ for _, suggestion := range suggestions {
401
+ edits := cloneTextEdits(suggestion.Edits)
402
+ if suggestion.Title == "" || len(edits) == 0 {
403
+ continue
404
+ }
405
+ cloned = append(cloned, Suggestion{Title: suggestion.Title, Edits: edits})
406
+ }
407
+ if len(cloned) == 0 {
408
+ return nil
409
+ }
410
+ return cloned
411
+ }
412
+
213
413
  // registry stores the package-global rule list keyed by name. Tests can
214
414
  // also reach into it via `LookupRule`.
215
415
  type registry struct {
@@ -227,7 +427,11 @@ func Register(rule Rule) {
227
427
  if _, exists := registered.rules[rule.Name()]; exists {
228
428
  panic("@ttsc/lint: rule " + rule.Name() + " registered twice")
229
429
  }
230
- registered.rules[rule.Name()] = rule
430
+ name := rule.Name()
431
+ registered.rules[name] = rule
432
+ if _, builtIn := builtInRuleCodes[name]; !builtIn {
433
+ invalidateRuntimeRuleCodes()
434
+ }
231
435
  }
232
436
 
233
437
  // LookupRule returns the registered rule by name, or nil if missing.
@@ -262,14 +466,17 @@ type Engine struct {
262
466
  unknownDirectiveMu sync.Mutex
263
467
  needsTypeChecker bool
264
468
  serial bool
469
+ projectSettings map[string]ProjectRuleSetting
470
+ configError error
471
+ currentDirectory string
265
472
  }
266
473
 
267
474
  // SetSerial forces Engine.Run to walk files one at a time. The host calls
268
475
  // this when `--singleThreaded` reaches the lint sidecar so the benchmark
269
476
  // (and any caller that wants a deterministic, low-overhead pass) can opt
270
477
  // out of file-level parallelism. Type-aware rule sets always run serial
271
- // regardless of this flag the single shared checker is not concurrent
272
- // so callers do not need to clear it themselves.
478
+ // regardless of this flag because their standalone checker is not concurrent,
479
+ // so callers do not need to force serial execution themselves.
273
480
  func (e *Engine) SetSerial(serial bool) {
274
481
  if e == nil {
275
482
  return
@@ -277,9 +484,17 @@ func (e *Engine) SetSerial(serial bool) {
277
484
  e.serial = serial
278
485
  }
279
486
 
280
- // runsSerial reports whether Run must walk files one at a time — either
281
- // because the caller asked for it or because a type-aware rule pins the
282
- // engine to the shared single checker.
487
+ // SetCurrentDirectory supplies the compiler Program's current directory for
488
+ // rule options whose relative paths are project-rooted.
489
+ func (e *Engine) SetCurrentDirectory(currentDirectory string) {
490
+ if e != nil {
491
+ e.currentDirectory = currentDirectory
492
+ }
493
+ }
494
+
495
+ // runsSerial reports whether Run must walk files one at a time: either because
496
+ // the caller asked for it or because a type-aware rule uses the standalone
497
+ // checker shared by every linted file.
283
498
  func (e *Engine) runsSerial() bool {
284
499
  return e == nil || e.serial || e.needsTypeChecker
285
500
  }
@@ -304,13 +519,52 @@ func NewEngineWithResolver(config RuleResolver) *Engine {
304
519
  enabled: make(map[string]Severity),
305
520
  unknownDirectives: make(map[string]struct{}),
306
521
  }
522
+ projectRuleNames := allProjectRuleNames()
523
+ eng.projectSettings, eng.configError = config.ResolveProjectRules(projectRuleNames)
524
+ for _, name := range projectRuleNames {
525
+ setting := eng.projectSettings[name]
526
+ if setting.Declared && len(setting.Options) > 0 && !registeredProjectRules[name].acceptsOptions {
527
+ eng.configError = errors.Join(
528
+ eng.configError,
529
+ fmt.Errorf("@ttsc/lint: invalid options for rule %q: rule does not accept options", name),
530
+ )
531
+ }
532
+ if setting.Declared && setting.Severity != SeverityOff {
533
+ eng.needsTypeChecker = true
534
+ }
535
+ }
307
536
  displaySeverities := config.EnabledRuleConfig()
537
+ invalidRuleOptions := make(map[string]struct{})
538
+ for _, name := range AllRuleNames() {
539
+ rule := registered.rules[name]
540
+ seenOptions := make(map[string]struct{})
541
+ for _, options := range resolvedRuleOptionsVariants(config, name) {
542
+ key := string(options)
543
+ if _, duplicate := seenOptions[key]; duplicate {
544
+ continue
545
+ }
546
+ seenOptions[key] = struct{}{}
547
+ if err := validateRuleOptions(rule, options); err != nil {
548
+ eng.configError = errors.Join(
549
+ eng.configError,
550
+ fmt.Errorf("@ttsc/lint: invalid options for rule %q: %w", name, err),
551
+ )
552
+ invalidRuleOptions[name] = struct{}{}
553
+ }
554
+ }
555
+ }
308
556
  for _, name := range config.ActiveRuleNames() {
557
+ if _, isProjectRule := registeredProjectRules[name]; isProjectRule {
558
+ continue
559
+ }
309
560
  rule, ok := registered.rules[name]
310
561
  if !ok {
311
562
  eng.unknown = append(eng.unknown, name)
312
563
  continue
313
564
  }
565
+ if _, invalid := invalidRuleOptions[name]; invalid {
566
+ continue
567
+ }
314
568
  if ruleNeedsTypeChecker(rule) {
315
569
  eng.needsTypeChecker = true
316
570
  }
@@ -414,6 +668,15 @@ func (e *Engine) NeedsTypeChecker() bool {
414
668
  return e != nil && e.needsTypeChecker
415
669
  }
416
670
 
671
+ // ConfigError reports an invalid project-rule declaration or rule option
672
+ // payload discovered while binding the resolver.
673
+ func (e *Engine) ConfigError() error {
674
+ if e == nil {
675
+ return nil
676
+ }
677
+ return e.configError
678
+ }
679
+
417
680
  // EnabledRules returns the active rule set keyed by name. Mostly for
418
681
  // tests + introspection.
419
682
  func (e *Engine) EnabledRules() map[string]Severity { return e.enabled }
@@ -425,13 +688,28 @@ func (e *Engine) EnabledRules() map[string]Severity { return e.enabled }
425
688
  // merged in source-file order so the diagnostic stream is deterministic across
426
689
  // runs even when the per-file work happens out of order.
427
690
  func (e *Engine) Run(files []*shimast.SourceFile, checker *shimchecker.Checker) []*Finding {
691
+ cycle := e.evaluateProject(publicrule.ProjectIdentity{}, files, checker)
692
+ currentDirectory := e.currentDirectory
693
+ if currentDirectory == "" {
694
+ currentDirectory, _ = os.Getwd()
695
+ }
696
+ fileFindings := e.runFiles(files, checker, cycle.results, currentDirectory)
697
+ return append(cycle.finalize(), fileFindings...)
698
+ }
699
+
700
+ func (e *Engine) runFiles(
701
+ files []*shimast.SourceFile,
702
+ checker *shimchecker.Checker,
703
+ results publicrule.ProjectResultReader,
704
+ currentDirectory string,
705
+ ) []*Finding {
428
706
  if e.runsSerial() {
429
707
  var findings []*Finding
430
708
  for _, file := range files {
431
709
  if file == nil {
432
710
  continue
433
711
  }
434
- findings = append(findings, e.runFile(file, checker)...)
712
+ findings = append(findings, e.runFile(file, checker, results, currentDirectory)...)
435
713
  }
436
714
  return findings
437
715
  }
@@ -452,7 +730,7 @@ func (e *Engine) Run(files []*shimast.SourceFile, checker *shimchecker.Checker)
452
730
  go func(idx int, f *shimast.SourceFile) {
453
731
  defer wg.Done()
454
732
  defer func() { <-sem }()
455
- perFile[idx] = e.runFile(f, checker)
733
+ perFile[idx] = e.runFile(f, checker, results, currentDirectory)
456
734
  }(i, file)
457
735
  }
458
736
  wg.Wait()
@@ -478,6 +756,19 @@ type boundRule struct {
478
756
  ctx *Context
479
757
  }
480
758
 
759
+ // check invokes one bound rule unless an earlier invocation panicked in this
760
+ // file. Context is shared by every kind bucket for the same file/rule pair, so
761
+ // the quarantine covers later nodes and later registered kinds without leaking
762
+ // into the next source file.
763
+ func (b boundRule) check(node *shimast.Node, collect func(*Finding)) {
764
+ if b.ctx == nil || b.ctx.quarantined {
765
+ return
766
+ }
767
+ if runRuleCheck(b.rule, b.ctx, node, collect) {
768
+ b.ctx.quarantined = true
769
+ }
770
+ }
771
+
481
772
  // lintFileWalker drives the per-file AST traversal. The struct exists so
482
773
  // the `ForEachChild` callback can be a method value cached in
483
774
  // `childCB`. A naive nested-closure walker re-allocates one callback
@@ -498,7 +789,7 @@ func (w *lintFileWalker) walk(node *shimast.Node) {
498
789
  }
499
790
  if k := int(node.Kind); k >= 0 && k < len(w.byKind) {
500
791
  for _, bound := range w.byKind[k] {
501
- runRuleCheck(bound.rule, bound.ctx, node, w.collect)
792
+ bound.check(node, w.collect)
502
793
  }
503
794
  }
504
795
  node.ForEachChild(w.childCB)
@@ -515,7 +806,12 @@ func (w *lintFileWalker) visitChild(child *shimast.Node) bool {
515
806
  // runFile is the per-file driver. The visitor is allocated once per file
516
807
  // to keep the per-node hot path branch-free; it visits children
517
808
  // post-order so parents see their already-checked subtrees.
518
- func (e *Engine) runFile(file *shimast.SourceFile, checker *shimchecker.Checker) []*Finding {
809
+ func (e *Engine) runFile(
810
+ file *shimast.SourceFile,
811
+ checker *shimchecker.Checker,
812
+ results publicrule.ProjectResultReader,
813
+ currentDirectory string,
814
+ ) []*Finding {
519
815
  var collected []*Finding
520
816
  collect := func(f *Finding) { collected = append(collected, f) }
521
817
  resolved := e.config.ResolveRules(file.FileName())
@@ -542,6 +838,10 @@ func (e *Engine) runFile(file *shimast.SourceFile, checker *shimchecker.Checker)
542
838
  bound := 0
543
839
  byKind := make([][]boundRule, len(e.rules))
544
840
  ctxByRule := make(map[string]*Context, len(e.enabled))
841
+ // One memo per file, shared by every rule's Context below, so
842
+ // file-invariant tables (security bindings, declared JSX names) are
843
+ // built once per file instead of once per visited node.
844
+ memo := &fileMemo{}
545
845
  for kind, rules := range e.rules {
546
846
  if len(rules) == 0 {
547
847
  continue
@@ -554,14 +854,24 @@ func (e *Engine) runFile(file *shimast.SourceFile, checker *shimchecker.Checker)
554
854
  ctx, built := ctxByRule[name]
555
855
  if !built {
556
856
  if severity := fileRules.Severity(name); severity != SeverityOff {
857
+ options := resolved.RuleOptions(name)
858
+ if len(options) == 0 && !resolved.OptionsResolved {
859
+ // Compatibility for custom RuleResolver implementations compiled
860
+ // against the original contract: until they opt into per-file
861
+ // options, their file-agnostic RuleOptions method remains active.
862
+ options = e.config.RuleOptions(name)
863
+ }
557
864
  ctx = &Context{
558
- File: file,
559
- Checker: checker,
560
- Severity: severity,
561
- Options: e.config.RuleOptions(name),
562
- rule: rule,
563
- isFormat: isFormatRule(rule),
564
- collect: collect,
865
+ File: file,
866
+ Checker: checker,
867
+ CurrentDirectory: currentDirectory,
868
+ Severity: severity,
869
+ Options: options,
870
+ rule: rule,
871
+ isFormat: isFormatRule(rule),
872
+ collect: collect,
873
+ projectResults: results,
874
+ fileMemo: memo,
565
875
  }
566
876
  }
567
877
  // A nil entry memoizes "off for this file" so a rule registered
@@ -593,11 +903,11 @@ func (e *Engine) runFile(file *shimast.SourceFile, checker *shimchecker.Checker)
593
903
 
594
904
  // SourceFile dispatches into its statement list directly; we walk
595
905
  // statements explicitly so the file node itself can be inspected by
596
- // rules (e.g., `ban-ts-comment` reads CommentDirectives off the
597
- // SourceFile).
906
+ // rules (e.g., `ban-ts-comment` scans the file's comment tokens once
907
+ // per SourceFile).
598
908
  if k := int(shimast.KindSourceFile); k >= 0 && k < len(byKind) {
599
909
  for _, bound := range byKind[k] {
600
- runRuleCheck(bound.rule, bound.ctx, file.AsNode(), collect)
910
+ bound.check(file.AsNode(), collect)
601
911
  }
602
912
  }
603
913
 
@@ -634,13 +944,15 @@ func hasEnabledFileRules(rules RuleConfig) bool {
634
944
  // anyone; protecting the engine is the only way to bound the blast
635
945
  // radius of one bad rule. The recovered panic is surfaced as a
636
946
  // SeverityError finding tagged with the rule's name so the user sees
637
- // the failure in the normal diagnostic stream.
638
- func runRuleCheck(rule Rule, ctx *Context, node *shimast.Node, collect func(*Finding)) {
947
+ // the failure in the normal diagnostic stream. The boolean result tells the
948
+ // per-file binding to quarantine the rule after recovery.
949
+ func runRuleCheck(rule Rule, ctx *Context, node *shimast.Node, collect func(*Finding)) (panicked bool) {
639
950
  defer func() {
640
951
  r := recover()
641
952
  if r == nil {
642
953
  return
643
954
  }
955
+ panicked = true
644
956
  if ctx == nil || ctx.File == nil {
645
957
  // Without source context there is nowhere to anchor the
646
958
  // diagnostic. Surface to stderr so the panic is at least
@@ -657,6 +969,7 @@ func runRuleCheck(rule Rule, ctx *Context, node *shimast.Node, collect func(*Fin
657
969
  if end <= pos {
658
970
  end = pos + 1
659
971
  }
972
+ pos, end = shimdw.NormalizeLintRange(ctx.File, pos, end)
660
973
  collect(&Finding{
661
974
  Rule: rule.Name(),
662
975
  Severity: SeverityError,
@@ -666,8 +979,10 @@ func runRuleCheck(rule Rule, ctx *Context, node *shimast.Node, collect func(*Fin
666
979
  "Rule %q panicked while checking this node: %v. Report this to the rule's author; ttsc skipped the rule on this file.",
667
980
  rule.Name(), r,
668
981
  ),
669
- File: ctx.File,
982
+ File: ctx.File,
983
+ engineFailure: true,
670
984
  })
671
985
  }()
672
986
  rule.Check(ctx, node)
987
+ return false
673
988
  }