@ttsc/lint 0.19.3 → 0.20.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 (63) hide show
  1. package/README.md +1 -1
  2. package/lib/index.js +26 -4
  3. package/lib/index.js.map +1 -1
  4. package/lib/structures/format/ITtscLintFormat.d.ts +14 -10
  5. package/lib/structures/rules/ITtscLintRegexpRules.d.ts +24 -9
  6. package/lib/structures/rules/ITtscLintSecurityRules.d.ts +18 -0
  7. package/lib/structures/rules/ITtscLintSolidRules.d.ts +22 -2
  8. package/lib/structures/rules/ITtscLintStorybookRules.d.ts +21 -0
  9. package/lib/structures/rules/ITtscLintTypeScriptRuleOptions.d.ts +10 -1
  10. package/lib/structures/rules/ITtscLintTypeScriptRules.d.ts +13 -2
  11. package/linthost/compile.go +9 -4
  12. package/linthost/config.go +18 -14
  13. package/linthost/contrib_adapter.go +57 -0
  14. package/linthost/dispatch.go +5 -1
  15. package/linthost/display_width.go +179 -41
  16. package/linthost/engine.go +120 -4
  17. package/linthost/flags_gen.go +4 -4
  18. package/linthost/hints.go +207 -0
  19. package/linthost/host.go +68 -0
  20. package/linthost/lsp.go +148 -29
  21. package/linthost/print_dispatch.go +24 -0
  22. package/linthost/print_nodes_array.go +88 -15
  23. package/linthost/print_nodes_call.go +38 -31
  24. package/linthost/print_nodes_control_flow.go +319 -0
  25. package/linthost/print_nodes_function.go +140 -13
  26. package/linthost/print_nodes_list.go +22 -10
  27. package/linthost/project_engine.go +18 -1
  28. package/linthost/project_rules.go +47 -0
  29. package/linthost/rule_docs.go +114 -0
  30. package/linthost/rules_boundaries.go +80 -2
  31. package/linthost/rules_format_bracket_spacing.go +18 -6
  32. package/linthost/rules_format_clause_join.go +11 -8
  33. package/linthost/rules_format_indent.go +155 -4
  34. package/linthost/rules_format_print_width.go +12 -35
  35. package/linthost/rules_format_quote_props.go +88 -27
  36. package/linthost/rules_format_statement_split.go +81 -0
  37. package/linthost/rules_format_trailing_comma.go +62 -94
  38. package/linthost/rules_gap.go +26 -17
  39. package/linthost/rules_jsdoc.go +54 -0
  40. package/linthost/rules_logic.go +30 -15
  41. package/linthost/rules_no_redeclare.go +12 -3
  42. package/linthost/rules_promise.go +37 -15
  43. package/linthost/rules_regexp.go +443 -49
  44. package/linthost/rules_security.go +243 -4
  45. package/linthost/rules_solid.go +430 -10
  46. package/linthost/rules_storybook.go +255 -10
  47. package/linthost/rules_suggestions.go +60 -39
  48. package/linthost/rules_ts.go +7 -0
  49. package/linthost/rules_ts_async.go +80 -2
  50. package/linthost/rules_ts_extra.go +21 -7
  51. package/linthost/serve.go +318 -0
  52. package/linthost/width_tables_gen.go +219 -0
  53. package/package.json +2 -2
  54. package/rule/hint.go +142 -0
  55. package/rule/rule.go +197 -1
  56. package/src/index.ts +35 -4
  57. package/src/structures/format/ITtscLintFormat.ts +14 -10
  58. package/src/structures/rules/ITtscLintRegexpRules.ts +24 -9
  59. package/src/structures/rules/ITtscLintSecurityRules.ts +18 -0
  60. package/src/structures/rules/ITtscLintSolidRules.ts +22 -2
  61. package/src/structures/rules/ITtscLintStorybookRules.ts +21 -0
  62. package/src/structures/rules/ITtscLintTypeScriptRuleOptions.ts +10 -1
  63. package/src/structures/rules/ITtscLintTypeScriptRules.ts +13 -2
