@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
@@ -1,6 +1,8 @@
1
1
  package linthost
2
2
 
3
3
  import (
4
+ "sync/atomic"
5
+
4
6
  shimast "github.com/microsoft/typescript-go/shim/ast"
5
7
  )
6
8
 
@@ -32,7 +34,7 @@ func (reactJSXNoUndef) Check(ctx *Context, node *shimast.Node) {
32
34
  if first < 'A' || first > 'Z' {
33
35
  return
34
36
  }
35
- if reactExtrasFileHasDeclaration(ctx.File.AsNode(), name) {
37
+ if ctx.reactDeclaredNames()[name] {
36
38
  return
37
39
  }
38
40
  ctx.Report(info.opening, "'"+name+"' is not defined.")
@@ -102,77 +104,101 @@ func reactExtrasOpeningTagNameNode(opening *shimast.Node) *shimast.Node {
102
104
  return nil
103
105
  }
104
106
 
105
- // reactExtrasFileHasDeclaration reports whether `name` is bound anywhere
106
- // in the source file by an import, function declaration, class
107
- // declaration, variable declaration, parameter, or enum declaration. The
108
- // walk is whole-file because JSX names resolve lexically in the
109
- // surrounding closure, not against a single statement list.
110
- func reactExtrasFileHasDeclaration(root *shimast.Node, name string) bool {
111
- if root == nil || name == "" {
112
- return false
107
+ // reactDeclaredNamesKey is the fileMemo sentinel under which a file's set
108
+ // of value-level declared names is cached.
109
+ type reactDeclaredNamesKey struct{}
110
+
111
+ // reactDeclaredNamesCollectCount records how many times
112
+ // reactExtrasFileDeclaredNames walks a file. The memoized accessor triggers
113
+ // it once per file, so a regression back to the per-tag walk makes this scale
114
+ // with the JSX-element count — the property the scaling test pins. Read only
115
+ // by tests; the per-file atomic add is negligible next to the walk it guards.
116
+ var reactDeclaredNamesCollectCount atomic.Int64
117
+
118
+ // reactDeclaredNames returns the set of value-level names declared anywhere
119
+ // in this file, computed once per file and cached on the shared fileMemo so
120
+ // react/jsx-no-undef does an O(1) membership check per capitalized tag rather
121
+ // than walking the whole file per JSX element. Without a memo (a Context built
122
+ // outside the engine) it recomputes, matching the pre-memoization behavior.
123
+ func (c *Context) reactDeclaredNames() map[string]bool {
124
+ if cached, ok := c.fileValue(reactDeclaredNamesKey{}); ok {
125
+ return cached.(map[string]bool)
126
+ }
127
+ names := reactExtrasFileDeclaredNames(c.File.AsNode())
128
+ c.setFileValue(reactDeclaredNamesKey{}, names)
129
+ return names
130
+ }
131
+
132
+ // reactExtrasFileDeclaredNames collects every name bound in the source file
133
+ // by an import, function declaration, class declaration, variable
134
+ // declaration, parameter, or enum declaration. The walk is whole-file because
135
+ // JSX names resolve lexically in the surrounding closure, not against a single
136
+ // statement list; membership in the returned set answers exactly what the old
137
+ // per-name predicate did, so react/jsx-no-undef's findings are unchanged.
138
+ func reactExtrasFileDeclaredNames(root *shimast.Node) map[string]bool {
139
+ reactDeclaredNamesCollectCount.Add(1)
140
+ names := map[string]bool{}
141
+ if root == nil {
142
+ return names
143
+ }
144
+ add := func(name string) {
145
+ if name != "" {
146
+ names[name] = true
147
+ }
113
148
  }
114
- found := false
115
149
  walkDescendants(root, func(child *shimast.Node) {
116
- if found || child == nil {
150
+ if child == nil {
117
151
  return
118
152
  }
119
153
  switch child.Kind {
120
154
  case shimast.KindFunctionDeclaration:
121
- if fn := child.AsFunctionDeclaration(); fn != nil && identifierText(fn.Name()) == name {
122
- found = true
155
+ if fn := child.AsFunctionDeclaration(); fn != nil {
156
+ add(identifierText(fn.Name()))
123
157
  }
124
158
  case shimast.KindClassDeclaration:
125
- if cl := child.AsClassDeclaration(); cl != nil && identifierText(cl.Name()) == name {
126
- found = true
159
+ if cl := child.AsClassDeclaration(); cl != nil {
160
+ add(identifierText(cl.Name()))
127
161
  }
128
162
  case shimast.KindVariableDeclaration:
129
- if v := child.AsVariableDeclaration(); v != nil && identifierText(v.Name()) == name {
130
- found = true
163
+ if v := child.AsVariableDeclaration(); v != nil {
164
+ add(identifierText(v.Name()))
131
165
  }
132
166
  case shimast.KindParameter:
133
- if p := child.AsParameterDeclaration(); p != nil && identifierText(p.Name()) == name {
134
- found = true
167
+ if p := child.AsParameterDeclaration(); p != nil {
168
+ add(identifierText(p.Name()))
135
169
  }
136
170
  case shimast.KindEnumDeclaration:
137
- if e := child.AsEnumDeclaration(); e != nil && identifierText(e.Name()) == name {
138
- found = true
171
+ if e := child.AsEnumDeclaration(); e != nil {
172
+ add(identifierText(e.Name()))
139
173
  }
140
174
  case shimast.KindImportClause:
141
175
  clause := child.AsImportClause()
142
176
  if clause == nil {
143
177
  return
144
178
  }
145
- if identifierText(clause.Name()) == name {
146
- found = true
179
+ add(identifierText(clause.Name()))
180
+ if clause.NamedBindings == nil {
147
181
  return
148
182
  }
149
- if clause.NamedBindings != nil {
150
- switch clause.NamedBindings.Kind {
151
- case shimast.KindNamespaceImport:
152
- ns := clause.NamedBindings.AsNamespaceImport()
153
- if ns != nil && identifierText(ns.Name()) == name {
154
- found = true
155
- }
156
- case shimast.KindNamedImports:
157
- named := clause.NamedBindings.AsNamedImports()
158
- if named == nil || named.Elements == nil {
159
- return
160
- }
161
- for _, spec := range named.Elements.Nodes {
162
- s := spec.AsImportSpecifier()
163
- if s == nil {
164
- continue
165
- }
166
- if identifierText(s.Name()) == name {
167
- found = true
168
- return
169
- }
183
+ switch clause.NamedBindings.Kind {
184
+ case shimast.KindNamespaceImport:
185
+ if ns := clause.NamedBindings.AsNamespaceImport(); ns != nil {
186
+ add(identifierText(ns.Name()))
187
+ }
188
+ case shimast.KindNamedImports:
189
+ named := clause.NamedBindings.AsNamedImports()
190
+ if named == nil || named.Elements == nil {
191
+ return
192
+ }
193
+ for _, spec := range named.Elements.Nodes {
194
+ if s := spec.AsImportSpecifier(); s != nil {
195
+ add(identifierText(s.Name()))
170
196
  }
171
197
  }
172
198
  }
173
199
  }
174
200
  })
175
- return found
201
+ return names
176
202
  }
177
203
 
178
204
  // reactExtrasIsDisplayNameWrapperCall reports whether the call
@@ -174,6 +174,9 @@ func (a *reactHooksAnalyzer) checkComponentHookFactories(root *shimast.Node) {
174
174
 
175
175
  func (a *reactHooksAnalyzer) checkSetStateInRender(root *shimast.Node) {
176
176
  walkDescendants(root, func(node *shimast.Node) {
177
+ if node == nil || node.Kind != shimast.KindCallExpression {
178
+ return
179
+ }
177
180
  call := node.AsCallExpression()
178
181
  if call == nil {
179
182
  return
@@ -192,6 +195,9 @@ func (a *reactHooksAnalyzer) checkSetStateInRender(root *shimast.Node) {
192
195
 
193
196
  func (a *reactHooksAnalyzer) checkSetStateInEffect(root *shimast.Node) {
194
197
  walkDescendants(root, func(node *shimast.Node) {
198
+ if node == nil || node.Kind != shimast.KindCallExpression {
199
+ return
200
+ }
195
201
  call := node.AsCallExpression()
196
202
  if call == nil {
197
203
  return
@@ -255,6 +261,9 @@ func (a *reactHooksAnalyzer) reportImmutableWrite(target *shimast.Node, reportNo
255
261
 
256
262
  func (a *reactHooksAnalyzer) checkRefs(root *shimast.Node) {
257
263
  walkDescendants(root, func(node *shimast.Node) {
264
+ if node == nil || node.Kind != shimast.KindPropertyAccessExpression {
265
+ return
266
+ }
258
267
  access := node.AsPropertyAccessExpression()
259
268
  if access == nil || identifierText(access.Name()) != "current" {
260
269
  return
@@ -281,6 +290,9 @@ func (a *reactHooksAnalyzer) collectFunctionBindings(fn *reactHooksFunction) {
281
290
  if a.nearestFunction(node) != fn {
282
291
  return
283
292
  }
293
+ if node == nil || node.Kind != shimast.KindVariableDeclaration {
294
+ return
295
+ }
284
296
  decl := node.AsVariableDeclaration()
285
297
  if decl == nil || decl.Initializer == nil {
286
298
  return
@@ -487,7 +499,11 @@ func isNamedReactCallee(node *shimast.Node, name string) bool {
487
499
  }
488
500
 
489
501
  func isNamedReactCalleeFromCall(node *shimast.Node, name string) bool {
490
- call := stripParens(node).AsCallExpression()
502
+ node = stripParens(node)
503
+ if node == nil || node.Kind != shimast.KindCallExpression {
504
+ return false
505
+ }
506
+ call := node.AsCallExpression()
491
507
  return call != nil && isNamedReactCallee(call.Expression, name)
492
508
  }
493
509
 
@@ -563,7 +579,7 @@ func statementContainsOwnReturn(stmt *shimast.Node, fnNode *shimast.Node) bool {
563
579
  func functionContainsReactHookCall(node *shimast.Node) bool {
564
580
  found := false
565
581
  walkDescendants(reactFunctionBody(node), func(child *shimast.Node) {
566
- if found || child == node {
582
+ if found || child == nil || child == node || child.Kind != shimast.KindCallExpression {
567
583
  return
568
584
  }
569
585
  call := child.AsCallExpression()
@@ -12,6 +12,7 @@ import (
12
12
  type reactPerfMatcher func(*shimast.Node) bool
13
13
 
14
14
  type reactPerfRule struct {
15
+ optionsRule
15
16
  name string
16
17
  message string
17
18
  matcher reactPerfMatcher
@@ -9,7 +9,7 @@ import (
9
9
  // onlyExportComponents enforces React Fast Refresh's module boundary rule:
10
10
  // files that export React components should not also export non-components.
11
11
  // Ported from eslint-plugin-react-refresh's only-export-components rule.
12
- type onlyExportComponents struct{}
12
+ type onlyExportComponents struct{ optionsRule }
13
13
 
14
14
  type onlyExportComponentsOptions struct {
15
15
  ExtraHOCs []string `json:"extraHOCs"`
@@ -4,8 +4,10 @@ import (
4
4
  "sort"
5
5
  "strconv"
6
6
  "strings"
7
+ "unicode"
7
8
 
8
9
  shimast "github.com/microsoft/typescript-go/shim/ast"
10
+ shimscanner "github.com/microsoft/typescript-go/shim/scanner"
9
11
  )
10
12
 
11
13
  // regexpSourceRule implements the high-confidence, source-text subset of
@@ -145,6 +147,43 @@ func regexpHasEmptyLookaround(parts regexpLiteralParts) bool {
145
147
  })
146
148
  }
147
149
 
150
+ // regexpHasEmptyCharacterClass reports non-negated character classes whose
151
+ // parsed element list is empty. The compiler's regexp parser is the syntax
152
+ // authority; the structural walk runs only after that parser accepts the full
153
+ // literal, so malformed escapes, flags, and class-set expressions cannot
154
+ // create lint findings.
155
+ func regexpHasEmptyCharacterClass(parts regexpLiteralParts) bool {
156
+ if !shimscanner.IsValidRegularExpressionLiteral(parts.raw) {
157
+ return false
158
+ }
159
+ unicodeSets := strings.Contains(parts.flags, "v")
160
+ depth := 0
161
+ for i := 0; i < len(parts.pattern); i++ {
162
+ switch parts.pattern[i] {
163
+ case '\\':
164
+ i++
165
+ case '[':
166
+ if depth != 0 && !unicodeSets {
167
+ continue
168
+ }
169
+ depth++
170
+ content := i + 1
171
+ negated := content < len(parts.pattern) && parts.pattern[content] == '^'
172
+ if negated {
173
+ content++
174
+ }
175
+ if !negated && content < len(parts.pattern) && parts.pattern[content] == ']' {
176
+ return true
177
+ }
178
+ case ']':
179
+ if depth > 0 {
180
+ depth--
181
+ }
182
+ }
183
+ }
184
+ return false
185
+ }
186
+
148
187
  func regexpHasZeroQuantifier(parts regexpLiteralParts) bool {
149
188
  return scanRegexpQuantifiers(parts.pattern, func(min, max int, hasComma bool) bool {
150
189
  return min == 0 && (!hasComma || max == 0)
@@ -205,11 +244,32 @@ func regexpFlagOrder(flag byte) int {
205
244
  return len(order) + int(flag)
206
245
  }
207
246
 
247
+ // regexpHasUselessFlag decides `regexp/no-useless-flag` for the two flags whose
248
+ // effect is visible in the pattern alone: `i` (nothing it could re-case) and `m`
249
+ // (no `^`/`$` for it to re-anchor).
250
+ //
251
+ // Both questions are answered on the regexp AST from regex_tree.go rather than
252
+ // on a byte scan. `scanRegexpPattern` never enters a character class -- correct
253
+ // for the rules that hunt `|`, `(`, `{` outside classes, and correct for `m`
254
+ // (`^`/`$` are literals inside a class), but fatal for `i`, because `[a-z]` is
255
+ // exactly where the flag earns its keep.
256
+ //
257
+ // A literal the parser rejects yields no finding at all: the rule tells people
258
+ // to delete a flag, so it stays silent whenever it cannot see the whole pattern.
208
259
  func regexpHasUselessFlag(parts regexpLiteralParts) bool {
209
- if strings.Contains(parts.flags, "i") && !regexpPatternHasAsciiLetter(parts.pattern) {
260
+ ignoreCase := strings.Contains(parts.flags, "i")
261
+ multiline := strings.Contains(parts.flags, "m")
262
+ if !ignoreCase && !multiline {
263
+ return false
264
+ }
265
+ parsed, err := regexParseLiteral(parts.raw)
266
+ if err != nil {
267
+ return false
268
+ }
269
+ if ignoreCase && !regexpNodeIsCaseVariant(parsed.Body, strings.ContainsAny(parts.flags, "uv")) {
210
270
  return true
211
271
  }
212
- if strings.Contains(parts.flags, "m") && !regexpPatternHasLineAnchor(parts.pattern) {
272
+ if multiline && !regexpNodeHasLineAnchor(parsed.Body) {
213
273
  return true
214
274
  }
215
275
  return false
@@ -365,17 +425,175 @@ func classHasRange(content string) bool {
365
425
  return false
366
426
  }
367
427
 
368
- func regexpPatternHasAsciiLetter(pattern string) bool {
369
- return scanRegexpPattern(pattern, func(pattern string, i int) bool {
370
- ch := pattern[i]
371
- return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')
372
- })
428
+ // regexpCaseFoldScanLimit bounds the fold scan of a single character range.
429
+ // The scan exists to prove a range case-*invariant*, so refusing to walk an
430
+ // unbounded one costs at most a missed report, never a wrong one. Every script
431
+ // block that carries cased letters is orders of magnitude narrower than this.
432
+ const regexpCaseFoldScanLimit = 0x20000
433
+
434
+ // regexpNodeIsCaseVariant reports whether toggling the `i` flag can change what
435
+ // the node matches.
436
+ //
437
+ // Mirrors eslint-plugin-regexp's `isCaseVariant(pattern, flags, false)`, which
438
+ // judges a character class element by element rather than as a whole set: an
439
+ // element that the flag widens keeps the flag alive even when the class already
440
+ // spells both cases out, so `/[a-zA-Z]/i` is case-variant.
441
+ //
442
+ // The analysis is one-sided. Every construct it cannot settle from the AST --
443
+ // a Unicode property escape, a `v`-mode set-notation class, a range too wide to
444
+ // fold-scan, an unmodeled node -- counts as case-variant, so the rule stays
445
+ // quiet rather than order a load-bearing flag deleted. Backreferences are
446
+ // case-variant for a real reason and not merely out of caution: `i` makes the
447
+ // backreference comparison itself case-insensitive, so `/(.)\1/i` matches "aA"
448
+ // while `/(.)\1/` does not, without a single letter in the source.
449
+ func regexpNodeIsCaseVariant(node regexNode, unicodeMode bool) bool {
450
+ switch n := node.(type) {
451
+ case nil:
452
+ return false
453
+ case *regexCharNode:
454
+ return regexpCharIsCaseVariant(n, unicodeMode)
455
+ case *regexClassRangeNode:
456
+ return regexpClassRangeIsCaseVariant(n, unicodeMode)
457
+ case *regexClassNode:
458
+ for _, expression := range n.Expressions {
459
+ if regexpNodeIsCaseVariant(expression, unicodeMode) {
460
+ return true
461
+ }
462
+ }
463
+ return false
464
+ case *regexAlternativeNode:
465
+ for _, expression := range n.Expressions {
466
+ if regexpNodeIsCaseVariant(expression, unicodeMode) {
467
+ return true
468
+ }
469
+ }
470
+ return false
471
+ case *regexDisjunctionNode:
472
+ return regexpNodeIsCaseVariant(n.Left, unicodeMode) ||
473
+ regexpNodeIsCaseVariant(n.Right, unicodeMode)
474
+ case *regexGroupNode:
475
+ // A group name is never matched against the input, so `/(?<year>\d{4})/i`
476
+ // stays case-invariant despite the letters in `year`.
477
+ return regexpNodeIsCaseVariant(n.Expression, unicodeMode)
478
+ case *regexRepetitionNode:
479
+ return regexpNodeIsCaseVariant(n.Expression, unicodeMode)
480
+ case *regexAssertionNode:
481
+ switch n.Kind {
482
+ case "^", "$":
483
+ return false
484
+ case "\\b", "\\B":
485
+ // Word boundaries are defined in terms of `\w`, which grows by U+017F and
486
+ // U+212A under `iu`/`iv`.
487
+ return unicodeMode
488
+ }
489
+ return regexpNodeIsCaseVariant(n.Assertion, unicodeMode)
490
+ case *regexBackreferenceNode:
491
+ // `i` canonicalizes the backreference comparison itself.
492
+ return true
493
+ case *regexUnicodePropertyNode:
494
+ // Whether `\p{...}` is closed under case folding needs the Unicode property
495
+ // tables, not the source text: `\p{Lu}` moves under `i`, `\p{Nd}` does not.
496
+ return true
497
+ case *regexClassSetNode:
498
+ // A `v`-mode set-notation class is kept verbatim by the parser, so its
499
+ // members are not available to judge.
500
+ return true
501
+ }
502
+ // An unmodeled node keeps the flag: silence beats a wrong deletion.
503
+ return true
373
504
  }
374
505
 
375
- func regexpPatternHasLineAnchor(pattern string) bool {
376
- return scanRegexpPattern(pattern, func(pattern string, i int) bool {
377
- return pattern[i] == '^' || pattern[i] == '$'
378
- })
506
+ // regexpCharIsCaseVariant reports whether the `i` flag widens what a single
507
+ // character node matches.
508
+ func regexpCharIsCaseVariant(char *regexCharNode, unicodeMode bool) bool {
509
+ if !char.codePointIsNaN() {
510
+ return regexpRuneIsCaseVariant(rune(char.CodePoint))
511
+ }
512
+ switch char.Kind {
513
+ case "meta":
514
+ switch char.Value {
515
+ case "\\w", "\\W":
516
+ // `\w` is the one character set the flag moves: in Unicode mode
517
+ // Canonicalize folds U+017F and U+212A onto `s` and `k`, so they join the
518
+ // word characters (and leave `\W`).
519
+ return unicodeMode
520
+ case "\\d", "\\D", "\\s", "\\S", ".", "\\b":
521
+ return false
522
+ }
523
+ case "decimal":
524
+ // Annex B `\8` and `\9` match the bare digits.
525
+ return false
526
+ case "control":
527
+ // `\cX` is a control code point, but a dangling `\c` matches the two
528
+ // characters `\` and `c`, and that `c` re-cases.
529
+ return char.Value == "\\c"
530
+ }
531
+ return true
532
+ }
533
+
534
+ // regexpClassRangeIsCaseVariant reports whether the `i` flag widens a character
535
+ // class range. A range is case-variant as soon as it contains one code point
536
+ // with a case-folded counterpart, because that counterpart may sit outside the
537
+ // range.
538
+ func regexpClassRangeIsCaseVariant(node *regexClassRangeNode, unicodeMode bool) bool {
539
+ if node.From == nil || node.To == nil {
540
+ return true
541
+ }
542
+ if node.From.codePointIsNaN() || node.To.codePointIsNaN() {
543
+ // Annex B reads `[\d-z]` as three independent members while the AST still
544
+ // models it as a range, so judge the two ends on their own.
545
+ return regexpCharIsCaseVariant(node.From, unicodeMode) ||
546
+ regexpCharIsCaseVariant(node.To, unicodeMode)
547
+ }
548
+ low, high := rune(node.From.CodePoint), rune(node.To.CodePoint)
549
+ if low > high || high-low >= regexpCaseFoldScanLimit {
550
+ return true
551
+ }
552
+ for r := low; r <= high; r++ {
553
+ if regexpRuneIsCaseVariant(r) {
554
+ return true
555
+ }
556
+ }
557
+ return false
558
+ }
559
+
560
+ // regexpRuneIsCaseVariant reports whether a code point has any case-folded
561
+ // counterpart.
562
+ //
563
+ // unicode.SimpleFold walks the simple case-folding orbit, which is exactly the
564
+ // equivalence class ECMAScript's Canonicalize builds in `u`/`v` mode. Legacy
565
+ // mode canonicalizes more narrowly -- it keeps U+017F, U+212A and U+00DF apart
566
+ // from their ASCII or uppercase partners -- so there the orbit is a superset,
567
+ // and the rule at worst keeps quiet about a flag it could have reported.
568
+ func regexpRuneIsCaseVariant(r rune) bool {
569
+ return unicode.SimpleFold(r) != r
570
+ }
571
+
572
+ // regexpNodeHasLineAnchor reports whether the pattern contains a `^` or `$`
573
+ // assertion, the only thing the `m` flag re-defines. Character classes are not
574
+ // descended into: `^` and `$` are literal characters in there.
575
+ func regexpNodeHasLineAnchor(node regexNode) bool {
576
+ switch n := node.(type) {
577
+ case *regexAssertionNode:
578
+ if n.Kind == "^" || n.Kind == "$" {
579
+ return true
580
+ }
581
+ return regexpNodeHasLineAnchor(n.Assertion)
582
+ case *regexAlternativeNode:
583
+ for _, expression := range n.Expressions {
584
+ if regexpNodeHasLineAnchor(expression) {
585
+ return true
586
+ }
587
+ }
588
+ return false
589
+ case *regexDisjunctionNode:
590
+ return regexpNodeHasLineAnchor(n.Left) || regexpNodeHasLineAnchor(n.Right)
591
+ case *regexGroupNode:
592
+ return regexpNodeHasLineAnchor(n.Expression)
593
+ case *regexRepetitionNode:
594
+ return regexpNodeHasLineAnchor(n.Expression)
595
+ }
596
+ return false
379
597
  }
380
598
 
381
599
  func init() {
@@ -385,9 +603,7 @@ func init() {
385
603
  Register(regexpSourceRule{name: "regexp/no-dupe-characters-character-class", check: regexpHasDuplicateClassCharacter})
386
604
  Register(regexpSourceRule{name: "regexp/no-empty-alternative", check: regexpHasEmptyAlternative})
387
605
  Register(regexpSourceRule{name: "regexp/no-empty-capturing-group", check: regexpHasEmptyCapturingGroup})
388
- Register(regexpSourceRule{name: "regexp/no-empty-character-class", check: func(parts regexpLiteralParts) bool {
389
- return hasEmptyCharClass(parts.raw)
390
- }})
606
+ Register(regexpSourceRule{name: "regexp/no-empty-character-class", check: regexpHasEmptyCharacterClass})
391
607
  Register(regexpSourceRule{name: "regexp/no-empty-group", check: regexpHasEmptyGroup})
392
608
  Register(regexpSourceRule{name: "regexp/no-empty-lookarounds-assertion", check: regexpHasEmptyLookaround})
393
609
  Register(regexpSourceRule{name: "regexp/no-misleading-unicode-character", check: func(parts regexpLiteralParts) bool {
@@ -2,6 +2,7 @@ package linthost
2
2
 
3
3
  import (
4
4
  "strings"
5
+ "sync/atomic"
5
6
  "unicode/utf8"
6
7
 
7
8
  shimast "github.com/microsoft/typescript-go/shim/ast"
@@ -9,6 +10,33 @@ import (
9
10
 
10
11
  const securityRulePrefix = "security/"
11
12
 
13
+ // securityBindingsKey is the fileMemo sentinel under which a file's
14
+ // security binding table is cached. Every `security/*` rule shares the
15
+ // identical file-invariant table, so keying it on the type — not on a
16
+ // per-rule value — lets one walk serve all of them for the whole file.
17
+ type securityBindingsKey struct{}
18
+
19
+ // securityBindingsCollectCount records how many times collectSecurityBindings
20
+ // actually walks a file. The memoized accessor triggers it once per file, so a
21
+ // regression that reintroduces the per-node rebuild makes this scale with the
22
+ // visited call-node count — the property the scaling test pins. Read only by
23
+ // tests; the per-file atomic add is negligible next to the walk it guards.
24
+ var securityBindingsCollectCount atomic.Int64
25
+
26
+ // securityBindings returns this file's binding table, computing it once
27
+ // per file and caching it on the shared fileMemo so every security rule
28
+ // and every visited call/new node reuses one table instead of rebuilding
29
+ // it. Without a memo (a Context built outside the engine) it recomputes,
30
+ // matching the pre-memoization behavior exactly.
31
+ func (c *Context) securityBindings() securityBindings {
32
+ if cached, ok := c.fileValue(securityBindingsKey{}); ok {
33
+ return cached.(securityBindings)
34
+ }
35
+ bindings := collectSecurityBindings(c.File)
36
+ c.setFileValue(securityBindingsKey{}, bindings)
37
+ return bindings
38
+ }
39
+
12
40
  type securityDetectBidiCharacters struct{}
13
41
 
14
42
  func (securityDetectBidiCharacters) Name() string {
@@ -63,7 +91,7 @@ func (securityDetectChildProcess) Check(ctx *Context, node *shimast.Node) {
63
91
  if call == nil {
64
92
  return
65
93
  }
66
- bindings := collectSecurityBindings(ctx.File)
94
+ bindings := ctx.securityBindings()
67
95
  if module, ok := requireCallModule(call); ok && isChildProcessModule(module) {
68
96
  if isInlineChildProcessExecRequire(node, bindings) {
69
97
  return
@@ -112,7 +140,7 @@ func (securityDetectEvalWithExpression) Check(ctx *Context, node *shimast.Node)
112
140
  if call == nil || callCalleeName(call) != "eval" || call.Arguments == nil || len(call.Arguments.Nodes) == 0 {
113
141
  return
114
142
  }
115
- bindings := collectSecurityBindings(ctx.File)
143
+ bindings := ctx.securityBindings()
116
144
  if !isSecurityStaticExpression(call.Arguments.Nodes[0], bindings, nil) {
117
145
  ctx.Report(node, "eval called with a non-literal expression.")
118
146
  }
@@ -129,7 +157,7 @@ func (securityDetectNewBuffer) Check(ctx *Context, node *shimast.Node) {
129
157
  if expr == nil || identifierText(expr.Expression) != "Buffer" || expr.Arguments == nil || len(expr.Arguments.Nodes) == 0 {
130
158
  return
131
159
  }
132
- bindings := collectSecurityBindings(ctx.File)
160
+ bindings := ctx.securityBindings()
133
161
  if !isSecurityStaticExpression(expr.Arguments.Nodes[0], bindings, nil) {
134
162
  ctx.Report(node, "Found new Buffer with a non-literal argument.")
135
163
  }
@@ -178,7 +206,7 @@ func (securityDetectNonLiteralFSFilename) Check(ctx *Context, node *shimast.Node
178
206
  if call == nil || call.Arguments == nil || len(call.Arguments.Nodes) == 0 {
179
207
  return
180
208
  }
181
- bindings := collectSecurityBindings(ctx.File)
209
+ bindings := ctx.securityBindings()
182
210
  method, module, ok := fsCallInfo(call, bindings)
183
211
  if !ok || !isFSFilenameMethod(method) {
184
212
  return
@@ -215,7 +243,7 @@ func (securityDetectNonLiteralRegexp) Check(ctx *Context, node *shimast.Node) {
215
243
  if len(args) == 0 {
216
244
  return
217
245
  }
218
- bindings := collectSecurityBindings(ctx.File)
246
+ bindings := ctx.securityBindings()
219
247
  if !isSecurityStaticExpression(args[0], bindings, nil) {
220
248
  ctx.Report(node, "Found non-literal argument to RegExp constructor.")
221
249
  }
@@ -234,7 +262,7 @@ func (securityDetectNonLiteralRequire) Check(ctx *Context, node *shimast.Node) {
234
262
  if call == nil || callCalleeName(call) != "require" || call.Arguments == nil || len(call.Arguments.Nodes) == 0 {
235
263
  return
236
264
  }
237
- bindings := collectSecurityBindings(ctx.File)
265
+ bindings := ctx.securityBindings()
238
266
  if !isSecurityStaticExpression(call.Arguments.Nodes[0], bindings, nil) {
239
267
  ctx.Report(node, "Found non-literal argument in require.")
240
268
  }
@@ -323,6 +351,7 @@ type securityBindings struct {
323
351
  }
324
352
 
325
353
  func collectSecurityBindings(file *shimast.SourceFile) securityBindings {
354
+ securityBindingsCollectCount.Add(1)
326
355
  bindings := securityBindings{
327
356
  Modules: map[string]string{},
328
357
  Named: map[string]securityNamedBinding{},
@@ -367,7 +396,7 @@ func collectSecurityAssignedNames(node *shimast.Node) map[string]bool {
367
396
  return
368
397
  }
369
398
  if expr.Operator == shimast.KindPlusPlusToken || expr.Operator == shimast.KindMinusMinusToken {
370
- if name := identifierText(expr.Operand); name != "" {
399
+ for _, name := range assignmentTargetNames(expr.Operand) {
371
400
  assigned[name] = true
372
401
  }
373
402
  }
@@ -377,10 +406,18 @@ func collectSecurityAssignedNames(node *shimast.Node) map[string]bool {
377
406
  return
378
407
  }
379
408
  if expr.Operator == shimast.KindPlusPlusToken || expr.Operator == shimast.KindMinusMinusToken {
380
- if name := identifierText(expr.Operand); name != "" {
409
+ for _, name := range assignmentTargetNames(expr.Operand) {
381
410
  assigned[name] = true
382
411
  }
383
412
  }
413
+ case shimast.KindForOfStatement, shimast.KindForInStatement:
414
+ stmt := child.AsForInOrOfStatement()
415
+ if stmt == nil || stmt.Initializer == nil || stmt.Initializer.Kind == shimast.KindVariableDeclarationList {
416
+ return
417
+ }
418
+ for _, name := range assignmentTargetNames(stmt.Initializer) {
419
+ assigned[name] = true
420
+ }
384
421
  }
385
422
  })
386
423
  return assigned
@@ -748,17 +785,6 @@ func isFalseLiteral(node *shimast.Node) bool {
748
785
  return ok && !value
749
786
  }
750
787
 
751
- func isEqualityOperator(kind shimast.Kind) bool {
752
- switch kind {
753
- case shimast.KindEqualsEqualsToken,
754
- shimast.KindEqualsEqualsEqualsToken,
755
- shimast.KindExclamationEqualsToken,
756
- shimast.KindExclamationEqualsEqualsToken:
757
- return true
758
- }
759
- return false
760
- }
761
-
762
788
  func containsSecretIdentifier(node *shimast.Node) bool {
763
789
  found := false
764
790
  walkDescendants(node, func(child *shimast.Node) {