@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.
- package/README.md +125 -43
- package/go.mod +5 -4
- package/internal/rulecode/rulecode.go +88 -0
- package/lib/index.d.ts +10 -1
- package/lib/index.js +45 -17
- package/lib/index.js.map +1 -1
- package/lib/structures/ITtscLintConfig.d.ts +3 -0
- package/lib/structures/TtscLintRuleSetting.d.ts +3 -2
- package/lib/structures/format/ITtscLintFormat.d.ts +3 -2
- package/lib/structures/format/ITtscLintFormatSortImports.d.ts +20 -4
- package/lib/structures/rules/ITtscLintBoundariesRuleOptions.d.ts +80 -8
- package/lib/structures/rules/ITtscLintBoundariesRules.d.ts +6 -8
- package/lib/structures/rules/ITtscLintContributorRules.d.ts +18 -2
- package/lib/structures/rules/ITtscLintCoreRuleOptions.d.ts +347 -0
- package/lib/structures/rules/ITtscLintCoreRuleOptions.js +3 -0
- package/lib/structures/rules/ITtscLintCoreRuleOptions.js.map +1 -0
- package/lib/structures/rules/ITtscLintCoreRules.d.ts +101 -67
- package/lib/structures/rules/ITtscLintRegexpRules.d.ts +7 -2
- package/lib/structures/rules/ITtscLintRuleOptionsMap.d.ts +53 -7
- package/lib/structures/rules/ITtscLintRules.d.ts +6 -2
- package/lib/structures/rules/ITtscLintSolidRules.d.ts +0 -8
- package/lib/structures/rules/ITtscLintTypeScriptRuleOptions.d.ts +206 -0
- package/lib/structures/rules/ITtscLintTypeScriptRuleOptions.js +8 -0
- package/lib/structures/rules/ITtscLintTypeScriptRuleOptions.js.map +1 -0
- package/lib/structures/rules/ITtscLintTypeScriptRules.d.ts +55 -42
- package/lib/structures/rules/ITtscLintUnicornRuleOptions.d.ts +276 -0
- package/lib/structures/rules/ITtscLintUnicornRuleOptions.js +3 -0
- package/lib/structures/rules/ITtscLintUnicornRuleOptions.js.map +1 -0
- package/lib/structures/rules/ITtscLintUnicornRules.d.ts +44 -36
- package/lib/structures/rules/index.d.ts +3 -0
- package/lib/structures/rules/index.js +3 -0
- package/lib/structures/rules/index.js.map +1 -1
- package/linthost/ast_helpers.go +521 -23
- package/linthost/ast_selector_matcher.go +1628 -0
- package/linthost/ast_selector_parser.go +628 -0
- package/linthost/comment_scan.go +154 -0
- package/linthost/compile.go +129 -79
- package/linthost/config.go +352 -88
- package/linthost/config_format.go +15 -1
- package/linthost/contrib_adapter.go +47 -8
- package/linthost/declaration_rules.go +12 -11
- package/linthost/directives.go +9 -67
- package/linthost/dispatch.go +37 -3
- package/linthost/engine.go +368 -53
- package/linthost/escape_string.go +77 -0
- package/linthost/fix.go +133 -23
- package/linthost/flags_gen.go +18 -17
- package/linthost/format.go +73 -167
- package/linthost/format_editor_settings.go +134 -26
- package/linthost/grapheme_tables_gen.go +1920 -0
- package/linthost/host.go +159 -61
- package/linthost/literal_escape_scan.go +117 -0
- package/linthost/lsp.go +545 -98
- package/linthost/numeric_literal_precision.go +228 -0
- package/linthost/polyfill_browserslist.go +1439 -0
- package/linthost/polyfill_browserslist_config.go +603 -0
- package/linthost/polyfill_corejs.go +750 -0
- package/linthost/polyfill_data.go +113 -0
- package/linthost/polyfill_data_gen.json +39868 -0
- package/linthost/print_nodes_array.go +7 -1
- package/linthost/print_nodes_object.go +7 -1
- package/linthost/project_engine.go +231 -0
- package/linthost/project_rules.go +78 -0
- package/linthost/regex_clean.go +191 -0
- package/linthost/regex_tree.go +1544 -0
- package/linthost/regex_tree_optimizer.go +1685 -0
- package/linthost/regexp_cache.go +44 -0
- package/linthost/rule_codes.go +105 -0
- package/linthost/rule_codes.json +745 -0
- package/linthost/rules_ban_ts_comment.go +312 -0
- package/linthost/rules_boundaries.go +128 -43
- package/linthost/rules_boundaries_dependencies.go +1013 -0
- package/linthost/rules_core_extra.go +62 -92
- package/linthost/rules_cypress.go +1 -1
- package/linthost/rules_default_case.go +87 -11
- package/linthost/rules_dupes.go +38 -13
- package/linthost/rules_empty.go +188 -20
- package/linthost/rules_escape.go +62 -48
- package/linthost/rules_format_arrow_parens.go +19 -5
- package/linthost/rules_format_bracket_spacing.go +1 -1
- package/linthost/rules_format_clause_join.go +1 -1
- package/linthost/rules_format_declaration_header.go +31 -19
- package/linthost/rules_format_indent.go +1 -1
- package/linthost/rules_format_jsdoc.go +1 -1
- package/linthost/rules_format_orphan_semi.go +1 -1
- package/linthost/rules_format_parameter_properties.go +17 -7
- package/linthost/rules_format_print_width.go +1 -1
- package/linthost/rules_format_quote_props.go +1 -1
- package/linthost/rules_format_quotes.go +1 -1
- package/linthost/rules_format_semi.go +106 -79
- package/linthost/rules_format_sort_imports.go +79 -45
- package/linthost/rules_format_statement_split.go +1 -1
- package/linthost/rules_format_ternary_nullish_parens.go +1 -1
- package/linthost/rules_format_trailing_comma.go +64 -1
- package/linthost/rules_format_whitespace.go +1 -1
- package/linthost/rules_functional.go +21 -22
- package/linthost/rules_gap.go +80 -3
- package/linthost/rules_grouped_accessor_pairs.go +84 -25
- package/linthost/rules_guard_for_in.go +64 -147
- package/linthost/rules_jsx_a11y.go +232 -22
- package/linthost/rules_logic.go +84 -38
- package/linthost/rules_nextjs.go +105 -16
- package/linthost/rules_no_class_assign.go +70 -0
- package/linthost/rules_no_duplicate_imports.go +266 -0
- package/linthost/rules_no_else_return.go +156 -38
- package/linthost/rules_no_extend_native.go +113 -17
- package/linthost/rules_no_fallthrough.go +1013 -0
- package/linthost/rules_no_func_assign.go +70 -0
- package/linthost/rules_no_import_assign.go +278 -86
- package/linthost/rules_no_inner_declarations.go +186 -0
- package/linthost/rules_no_loop_func.go +438 -53
- package/linthost/rules_no_mixed_operators.go +208 -52
- package/linthost/rules_no_param_reassign.go +376 -61
- package/linthost/rules_no_promise_executor_return.go +156 -0
- package/linthost/rules_no_restricted_imports.go +1013 -57
- package/linthost/rules_no_restricted_syntax.go +190 -26
- package/linthost/rules_no_useless_constructor.go +6 -36
- package/linthost/rules_problems.go +154 -311
- package/linthost/rules_promise.go +2578 -109
- package/linthost/rules_react_extras.go +71 -45
- package/linthost/rules_react_hooks.go +18 -2
- package/linthost/rules_react_perf.go +1 -0
- package/linthost/rules_react_refresh.go +1 -1
- package/linthost/rules_regexp.go +230 -14
- package/linthost/rules_security.go +45 -19
- package/linthost/rules_self.go +21 -1
- package/linthost/rules_solid.go +13 -7
- package/linthost/rules_storybook.go +1 -1
- package/linthost/rules_suggestions.go +490 -84
- package/linthost/rules_testing_library.go +12 -1
- package/linthost/rules_ts.go +227 -54
- package/linthost/rules_ts_async.go +0 -131
- package/linthost/rules_ts_no_base_to_string.go +3 -4
- package/linthost/rules_ts_no_misused_promises.go +817 -0
- package/linthost/rules_ts_no_restricted_types.go +320 -34
- package/linthost/rules_ts_no_unnecessary_qualifier.go +132 -55
- package/linthost/rules_ts_no_unsafe_assignment.go +608 -60
- package/linthost/rules_ts_require_array_sort_compare.go +5 -5
- package/linthost/rules_ts_switch_exhaustiveness_check.go +556 -86
- package/linthost/rules_unicorn_better_regex.go +224 -16
- package/linthost/rules_unicorn_consistent_destructuring.go +1002 -7
- package/linthost/rules_unicorn_consistent_existence_index_check.go +208 -29
- package/linthost/rules_unicorn_consistent_function_scoping.go +1041 -8
- package/linthost/rules_unicorn_consistent_template_literal_escape.go +178 -7
- package/linthost/rules_unicorn_escape_case.go +30 -19
- package/linthost/rules_unicorn_filename_case.go +763 -16
- package/linthost/rules_unicorn_import_style.go +783 -17
- package/linthost/rules_unicorn_isolated_functions.go +934 -14
- package/linthost/rules_unicorn_no_hex_escape.go +26 -11
- package/linthost/rules_unicorn_no_typeof_undefined.go +234 -25
- package/linthost/rules_unicorn_no_unnecessary_polyfills.go +376 -21
- package/linthost/rules_unicorn_no_unused_properties.go +756 -19
- package/linthost/rules_unicorn_number_literal_case.go +58 -39
- package/linthost/rules_unicorn_prefer_at.go +7 -3
- package/linthost/rules_unicorn_prefer_number_properties.go +381 -48
- package/linthost/rules_unicorn_prefer_optional_catch_binding.go +24 -24
- package/linthost/rules_unicorn_prefer_simple_condition_first.go +597 -19
- package/linthost/rules_unicorn_prefer_string_raw.go +147 -16
- package/linthost/rules_unicorn_prevent_abbreviations.go +1963 -95
- package/linthost/rules_unicorn_string_content.go +611 -15
- package/linthost/rules_unicorn_switch_case_break_position.go +172 -15
- package/linthost/rules_unicorn_template_indent.go +923 -18
- package/linthost/rules_unicorn_text_encoding_identifier_case.go +202 -42
- package/linthost/rules_unicorn_throw_new_error.go +218 -26
- package/linthost/rules_var.go +829 -142
- package/linthost/string_length.go +222 -0
- package/package.json +4 -3
- package/rule/project.go +206 -0
- package/rule/rule.go +58 -1
- package/src/index.ts +58 -17
- package/src/structures/ITtscLintConfig.ts +3 -0
- package/src/structures/TtscLintRuleSetting.ts +3 -2
- package/src/structures/format/ITtscLintFormat.ts +3 -2
- package/src/structures/format/ITtscLintFormatSortImports.ts +21 -4
- package/src/structures/rules/ITtscLintBoundariesRuleOptions.ts +122 -8
- package/src/structures/rules/ITtscLintBoundariesRules.ts +6 -8
- package/src/structures/rules/ITtscLintContributorRules.ts +18 -2
- package/src/structures/rules/ITtscLintCoreRuleOptions.ts +465 -0
- package/src/structures/rules/ITtscLintCoreRules.ts +122 -67
- package/src/structures/rules/ITtscLintRegexpRules.ts +7 -2
- package/src/structures/rules/ITtscLintRuleOptionsMap.ts +88 -7
- package/src/structures/rules/ITtscLintRules.ts +7 -2
- package/src/structures/rules/ITtscLintSolidRules.ts +0 -9
- package/src/structures/rules/ITtscLintTypeScriptRuleOptions.ts +248 -0
- package/src/structures/rules/ITtscLintTypeScriptRules.ts +64 -42
- package/src/structures/rules/ITtscLintUnicornRuleOptions.ts +350 -0
- package/src/structures/rules/ITtscLintUnicornRules.ts +60 -36
- package/src/structures/rules/index.ts +3 -0
package/linthost/engine.go
CHANGED
|
@@ -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
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
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
|
|
93
|
-
Checker
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
124
|
-
Severity
|
|
125
|
-
File
|
|
126
|
-
Pos
|
|
127
|
-
End
|
|
128
|
-
Message
|
|
129
|
-
Fix
|
|
130
|
-
|
|
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
|
|
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 :=
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
272
|
-
// so callers do not need to
|
|
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
|
-
//
|
|
281
|
-
//
|
|
282
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
559
|
-
Checker:
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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`
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
982
|
+
File: ctx.File,
|
|
983
|
+
engineFailure: true,
|
|
670
984
|
})
|
|
671
985
|
}()
|
|
672
986
|
rule.Check(ctx, node)
|
|
987
|
+
return false
|
|
673
988
|
}
|