@@ -94,6 +94,7 @@ type contributorMetadata struct {
94
94
  visits []shimast.Kind
95
95
  visitsDeclarationFiles bool
96
96
  needsTypeChecker bool
97
+ diagnosticTags []rule.DiagnosticTag
97
98
  }
98
99
 
99
100
  // inspectContributor evaluates the public rule metadata behind a recover
@@ -124,6 +125,9 @@ func inspectContributor(contributor rule.Rule) (metadata contributorMetadata, er
124
125
  if typeAware, ok := contributor.(rule.TypeAwareRule); ok {
125
126
  metadata.needsTypeChecker = typeAware.NeedsTypeChecker()
126
127
  }
128
+ if tagged, ok := contributor.(rule.TaggedRule); ok {
129
+ metadata.diagnosticTags = tagged.DiagnosticTags()
130
+ }
127
131
  if optionsRule, ok := contributor.(rule.OptionsRule); ok {
128
132
  metadata.acceptsOptions = optionsRule.AcceptsTtscLintOptions()
129
133
  }
@@ -142,6 +146,7 @@ func newContributorAdapter(metadata contributorMetadata) contributorAdapter {
142
146
  visits: metadata.visits,
143
147
  visitsDeclarationFiles: metadata.visitsDeclarationFiles,
144
148
  needsTypeChecker: metadata.needsTypeChecker,
149
+ diagnosticTags: metadata.diagnosticTags,
145
150
  }
146
151
  }
147
152
 
@@ -159,6 +164,7 @@ type contributorAdapter struct {
159
164
  visits []shimast.Kind
160
165
  visitsDeclarationFiles bool
161
166
  needsTypeChecker bool
167
+ diagnosticTags []rule.DiagnosticTag
162
168
  }
163
169
 
164
170
  // AcceptsTtscLintOptions preserves the public contributor contract:
@@ -190,6 +196,14 @@ func (a contributorAdapter) VisitsDeclarationFiles() bool {
190
196
  return a.visitsDeclarationFiles
191
197
  }
192
198
 
199
+ // DiagnosticTags forwards the contributor's rule.TaggedRule classification so
200
+ // the host reads it through the same assertion path as a built-in tagged rule.
201
+ // Without this the wrapping adapter would hide the marker and every
202
+ // contributor's tags would silently vanish.
203
+ func (a contributorAdapter) DiagnosticTags() []rule.DiagnosticTag {
204
+ return a.diagnosticTags
205
+ }
206
+
193
207
  // formatContributorAdapter is the FormatRule-tagged variant of
194
208
  // contributorAdapter. Wrapping the lint-only adapter (rather than
195
209
  // duplicating its method set) keeps the marker addition trivial and
@@ -241,6 +255,49 @@ func (r contextReporter) ReportRangeFix(pos, end int, message string, edits ...r
241
255
  r.ctx.ReportRangeFix(pos, end, message, toInternalTextEdits(edits)...)
242
256
  }
243
257
 
258
+ // ReportSuggestion and ReportRangeSuggestion satisfy rule.SuggestionReporter,
259
+ // so a contributor can offer a choice of fixes — the surface built-in rules
260
+ // already had internally and contributors could not reach.
261
+ func (r contextReporter) ReportSuggestion(node *shimast.Node, message string, suggestions ...rule.Suggestion) {
262
+ r.ctx.ReportFixSuggestions(node, message, nil, toInternalSuggestions(suggestions)...)
263
+ }
264
+
265
+ func (r contextReporter) ReportRangeSuggestion(pos, end int, message string, suggestions ...rule.Suggestion) {
266
+ r.ctx.ReportRangeSuggestions(pos, end, message, toInternalSuggestions(suggestions)...)
267
+ }
268
+
269
+ func toInternalSuggestions(suggestions []rule.Suggestion) []Suggestion {
270
+ if len(suggestions) == 0 {
271
+ return nil
272
+ }
273
+ out := make([]Suggestion, len(suggestions))
274
+ for i, suggestion := range suggestions {
275
+ out[i] = Suggestion{
276
+ Title: suggestion.Title,
277
+ Edits: toInternalTextEdits(suggestion.Edits),
278
+ }
279
+ }
280
+ return out
281
+ }
282
+
283
+ func (r contextReporter) ReportRelated(node *shimast.Node, message string, related ...rule.RelatedInformation) {
284
+ r.ctx.ReportRelated(node, message, related...)
285
+ }
286
+
287
+ func (r contextReporter) ReportRangeRelated(pos, end int, message string, related ...rule.RelatedInformation) {
288
+ r.ctx.ReportRangeRelated(pos, end, message, related...)
289
+ }
290
+
291
+ // contextReporter must satisfy the optional reporter extensions whole, since the
292
+ // public Context type-asserts against each: a signature drift here would
293
+ // silently drop the rule back to the plain-diagnostic fallback instead of
294
+ // failing the build.
295
+ var (
296
+ _ rule.FixReporter = contextReporter{}
297
+ _ rule.SuggestionReporter = contextReporter{}
298
+ _ rule.RelatedReporter = contextReporter{}
299
+ )
300
+
244
301
  func toInternalTextEdits(edits []rule.TextEdit) []TextEdit {
245
302
  if len(edits) == 0 {
246
303
  return nil
@@ -76,7 +76,7 @@ func run(args []string) int {
76
76
  // Don't pay contributor-registration cost for the version banner.
77
77
  fmt.Fprintf(os.Stdout, "@ttsc/lint %s\n", Version)
78
78
  return 0
79
- case "check", "fix", "format", "build", "transform", "lsp-command-ids", "lsp-code-action-kinds", "lsp-diagnostics", "lsp-code-actions", "lsp-execute-command":
79
+ case "check", "fix", "format", "build", "transform", "lsp-command-ids", "lsp-code-action-kinds", "lsp-diagnostics", "lsp-code-actions", "lsp-execute-command", "lsp-hints", "lsp-serve":
80
80
  default:
81
81
  fmt.Fprintf(os.Stderr, "@ttsc/lint: unknown command %q\n", args[0])
82
82
  return 2
@@ -105,6 +105,10 @@ func run(args []string) int {
105
105
  return RunLSPCodeActions(args[1:])
106
106
  case "lsp-execute-command":
107
107
  return RunLSPExecuteCommand(args[1:])
108
+ case "lsp-hints":
109
+ return RunLSPHints(args[1:])
110
+ case "lsp-serve":
111
+ return RunLSPServe(os.Stdin, os.Stdout, args[1:])
108
112
  }
109
113
  return 2
110
114
  }
@@ -1,25 +1,72 @@
1
1
  package linthost
2
2
 
3
- import "unicode"
3
+ import (
4
+ "regexp"
5
+ "strings"
6
+ "sync"
7
+ "unicode/utf16"
8
+ )
9
+
10
+ //go:generate node ../tools/widthgen/main.cjs
4
11
 
5
12
  // displayWidth returns the number of terminal columns a string occupies,
6
- // matching Prettier's getStringWidth: an ASCII string's width equals its byte
7
- // length, a zero-width combining mark counts as zero, and a wide East Asian
8
- // (or emoji) code point counts as two. Width decisions across the formatter,
9
- // the layout engine's fit checks and the print-width rule's source measurement,
10
- // must use display columns rather than byte length, or a multi-byte token (a
13
+ // matching Prettier's getStringWidth. Width decisions across the formatter
14
+ // the layout engine's fit checks and the print-width rule's source measurement
15
+ // must use display columns rather than byte length, or a multi-byte token (a
11
16
  // subscript digit, a Hangul or CJK identifier) is over-counted and the line is
12
17
  // wrongly broken.
13
18
  //
14
- // A tab counts as one column here; callers that need tab-stop expansion handle
15
- // it separately (the layout engine emits indentation outside of Text nodes, so
16
- // an embedded tab is rare and was already charged as one byte before this).
19
+ // This is a transcription of `prettier/src/utilities/get-string-width.js`, not
20
+ // an implementation of the same idea. That distinction is the whole history of
21
+ // this function: it began as a hand-written 13-range table that diverged on
22
+ // 11,482 code points, and a first repair ported `string-width` — the package
23
+ // the report named — which Prettier does not use, and which regressed
24
+ // Devanagari, the Hangul fillers, the bidi controls, and 98 astral
25
+ // text-presentation emoji before it was reverted. The tables and the emoji
26
+ // pattern are generated from the module the lockfile pins, so there is no
27
+ // second source that can be right about a different Prettier.
28
+ //
29
+ // The procedure, in Prettier's order:
30
+ //
31
+ // 1. An empty string is zero, and a pure-ASCII string is its length. The fast
32
+ // path is observable, not merely an optimization: U+007F returns 1 through
33
+ // it although the loop below would skip it.
34
+ // 2. Every RGI emoji match is replaced by one space (for the ~65 Prettier
35
+ // charges narrow) or two, so a sequence costs its own width and its code
36
+ // points are never counted individually.
37
+ // 3. What remains is counted per code point, skipping C0/C1 controls, the
38
+ // combining diacriticals U+0300-U+036F, and the variation selectors
39
+ // U+FE00-U+FE0F, charging 2 for East Asian Wide or Fullwidth and 1
40
+ // otherwise. Per CODE POINT, not per grapheme cluster: a combining mark
41
+ // outside that one block is charged, which is why `हिंदी` is five columns.
42
+ //
43
+ // One deviation is deliberate and predates all of this: a tab counts as one
44
+ // column here, where Prettier's loop skips it as a control. Callers that need
45
+ // tab-stop expansion handle it separately, and the layout engine emits
46
+ // indentation outside the text it measures.
17
47
  func displayWidth(s string) int {
18
- w := 0
19
- for _, r := range s {
20
- w += runeWidth(r)
48
+ if s == "" {
49
+ return 0
50
+ }
51
+ if !hasNonASCII(s) {
52
+ return len(s)
21
53
  }
22
- return w
54
+ width := 0
55
+ for _, r := range replacePrettierEmoji(s) {
56
+ switch {
57
+ case r == '\t':
58
+ // The documented deviation, kept so tab-stop expansion stays the caller's.
59
+ width++
60
+ case r <= 0x1F, r >= 0x7F && r <= 0x9F:
61
+ case r >= 0x0300 && r <= 0x036F:
62
+ case r >= 0xFE00 && r <= 0xFE0F:
63
+ case isPrettierWide(r):
64
+ width += 2
65
+ default:
66
+ width++
67
+ }
68
+ }
69
+ return width
23
70
  }
24
71
 
25
72
  // displayWidthAfterLastNewline returns the display width of the substring that
@@ -35,38 +82,129 @@ func displayWidthAfterLastNewline(s string) int {
35
82
  return displayWidth(s)
36
83
  }
37
84
 
38
- // runeWidth returns the column width of a single rune: zero for a combining
39
- // mark or zero-width space, two for a wide East Asian or emoji code point, one
40
- // otherwise (tabs included, see displayWidth).
41
- func runeWidth(r rune) int {
42
- if r == 0x200B || unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r) {
43
- return 0
85
+ // displayWidthFromColumn returns the display width of s laid out starting at
86
+ // column `start`, expanding a tab to the next multiple of tabWidth. It is the
87
+ // tab-aware form the source-measuring rules need, where a literal tab in the
88
+ // file does advance to a tab stop; displayWidth itself charges a tab one column
89
+ // because the layout engine emits indentation outside the text it measures.
90
+ func displayWidthFromColumn(s string, tabWidth int, start int) int {
91
+ if tabWidth <= 0 {
92
+ tabWidth = 2
44
93
  }
45
- if isWideRune(r) {
46
- return 2
94
+ // Walked one tab at a time rather than by splitting, so each tab advances
95
+ // from the column the text before it actually reached.
96
+ column := start
97
+ rest := s
98
+ for {
99
+ index := strings.IndexByte(rest, '\t')
100
+ if index < 0 {
101
+ column += displayWidth(rest)
102
+ break
103
+ }
104
+ column += displayWidth(rest[:index])
105
+ column += tabWidth - (column % tabWidth)
106
+ rest = rest[index+1:]
47
107
  }
48
- return 1
108
+ return column - start
49
109
  }
50
110
 
51
- // isWideRune reports whether a rune renders in two terminal columns, covering
52
- // the East Asian Wide / Fullwidth ranges and the common emoji blocks the way
53
- // Prettier's width table does.
54
- func isWideRune(r rune) bool {
55
- switch {
56
- case r >= 0x1100 && r <= 0x115F, // Hangul Jamo
57
- r >= 0x2E80 && r <= 0x303E, // CJK Radicals .. CJK Symbols (part)
58
- r >= 0x3041 && r <= 0x33FF, // Hiragana .. CJK Compatibility
59
- r >= 0x3400 && r <= 0x4DBF, // CJK Extension A
60
- r >= 0x4E00 && r <= 0x9FFF, // CJK Unified Ideographs
61
- r >= 0xA000 && r <= 0xA4CF, // Yi
62
- r >= 0xAC00 && r <= 0xD7A3, // Hangul Syllables
63
- r >= 0xF900 && r <= 0xFAFF, // CJK Compatibility Ideographs
64
- r >= 0xFE30 && r <= 0xFE4F, // CJK Compatibility Forms
65
- r >= 0xFF00 && r <= 0xFF60, // Fullwidth Forms
66
- r >= 0xFFE0 && r <= 0xFFE6, // Fullwidth signs
67
- r >= 0x1F300 && r <= 0x1FAFF, // emoji & pictographs
68
- r >= 0x20000 && r <= 0x3FFFD: // CJK Extension B and beyond
69
- return true
111
+ // hasNonASCII reports whether s holds a byte outside Prettier's printable-ASCII
112
+ // fast-path range, matching its `/[^\x20-\x7F]/u` test. A multi-byte rune's
113
+ // lead byte is >= 0x80, so a byte scan answers the same question.
114
+ func hasNonASCII(s string) bool {
115
+ for i := 0; i < len(s); i++ {
116
+ if s[i] < 0x20 || s[i] > 0x7F {
117
+ return true
118
+ }
119
+ }
120
+ return false
121
+ }
122
+
123
+ var prettierEmojiRegexp = sync.OnceValue(func() *regexp.Regexp {
124
+ return regexp.MustCompile(prettierEmojiPattern)
125
+ })
126
+
127
+ // replacePrettierEmoji performs Prettier's emoji substitution: every RGI emoji
128
+ // becomes one space when Prettier charges it one column, two spaces otherwise.
129
+ //
130
+ // The work happens in the shifted UTF-16 domain the generated pattern is
131
+ // written against, because emoji-regex matches code units and a Go string
132
+ // cannot hold a lone surrogate. Text goes in as runes, becomes units, each
133
+ // surrogate unit moves into a private-use range, the match runs, and the result
134
+ // comes back. The mapping is a bijection over a range nothing else occupies.
135
+ func replacePrettierEmoji(s string) string {
136
+ shifted := shiftToUTF16Domain(s)
137
+ replaced := prettierEmojiRegexp().ReplaceAllStringFunc(shifted, func(match string) string {
138
+ if isNarrowPrettierEmoji(match) {
139
+ return " "
140
+ }
141
+ return " "
142
+ })
143
+ if replaced == shifted {
144
+ return s
145
+ }
146
+ return unshiftFromUTF16Domain(replaced)
147
+ }
148
+
149
+ // isNarrowPrettierEmoji reports whether a match is one of the emoji Prettier
150
+ // charges a single column. The set is single code points, so a match of any
151
+ // other length is never narrow.
152
+ func isNarrowPrettierEmoji(match string) bool {
153
+ runes := []rune(unshiftFromUTF16Domain(match))
154
+ return len(runes) == 1 && inUnicodeRanges(prettierNarrowEmojiRanges[:], runes[0])
155
+ }
156
+
157
+ // shiftToUTF16Domain encodes s as UTF-16 and represents each code unit as one
158
+ // rune, relocating surrogates so the result is a valid Go string.
159
+ func shiftToUTF16Domain(s string) string {
160
+ units := utf16.Encode([]rune(s))
161
+ out := make([]rune, 0, len(units))
162
+ for _, unit := range units {
163
+ r := rune(unit)
164
+ if r >= 0xD800 && r <= 0xDFFF {
165
+ r = r - 0xD800 + surrogateShift
166
+ }
167
+ out = append(out, r)
168
+ }
169
+ return string(out)
170
+ }
171
+
172
+ // unshiftFromUTF16Domain is shiftToUTF16Domain's inverse.
173
+ func unshiftFromUTF16Domain(s string) string {
174
+ units := make([]uint16, 0, len(s))
175
+ for _, r := range s {
176
+ if r >= surrogateShift && r <= surrogateShift+0x7FF {
177
+ r = r - surrogateShift + 0xD800
178
+ }
179
+ if r > 0xFFFF {
180
+ units = append(units, utf16.Encode([]rune{r})...)
181
+ continue
182
+ }
183
+ units = append(units, uint16(r))
184
+ }
185
+ return string(utf16.Decode(units))
186
+ }
187
+
188
+ func isPrettierWide(r rune) bool {
189
+ return inUnicodeRanges(prettierWideRanges[:], r) ||
190
+ inUnicodeRanges(prettierFullWidthRanges[:], r)
191
+ }
192
+
193
+ // inUnicodeRanges reports whether r falls in one of the sorted, disjoint
194
+ // ranges of a generated property table.
195
+ func inUnicodeRanges(ranges []unicodeRange, r rune) bool {
196
+ lo, hi := 0, len(ranges)
197
+ for lo < hi {
198
+ middle := int(uint(lo+hi) >> 1)
199
+ candidate := ranges[middle]
200
+ switch {
201
+ case r < candidate.lo:
202
+ hi = middle
203
+ case r > candidate.hi:
204
+ lo = middle + 1
205
+ default:
206
+ return true
207
+ }
70
208
  }
71
209
  return false
72
210
  }
@@ -107,6 +107,17 @@ func ruleNeedsTypeChecker(r Rule) bool {
107
107
  return ok && tr.NeedsTypeChecker()
108
108
  }
109
109
 
110
+ // ruleDiagnosticTags returns the diagnostic tags a rule classifies its findings
111
+ // with, or nil when the rule implements no marker or returns none. Read once per
112
+ // (file, rule) at dispatch and copied onto every finding the rule produces.
113
+ func ruleDiagnosticTags(r Rule) []publicrule.DiagnosticTag {
114
+ tagged, ok := r.(publicrule.TaggedRule)
115
+ if !ok {
116
+ return nil
117
+ }
118
+ return tagged.DiagnosticTags()
119
+ }
120
+
110
121
  func ruleAcceptsOptions(r Rule) bool {
111
122
  acceptor, ok := r.(ruleOptionsAcceptor)
112
123
  return ok && acceptor.AcceptsTtscLintOptions()
@@ -139,6 +150,7 @@ type Context struct {
139
150
 
140
151
  rule Rule
141
152
  isFormat bool
153
+ tags []publicrule.DiagnosticTag
142
154
  quarantined bool
143
155
  collect func(*Finding)
144
156
  projectResults publicrule.ProjectResultReader
@@ -216,6 +228,15 @@ type Finding struct {
216
228
  Fix []TextEdit
217
229
  Suggestions []Suggestion
218
230
  IsFormat bool
231
+ // Tags classify what the finding is (unnecessary, deprecated), for an editor
232
+ // to render it distinctively. Populated from the rule's TaggedRule marker at
233
+ // dispatch, so every finding a tagged rule produces carries its tags.
234
+ Tags []publicrule.DiagnosticTag
235
+ // RelatedInformation are secondary locations this finding points at, each with
236
+ // a message. Positions are byte offsets into File — already normalized against
237
+ // it at report time — so the LSP renderer resolves them against File's text
238
+ // and attaches File's own URI.
239
+ RelatedInformation []publicrule.RelatedInformation
219
240
 
220
241
  engineFailure bool
221
242
  }
@@ -262,6 +283,7 @@ func (c *Context) ReportFix(node *shimast.Node, message string, edits ...TextEdi
262
283
  Message: message,
263
284
  Fix: cloneTextEdits(edits),
264
285
  IsFormat: c.isFormat,
286
+ Tags: c.tags,
265
287
  })
266
288
  }
267
289
 
@@ -282,6 +304,7 @@ func (c *Context) ReportSuggestion(node *shimast.Node, message string, title str
282
304
  Message: message,
283
305
  Suggestions: newSuggestions(title, edits),
284
306
  IsFormat: c.isFormat,
307
+ Tags: c.tags,
285
308
  })
286
309
  }
287
310
 
@@ -309,6 +332,7 @@ func (c *Context) ReportFixSuggestions(
309
332
  Fix: cloneTextEdits(fix),
310
333
  Suggestions: cloneSuggestions(suggestions),
311
334
  IsFormat: c.isFormat,
335
+ Tags: c.tags,
312
336
  })
313
337
  }
314
338
 
@@ -348,6 +372,7 @@ func (c *Context) ReportRangeFix(pos, end int, message string, edits ...TextEdit
348
372
  Message: message,
349
373
  Fix: cloneTextEdits(edits),
350
374
  IsFormat: c.isFormat,
375
+ Tags: c.tags,
351
376
  })
352
377
  }
353
378
 
@@ -368,9 +393,95 @@ func (c *Context) ReportRangeSuggestion(pos, end int, message string, title stri
368
393
  Message: message,
369
394
  Suggestions: newSuggestions(title, edits),
370
395
  IsFormat: c.isFormat,
396
+ Tags: c.tags,
397
+ })
398
+ }
399
+
400
+ // ReportRangeSuggestions records a finding at an explicit range with several
401
+ // candidate suggestions. It is the range counterpart of ReportFixSuggestions,
402
+ // added so the public contributor surface can offer a choice at a sub-token
403
+ // range and not only at a whole node.
404
+ func (c *Context) ReportRangeSuggestions(pos, end int, message string, suggestions ...Suggestion) {
405
+ if c.Severity == SeverityOff || c.File == nil {
406
+ return
407
+ }
408
+ pos, end = shimdw.NormalizeLintRange(c.File, pos, end)
409
+ c.collect(&Finding{
410
+ Rule: c.rule.Name(),
411
+ Severity: c.Severity,
412
+ File: c.File,
413
+ Pos: pos,
414
+ End: end,
415
+ Message: message,
416
+ Suggestions: cloneSuggestions(suggestions),
417
+ IsFormat: c.isFormat,
418
+ Tags: c.tags,
419
+ })
420
+ }
421
+
422
+ // ReportRelated records a node-scoped finding with related source locations.
423
+ // Each related location's Pos/End is normalized against the current file — the
424
+ // same bounding a range finding gets — so a rule that miscomputed an offset
425
+ // cannot point the editor outside the file.
426
+ func (c *Context) ReportRelated(node *shimast.Node, message string, related ...publicrule.RelatedInformation) {
427
+ if c.Severity == SeverityOff || node == nil {
428
+ return
429
+ }
430
+ pos, end := c.nodeFindingRange(node)
431
+ c.collect(&Finding{
432
+ Rule: c.rule.Name(),
433
+ Severity: c.Severity,
434
+ File: c.File,
435
+ Pos: pos,
436
+ End: end,
437
+ Message: message,
438
+ RelatedInformation: c.normalizeRelated(related),
439
+ IsFormat: c.isFormat,
440
+ Tags: c.tags,
441
+ })
442
+ }
443
+
444
+ // ReportRangeRelated records an explicit-range finding with related source
445
+ // locations. Both the primary range and each related range are normalized
446
+ // against the current file.
447
+ func (c *Context) ReportRangeRelated(pos, end int, message string, related ...publicrule.RelatedInformation) {
448
+ if c.Severity == SeverityOff || c.File == nil {
449
+ return
450
+ }
451
+ pos, end = shimdw.NormalizeLintRange(c.File, pos, end)
452
+ c.collect(&Finding{
453
+ Rule: c.rule.Name(),
454
+ Severity: c.Severity,
455
+ File: c.File,
456
+ Pos: pos,
457
+ End: end,
458
+ Message: message,
459
+ RelatedInformation: c.normalizeRelated(related),
460
+ IsFormat: c.isFormat,
461
+ Tags: c.tags,
371
462
  })
372
463
  }
373
464
 
465
+ // normalizeRelated copies the caller's related locations and bounds each range
466
+ // to the current file, so the stored Finding owns its slice and every position
467
+ // is already safe for the renderer. Returns nil for an empty input, keeping the
468
+ // Finding field nil rather than a zero-length slice.
469
+ func (c *Context) normalizeRelated(related []publicrule.RelatedInformation) []publicrule.RelatedInformation {
470
+ if len(related) == 0 {
471
+ return nil
472
+ }
473
+ out := make([]publicrule.RelatedInformation, 0, len(related))
474
+ for _, item := range related {
475
+ pos, end := shimdw.NormalizeLintRange(c.File, item.Pos, item.End)
476
+ out = append(out, publicrule.RelatedInformation{
477
+ Pos: pos,
478
+ End: end,
479
+ Message: item.Message,
480
+ })
481
+ }
482
+ return out
483
+ }
484
+
374
485
  // cloneTextEdits returns a shallow copy of `edits` so that the caller's
375
486
  // variadic slice cannot be mutated through the stored Finding. Returns nil
376
487
  // when the input is empty, keeping the Finding.Fix field nil rather than
@@ -529,7 +640,11 @@ func NewEngineWithResolver(config RuleResolver) *Engine {
529
640
  fmt.Errorf("@ttsc/lint: invalid options for rule %q: rule does not accept options", name),
530
641
  )
531
642
  }
532
- if setting.Declared && setting.Severity != SeverityOff {
643
+ // A project rule shares the engine-wide checker decision with every file
644
+ // rule, so one that declines the checker must not drag the whole run onto
645
+ // the serial walk. An unmarked rule keeps the conservative default.
646
+ if setting.Declared && setting.Severity != SeverityOff &&
647
+ projectRuleNeedsTypeChecker(name) {
533
648
  eng.needsTypeChecker = true
534
649
  }
535
650
  }
@@ -554,11 +669,11 @@ func NewEngineWithResolver(config RuleResolver) *Engine {
554
669
  }
555
670
  }
556
671
  for _, name := range config.ActiveRuleNames() {
557
- if _, isProjectRule := registeredProjectRules[name]; isProjectRule {
558
- continue
559
- }
560
672
  rule, ok := registered.rules[name]
561
673
  if !ok {
674
+ if _, isProjectRule := registeredProjectRules[name]; isProjectRule {
675
+ continue
676
+ }
562
677
  eng.unknown = append(eng.unknown, name)
563
678
  continue
564
679
  }
@@ -869,6 +984,7 @@ func (e *Engine) runFile(
869
984
  Options: options,
870
985
  rule: rule,
871
986
  isFormat: isFormatRule(rule),
987
+ tags: ruleDiagnosticTags(rule),
872
988
  collect: collect,
873
989
  projectResults: results,
874
990
  fileMemo: memo,
@@ -17,17 +17,17 @@ var LintFlagAllowList = map[string]bool{
17
17
  "cwd": true,
18
18
  "diagnostics": false,
19
19
  "emit": false,
20
- "extendedDiagnostics": false,
20
+ "extendeddiagnostics": false,
21
21
  "file": true,
22
- "noEmit": false,
22
+ "noemit": false,
23
23
  "out": true,
24
- "outDir": true,
24
+ "outdir": true,
25
25
  "p": true,
26
26
  "plugins-json": true,
27
27
  "project": true,
28
28
  "project-context-json": true,
29
29
  "quiet": false,
30
- "singleThreaded": false,
30
+ "singlethreaded": false,
31
31
  "tsconfig": true,
32
32
  "tsgo-args": true,
33
33
  "verbose": false,