@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
@@ -12,6 +12,8 @@ import (
12
12
  "unicode"
13
13
 
14
14
  shimast "github.com/microsoft/typescript-go/shim/ast"
15
+ shimscanner "github.com/microsoft/typescript-go/shim/scanner"
16
+ publicrule "github.com/samchon/ttsc/packages/lint/rule"
15
17
  )
16
18
 
17
19
  type storybookAwaitInteractions struct{}
@@ -89,6 +91,14 @@ func (storybookDefaultExports) Check(ctx *Context, node *shimast.Node) {
89
91
  type storybookHierarchySeparator struct{}
90
92
 
91
93
  func (storybookHierarchySeparator) Name() string { return "storybook/hierarchy-separator" }
94
+
95
+ // DiagnosticTags strikes the separator through: `|` is Storybook's superseded
96
+ // hierarchy separator, and a title that uses it still renders — Storybook
97
+ // keeps reading it — so the finding is exactly "this still works, migrate off
98
+ // it", never "delete this title property".
99
+ func (storybookHierarchySeparator) DiagnosticTags() []publicrule.DiagnosticTag {
100
+ return []publicrule.DiagnosticTag{publicrule.DiagnosticTagDeprecated}
101
+ }
92
102
  func (storybookHierarchySeparator) Visits() []shimast.Kind {
93
103
  return []shimast.Kind{shimast.KindSourceFile}
94
104
  }
@@ -101,7 +111,74 @@ func (storybookHierarchySeparator) Check(ctx *Context, node *shimast.Node) {
101
111
  if !ok || !strings.Contains(storybookLiteralString(value), "|") {
102
112
  return
103
113
  }
104
- ctx.Report(prop, "Deprecated hierarchy separator in title property.")
114
+ const message = "Deprecated hierarchy separator in title property."
115
+ pos, end, located := storybookLiteralPipeRange(ctx.File, value)
116
+ if !located {
117
+ // The decoded title carries the separator, so the finding is real even when
118
+ // the raw spelling that produced it cannot be bounded. Report the property
119
+ // rather than dropping the diagnostic: a wider strikethrough is an
120
+ // imprecision, a missing diagnostic is a rule that stopped working.
121
+ ctx.Report(prop, message)
122
+ return
123
+ }
124
+ ctx.ReportRange(pos, end, message)
125
+ }
126
+
127
+ // storybookLiteralPipeRange returns the raw source range of one `|` that the
128
+ // parser decoded from a title literal. The tagged diagnostic must cover the
129
+ // deprecated separator itself, not the still-live title property around it.
130
+ //
131
+ // A pipe can be written directly or as an active `\\|`, `\\x7C`, `\\u007C`, or
132
+ // `\\u{7C}` escape. Every spelling below evaluates to the same separator, and
133
+ // reporting its complete raw escape keeps the editor range valid for each.
134
+ func storybookLiteralPipeRange(file *shimast.SourceFile, node *shimast.Node) (int, int, bool) {
135
+ pos, end := tokenRange(file, node)
136
+ if pos < 0 {
137
+ return 0, 0, false
138
+ }
139
+ src := file.Text()
140
+ for index := pos; index < end; {
141
+ if src[index] == '|' {
142
+ return index, index + 1, true
143
+ }
144
+ if src[index] != '\\' {
145
+ index++
146
+ continue
147
+ }
148
+ start := index
149
+ for index < end && src[index] == '\\' {
150
+ index++
151
+ }
152
+ if (index-start)%2 == 0 || index >= end {
153
+ continue
154
+ }
155
+ active := index - 1
156
+ switch src[index] {
157
+ case '|':
158
+ return active, index + 1, true
159
+ case 'x':
160
+ if index+2 < end && hexDigit(src[index+1])*16+hexDigit(src[index+2]) == int('|') {
161
+ return active, index + 3, true
162
+ }
163
+ case 'u':
164
+ if index+1 < end && src[index+1] == '{' {
165
+ close := index + 2
166
+ value := 0
167
+ for close < end && hexDigit(src[close]) >= 0 {
168
+ value = value*16 + hexDigit(src[close])
169
+ close++
170
+ }
171
+ if close > index+2 && close < end && src[close] == '}' && value == int('|') {
172
+ return active, close + 1, true
173
+ }
174
+ } else if index+4 < end &&
175
+ hexDigit(src[index+1])*4096+hexDigit(src[index+2])*256+hexDigit(src[index+3])*16+hexDigit(src[index+4]) == int('|') {
176
+ return active, index + 5, true
177
+ }
178
+ }
179
+ index++
180
+ }
181
+ return 0, 0, false
105
182
  }
106
183
 
107
184
  type storybookMetaInlineProperties struct{}
@@ -140,18 +217,34 @@ func (storybookMetaSatisfiesType) Check(ctx *Context, node *shimast.Node) {
140
217
  type storybookNoRedundantStoryName struct{}
141
218
 
142
219
  func (storybookNoRedundantStoryName) Name() string { return "storybook/no-redundant-story-name" }
220
+
221
+ // DiagnosticTags greys the redundant annotation out. Both arms of this rule
222
+ // report an annotation that restates the name Storybook already derives from
223
+ // the export identifier — the object property in one, the standalone
224
+ // `Story.storyName =` assignment in the other — and each is reported at
225
+ // exactly the range whose deletion is the whole resolution.
226
+ func (storybookNoRedundantStoryName) DiagnosticTags() []publicrule.DiagnosticTag {
227
+ return []publicrule.DiagnosticTag{publicrule.DiagnosticTagUnnecessary}
228
+ }
143
229
  func (storybookNoRedundantStoryName) Visits() []shimast.Kind {
144
230
  return []shimast.Kind{shimast.KindSourceFile}
145
231
  }
146
232
  func (storybookNoRedundantStoryName) Check(ctx *Context, node *shimast.Node) {
233
+ namedExports := map[string]bool{}
147
234
  for _, story := range storybookNamedExports(ctx.File) {
235
+ namedExports[story.Name] = true
148
236
  if story.Init == nil || story.Init.Kind != shimast.KindObjectLiteralExpression {
149
237
  continue
150
238
  }
151
239
  for _, propName := range []string{"name", "storyName"} {
152
240
  prop, value, ok := storybookObjectProperty(story.Init, propName)
153
241
  if ok && storybookLiteralString(value) == storybookNameFromExport(story.Name) {
154
- ctx.Report(prop, "Named exports should not use a redundant story name annotation.")
242
+ pos, end, removable := storybookRemovablePropertyRange(ctx.File, prop)
243
+ if removable {
244
+ ctx.ReportRange(pos, end, "Named exports should not use a redundant story name annotation.")
245
+ } else {
246
+ ctx.Report(prop, "Named exports should not use a redundant story name annotation.")
247
+ }
155
248
  }
156
249
  }
157
250
  }
@@ -164,12 +257,47 @@ func (storybookNoRedundantStoryName) Check(ctx *Context, node *shimast.Node) {
164
257
  return
165
258
  }
166
259
  objectName, propName := storybookPropertyAccessParts(expr.Left)
167
- if propName == "storyName" && storybookLiteralString(expr.Right) == storybookNameFromExport(objectName) {
168
- ctx.Report(child, "Named exports should not use a redundant story name annotation.")
260
+ if namedExports[objectName] &&
261
+ propName == "storyName" &&
262
+ storybookLiteralString(expr.Right) == storybookNameFromExport(objectName) {
263
+ if child.Parent == nil ||
264
+ child.Parent.Kind != shimast.KindExpressionStatement ||
265
+ child.Parent.Parent == nil ||
266
+ child.Parent.Parent.Kind != shimast.KindSourceFile {
267
+ return
268
+ }
269
+ // The Unnecessary tag is rule-wide. Only a standalone assignment can be
270
+ // removed as a whole; an assignment nested in another expression still
271
+ // contributes its value, while one in a nested scope can refer to a
272
+ // same-named shadow instead of the exported story.
273
+ ctx.Report(child.Parent, "Named exports should not use a redundant story name annotation.")
169
274
  }
170
275
  })
171
276
  }
172
277
 
278
+ // storybookRemovablePropertyRange returns the complete removable grain of an
279
+ // object property. A PropertyAssignment node stops before its trailing comma;
280
+ // when one is present, include it (and any trivia before it) so deleting
281
+ // exactly the faded `Unnecessary` range leaves valid object syntax.
282
+ //
283
+ // A comment sitting between the property and its comma is therefore faded with
284
+ // them. That is deliberate, not an oversight: the alternative — stopping at the
285
+ // comment and leaving the comma behind — produces `{ /* note */, }`, which does
286
+ // not parse. Only one of the two can hold, and a range whose deletion always
287
+ // leaves valid syntax is the contract the Unnecessary tag makes.
288
+ func storybookRemovablePropertyRange(file *shimast.SourceFile, property *shimast.Node) (int, int, bool) {
289
+ pos, end := tokenRange(file, property)
290
+ if pos < 0 {
291
+ return 0, 0, false
292
+ }
293
+ source := file.Text()
294
+ comma := shimscanner.SkipTrivia(source, end)
295
+ if comma < len(source) && source[comma] == ',' {
296
+ end = comma + 1
297
+ }
298
+ return pos, end, true
299
+ }
300
+
173
301
  type storybookNoRendererPackages struct{}
174
302
 
175
303
  func (storybookNoRendererPackages) Name() string { return "storybook/no-renderer-packages" }
@@ -181,27 +309,106 @@ func (storybookNoRendererPackages) Check(ctx *Context, node *shimast.Node) {
181
309
  if decl == nil {
182
310
  return
183
311
  }
184
- if _, ok := storybookRendererPackages[storybookLiteralString(decl.ModuleSpecifier)]; ok {
185
- ctx.Report(node, "Do not import Storybook renderer packages directly. Use a framework package instead.")
312
+ frameworks, ok := storybookRendererPackages[storybookLiteralString(decl.ModuleSpecifier)]
313
+ if !ok || len(frameworks) == 0 {
314
+ return
315
+ }
316
+ message := "Do not import Storybook renderer packages directly. Use a framework package instead: " +
317
+ storybookDisjunction(frameworks) + "."
318
+ pos, end, quoted := storybookQuotedTextRange(ctx.File, decl.ModuleSpecifier)
319
+ if !quoted {
320
+ ctx.Report(node, message)
321
+ return
322
+ }
323
+ suggestions := make([]Suggestion, 0, len(frameworks))
324
+ for _, framework := range frameworks {
325
+ suggestions = append(suggestions, Suggestion{
326
+ Title: "Import from `" + framework + "`.",
327
+ Edits: []TextEdit{{Pos: pos, End: end, Text: framework}},
328
+ })
329
+ }
330
+ ctx.ReportFixSuggestions(node, message, nil, suggestions...)
331
+ }
332
+
333
+ // storybookDisjunction renders a package list the way upstream's
334
+ // `Intl.ListFormat("en-US", {type: "disjunction"})` does: "a", "a or b",
335
+ // "a, b, or c".
336
+ func storybookDisjunction(items []string) string {
337
+ quoted := make([]string, 0, len(items))
338
+ for _, item := range items {
339
+ quoted = append(quoted, "`"+item+"`")
340
+ }
341
+ switch len(quoted) {
342
+ case 0:
343
+ return ""
344
+ case 1:
345
+ return quoted[0]
346
+ case 2:
347
+ return quoted[0] + " or " + quoted[1]
348
+ default:
349
+ return strings.Join(quoted[:len(quoted)-1], ", ") + ", or " + quoted[len(quoted)-1]
186
350
  }
187
351
  }
188
352
 
353
+ // storybookQuotedTextRange returns the byte range of the text inside a string
354
+ // literal's quotes, so a rewrite replaces the contents and leaves the
355
+ // surrounding quote characters exactly as the author wrote them. Reports false
356
+ // for anything that is not a plainly quoted literal, such as a
357
+ // parse-recovered node with no closing quote.
358
+ func storybookQuotedTextRange(file *shimast.SourceFile, node *shimast.Node) (int, int, bool) {
359
+ pos, end := tokenRange(file, node)
360
+ if pos < 0 || end-pos < 2 {
361
+ return 0, 0, false
362
+ }
363
+ src := file.Text()
364
+ quote := src[pos]
365
+ if quote != '"' && quote != '\'' {
366
+ return 0, 0, false
367
+ }
368
+ if src[end-1] != quote {
369
+ return 0, 0, false
370
+ }
371
+ return pos + 1, end - 1, true
372
+ }
373
+
189
374
  type storybookNoStoriesOf struct{}
190
375
 
191
376
  func (storybookNoStoriesOf) Name() string { return "storybook/no-stories-of" }
377
+
378
+ // DiagnosticTags strikes the `storiesOf` import specifier through. The builder
379
+ // API is deprecated in favor of CSF but still runs, and the whole reported
380
+ // range is the specifier that names it, so migration — not deletion — is the
381
+ // instruction the editor should render.
382
+ func (storybookNoStoriesOf) DiagnosticTags() []publicrule.DiagnosticTag {
383
+ return []publicrule.DiagnosticTag{publicrule.DiagnosticTagDeprecated}
384
+ }
192
385
  func (storybookNoStoriesOf) Visits() []shimast.Kind {
193
386
  return []shimast.Kind{shimast.KindImportSpecifier}
194
387
  }
195
388
  func (storybookNoStoriesOf) Check(ctx *Context, node *shimast.Node) {
196
389
  spec := node.AsImportSpecifier()
197
- if spec != nil && storybookImportSpecifierImportedName(spec) == "storiesOf" {
198
- ctx.Report(node, "storiesOf is deprecated and should not be used.")
390
+ if spec != nil &&
391
+ storybookImportSpecifierImportedName(spec) == "storiesOf" &&
392
+ isStorybookStoriesOfModule(storybookImportSpecifierModule(node)) {
393
+ reported := spec.Name()
394
+ if spec.PropertyName != nil {
395
+ reported = spec.PropertyName
396
+ }
397
+ ctx.Report(reported, "storiesOf is deprecated and should not be used.")
199
398
  }
200
399
  }
201
400
 
202
401
  type storybookNoTitlePropertyInMeta struct{}
203
402
 
204
403
  func (storybookNoTitlePropertyInMeta) Name() string { return "storybook/no-title-property-in-meta" }
404
+
405
+ // DiagnosticTags greys the `title` property out. CSF3 derives a story's title
406
+ // from the file's location on disk, so the property is dead weight rather than
407
+ // a wrong value, and the reported range is the property itself: removing
408
+ // exactly what the editor fades is the resolution.
409
+ func (storybookNoTitlePropertyInMeta) DiagnosticTags() []publicrule.DiagnosticTag {
410
+ return []publicrule.DiagnosticTag{publicrule.DiagnosticTagUnnecessary}
411
+ }
205
412
  func (storybookNoTitlePropertyInMeta) Visits() []shimast.Kind {
206
413
  return []shimast.Kind{shimast.KindSourceFile}
207
414
  }
@@ -211,7 +418,12 @@ func (storybookNoTitlePropertyInMeta) Check(ctx *Context, node *shimast.Node) {
211
418
  return
212
419
  }
213
420
  if prop, _, ok := storybookObjectProperty(meta.Object, "title"); ok {
214
- ctx.Report(prop, "CSF3 does not need a title in meta.")
421
+ pos, end, removable := storybookRemovablePropertyRange(ctx.File, prop)
422
+ if removable {
423
+ ctx.ReportRange(pos, end, "CSF3 does not need a title in meta.")
424
+ } else {
425
+ ctx.Report(prop, "CSF3 does not need a title in meta.")
426
+ }
215
427
  }
216
428
  }
217
429
 
@@ -370,6 +582,16 @@ var storybookRendererPackages = map[string][]string{
370
582
  "@storybook/web-components": {"@storybook/web-components-vite", "@storybook/web-components-webpack5"},
371
583
  }
372
584
 
585
+ var storybookStoriesOfModules = []string{
586
+ "@storybook/react",
587
+ "@storybook/vue",
588
+ "@storybook/vue3",
589
+ "@storybook/angular",
590
+ "@storybook/svelte",
591
+ "@storybook/html",
592
+ "@storybook/web-components",
593
+ }
594
+
373
595
  func storybookDefaultMeta(file *shimast.SourceFile) *storybookMetaInfo {
374
596
  if file == nil || file.Statements == nil {
375
597
  return nil
@@ -756,7 +978,30 @@ func storybookDescriptorsMatch(descriptors []storybookDescriptor, name string) b
756
978
  }
757
979
 
758
980
  func storybookHasStoriesOfImport(file *shimast.SourceFile) bool {
759
- return storybookHasNamedImport(file, "storiesOf", "@storybook/react", "@storybook/vue", "@storybook/vue3", "@storybook/angular", "@storybook/svelte", "@storybook/html", "@storybook/web-components")
981
+ return storybookHasNamedImport(file, "storiesOf", storybookStoriesOfModules...)
982
+ }
983
+
984
+ func isStorybookStoriesOfModule(module string) bool {
985
+ for _, candidate := range storybookStoriesOfModules {
986
+ if module == candidate {
987
+ return true
988
+ }
989
+ }
990
+ return false
991
+ }
992
+
993
+ func storybookImportSpecifierModule(node *shimast.Node) string {
994
+ for current := node; current != nil; current = current.Parent {
995
+ if current.Kind != shimast.KindImportDeclaration {
996
+ continue
997
+ }
998
+ decl := current.AsImportDeclaration()
999
+ if decl == nil {
1000
+ return ""
1001
+ }
1002
+ return storybookLiteralString(decl.ModuleSpecifier)
1003
+ }
1004
+ return ""
760
1005
  }
761
1006
 
762
1007
  func storybookHasNamedImport(file *shimast.SourceFile, name string, modules ...string) bool {
@@ -185,11 +185,20 @@ func (noExtraBind) Check(ctx *Context, node *shimast.Node) {
185
185
  return
186
186
  }
187
187
 
188
- edits := noExtraBindFixEdits(ctx, node, call, member, receiver)
189
- if len(edits) == 0 {
190
- ctx.Report(node, "The function binding is unnecessary.")
191
- } else {
192
- ctx.ReportFix(node, "The function binding is unnecessary.", edits...)
188
+ message := "The function binding is unnecessary."
189
+ edits, imposable := noExtraBindFixEdits(ctx, node, call, member, receiver)
190
+ switch {
191
+ case len(edits) == 0:
192
+ ctx.Report(node, message)
193
+ case imposable:
194
+ ctx.ReportFix(node, message, edits...)
195
+ default:
196
+ ctx.ReportSuggestion(
197
+ node,
198
+ message,
199
+ "Remove the unnecessary binding, discarding the comment inside it.",
200
+ edits...,
201
+ )
193
202
  }
194
203
  }
195
204
 
@@ -296,22 +305,28 @@ func noExtraBindThisBelongsToFunction(node, root *shimast.Node) bool {
296
305
 
297
306
  // noExtraBindFixEdits removes the member access and its one-argument call as
298
307
  // separate ranges. Parentheses and comments before the member operator remain
299
- // byte-for-byte intact. The fix is withheld when evaluating the receiver may
300
- // have effects or when any comment lies inside discarded syntax.
308
+ // byte-for-byte intact.
309
+ //
310
+ // No edit exists at all when the removal would change what the program does —
311
+ // evaluating the receiver may have effects — or when the ranges are degenerate;
312
+ // both return `(nil, false)`. When the edit is well-formed but a comment lies
313
+ // inside the discarded syntax, it returns `(edits, false)`: imposing silent
314
+ // comment loss is unacceptable, yet the caller can still offer the removal as
315
+ // an opt-in suggestion. Only `(edits, true)` may be applied automatically.
301
316
  func noExtraBindFixEdits(
302
317
  ctx *Context,
303
318
  callNode *shimast.Node,
304
319
  call *shimast.CallExpression,
305
320
  member *shimast.Node,
306
321
  receiver *shimast.Node,
307
- ) []TextEdit {
322
+ ) (edits []TextEdit, imposable bool) {
308
323
  if ctx == nil || ctx.File == nil || callNode == nil || call == nil || call.Expression == nil || member == nil ||
309
324
  !noExtraBindReceiverIsSideEffectFree(receiver) {
310
- return nil
325
+ return nil, false
311
326
  }
312
327
  parts, ok := referenceMemberParts(member)
313
328
  if !ok || parts.object == nil {
314
- return nil
329
+ return nil, false
315
330
  }
316
331
 
317
332
  src := ctx.File.Text()
@@ -320,15 +335,12 @@ func noExtraBindFixEdits(
320
335
  callStart := shimscanner.SkipTrivia(src, call.Expression.End())
321
336
  callEnd := callNode.End()
322
337
  if memberStart < 0 || memberStart >= memberEnd || memberEnd > callStart || callStart >= callEnd || callEnd > len(src) {
323
- return nil
324
- }
325
- if hasCommentBetween(src, memberStart, callEnd) {
326
- return nil
338
+ return nil, false
327
339
  }
328
340
  return []TextEdit{
329
341
  {Pos: memberStart, End: memberEnd, Text: ""},
330
342
  {Pos: callStart, End: callEnd, Text: ""},
331
- }
343
+ }, !hasCommentBetween(src, memberStart, callEnd)
332
344
  }
333
345
 
334
346
  func noExtraBindReceiverIsSideEffectFree(node *shimast.Node) bool {
@@ -1312,19 +1324,22 @@ func reportUselessRenameFix(ctx *Context, node, propertyName, name *shimast.Node
1312
1324
  ctx.Report(node, message)
1313
1325
  return
1314
1326
  }
1327
+ edit := TextEdit{Pos: propertyName.End(), End: name.End(), Text: ""}
1315
1328
  // A comment between the two names (`{ a as /* c */ a }`) sits inside the
1316
- // deleted rename tail; dropping it would be silent data loss, so decline to
1317
- // a report-only diagnostic. Mirrors ESLint no-useless-rename's
1318
- // `commentsExistBetween` guard.
1329
+ // deleted rename tail; imposing that loss is unacceptable, so the autofix
1330
+ // declines. Mirrors ESLint no-useless-rename's `commentsExistBetween` guard.
1331
+ // The collapse is still what the author wants, so the same edit is offered
1332
+ // as an opt-in suggestion whose title states that the comment goes with it.
1319
1333
  if hasCommentBetween(ctx.File.Text(), propertyName.End(), name.End()) {
1320
- ctx.Report(node, message)
1334
+ ctx.ReportSuggestion(
1335
+ node,
1336
+ message,
1337
+ "Remove the redundant rename, discarding the comment inside it.",
1338
+ edit,
1339
+ )
1321
1340
  return
1322
1341
  }
1323
- ctx.ReportFix(
1324
- node,
1325
- message,
1326
- TextEdit{Pos: propertyName.End(), End: name.End(), Text: ""},
1327
- )
1342
+ ctx.ReportFix(node, message, edit)
1328
1343
  }
1329
1344
 
1330
1345
  // objectShorthand: `{ x: x }` → `{ x }`.
@@ -1351,17 +1366,20 @@ func (objectShorthand) Check(ctx *Context, node *shimast.Node) {
1351
1366
  // that range.
1352
1367
  //
1353
1368
  // A comment inside that range (`{ x: /* c */ x }`) would be dropped by the
1354
- // deletion, so decline to a report-only diagnostic. Mirrors ESLint
1355
- // object-shorthand's `commentsExistBetween` guard.
1369
+ // deletion, so the autofix declines. Mirrors ESLint object-shorthand's
1370
+ // `commentsExistBetween` guard. The shorthand rewrite is still correct, so
1371
+ // the same edit is offered as an opt-in suggestion that names the loss.
1372
+ edit := TextEdit{Pos: prop.Name().End(), End: prop.Initializer.End(), Text: ""}
1356
1373
  if hasCommentBetween(ctx.File.Text(), prop.Name().End(), prop.Initializer.End()) {
1357
- ctx.Report(node, "Expected property shorthand.")
1374
+ ctx.ReportSuggestion(
1375
+ node,
1376
+ "Expected property shorthand.",
1377
+ "Use property shorthand, discarding the comment before the value.",
1378
+ edit,
1379
+ )
1358
1380
  return
1359
1381
  }
1360
- ctx.ReportFix(
1361
- node,
1362
- "Expected property shorthand.",
1363
- TextEdit{Pos: prop.Name().End(), End: prop.Initializer.End(), Text: ""},
1364
- )
1382
+ ctx.ReportFix(node, "Expected property shorthand.", edit)
1365
1383
  }
1366
1384
 
1367
1385
  // operatorAssignment: `x = x + 1` → `x += 1`.
@@ -1494,21 +1512,24 @@ func (preferTemplate) Check(ctx *Context, node *shimast.Node) {
1494
1512
  ctx.Report(node, message)
1495
1513
  return
1496
1514
  }
1515
+ edit := TextEdit{Pos: editPos, End: node.End(), Text: template}
1497
1516
  // A comment in an operator seam (`"a" + /* c */ b`) is dropped by the
1498
- // template rebuild, so decline to a report-only diagnostic. Mirrors ESLint
1517
+ // template rebuild, so the autofix declines and the rendered literal is
1518
+ // offered as an opt-in suggestion that names the loss. Mirrors ESLint
1499
1519
  // prefer-template's `commentsExistBetween` guard. Only the seams are scanned:
1500
1520
  // operand interiors are copied verbatim (their comments survive) and string
1501
1521
  // contents are cooked, so scanning the whole span would misread a `//` or
1502
1522
  // `/*` inside a string literal (`"https://" + host`) as a comment.
1503
1523
  if concatOperandSeamsHaveComment(src, operands) {
1504
- ctx.Report(node, message)
1524
+ ctx.ReportSuggestion(
1525
+ node,
1526
+ message,
1527
+ "Use a template literal, discarding the comments between the operands.",
1528
+ edit,
1529
+ )
1505
1530
  return
1506
1531
  }
1507
- ctx.ReportFix(
1508
- node,
1509
- message,
1510
- TextEdit{Pos: editPos, End: node.End(), Text: template},
1511
- )
1532
+ ctx.ReportFix(node, message, edit)
1512
1533
  }
1513
1534
 
1514
1535
  // concatOperandSeamsHaveComment reports whether a comment sits in any seam
@@ -147,6 +147,13 @@ func (noNamespace) Check(ctx *Context, node *shimast.Node) {
147
147
  if decl.Name().Kind == shimast.KindStringLiteral {
148
148
  return
149
149
  }
150
+ // Skip `declare global { … }`. It is spelled with the `global` keyword
151
+ // rather than `namespace`/`module`, and it is the only way to augment a
152
+ // global interface — ES module syntax has no equivalent, so the advice this
153
+ // rule gives cannot be followed. Upstream exempts it for the same reason.
154
+ if decl.Keyword == shimast.KindGlobalKeyword {
155
+ return
156
+ }
150
157
  ctx.Report(node, "ES2015 module syntax is preferred over namespaces.")
151
158
  }
152
159
 
@@ -6,7 +6,7 @@
6
6
  //
7
7
  // Implemented here:
8
8
  // - typescript/require-await
9
- // (AST-only; async function with no `await` in its body)
9
+ // (type-aware; async function with no `await` that also returns no promise)
10
10
  // - typescript/use-unknown-in-catch-callback-variable
11
11
  // (type-aware; `.catch(err)` / `.then(_, err)` must annotate `unknown`)
12
12
  // - typescript/only-throw-error
@@ -24,13 +24,20 @@ import (
24
24
  // refactor artifact. typescript-eslint:
25
25
  // https://typescript-eslint.io/rules/require-await/
26
26
  //
27
- // AST-only. The walker stops at nested function-like boundaries so an
27
+ // The walker stops at nested function-like boundaries so an
28
28
  // `await` inside an inner non-async closure does not count toward the
29
29
  // outer function. Async generators are exempt — they satisfy the
30
30
  // async-keyword contract through `yield`.
31
31
  type requireAwait struct{}
32
32
 
33
33
  func (requireAwait) Name() string { return "typescript/require-await" }
34
+
35
+ // NeedsTypeChecker: upstream states the rule as "async functions which do not
36
+ // return promises and have no await expression", and that it "uses type
37
+ // information to allow promise-returning functions to be marked as async
38
+ // without containing an await expression". Whether a returned expression is
39
+ // thenable is not visible in the syntax.
40
+ func (requireAwait) NeedsTypeChecker() bool { return true }
34
41
  func (requireAwait) Visits() []shimast.Kind {
35
42
  return []shimast.Kind{
36
43
  shimast.KindFunctionDeclaration,
@@ -53,6 +60,9 @@ func (requireAwait) Check(ctx *Context, node *shimast.Node) {
53
60
  if requireAwaitBodyHasAwait(body) {
54
61
  return
55
62
  }
63
+ if requireAwaitReturnsThenable(ctx, body) {
64
+ return
65
+ }
56
66
  startPos := keywordStart(ctx.File, node, "async")
57
67
  if startPos < 0 {
58
68
  ctx.Report(node, "Async function has no `await` expression.")
@@ -99,6 +109,16 @@ func requireAwaitBodyHasAwait(body *shimast.Node) bool {
99
109
  found = true
100
110
  return
101
111
  }
112
+ // `for await (const x of source)` awaits every step of the iteration. It
113
+ // is spelled as a for-of carrying an await modifier rather than as an
114
+ // AwaitExpression, so a walker that looks only for the expression form
115
+ // reports a function whose sole suspend point is the loop.
116
+ if n.Kind == shimast.KindForOfStatement {
117
+ if stmt := n.AsForInOrOfStatement(); stmt != nil && stmt.AwaitModifier != nil {
118
+ found = true
119
+ return
120
+ }
121
+ }
102
122
  n.ForEachChild(func(child *shimast.Node) bool {
103
123
  walk(child)
104
124
  return false
@@ -266,3 +286,61 @@ func init() {
266
286
  Register(useUnknownInCatchCallbackVariable{})
267
287
  Register(onlyThrowError{})
268
288
  }
289
+
290
+ // requireAwaitReturnsThenable reports whether the function body hands a
291
+ // thenable back to its caller.
292
+ //
293
+ // Upstream exempts these deliberately: `async function f() { return g() }`
294
+ // marks the async contract for callers and forwards the inner promise, which
295
+ // is a use of `async` rather than a leftover from a refactor. The walk stops at
296
+ // nested function-like scopes for the same reason the await walk does — an
297
+ // inner closure's `return` belongs to that closure.
298
+ //
299
+ // A concise arrow body (`async () => g()`) is the returned expression itself
300
+ // rather than a block, and is classified directly.
301
+ func requireAwaitReturnsThenable(ctx *Context, body *shimast.Node) bool {
302
+ if ctx == nil || ctx.Checker == nil || body == nil {
303
+ return false
304
+ }
305
+ if body.Kind != shimast.KindBlock {
306
+ return requireAwaitExpressionIsThenable(ctx, body)
307
+ }
308
+ thenable := false
309
+ var walk func(*shimast.Node)
310
+ walk = func(n *shimast.Node) {
311
+ if thenable || n == nil {
312
+ return
313
+ }
314
+ if n != body && isFunctionLikeKind(n) {
315
+ return
316
+ }
317
+ if n.Kind == shimast.KindReturnStatement {
318
+ if stmt := n.AsReturnStatement(); stmt != nil && requireAwaitExpressionIsThenable(ctx, stmt.Expression) {
319
+ thenable = true
320
+ return
321
+ }
322
+ }
323
+ n.ForEachChild(func(child *shimast.Node) bool {
324
+ walk(child)
325
+ return false
326
+ })
327
+ }
328
+ walk(body)
329
+ return thenable
330
+ }
331
+
332
+ // requireAwaitExpressionIsThenable classifies one returned expression with the
333
+ // same awaitability test `await-thenable` uses, so the two rules agree on what
334
+ // counts as a promise. Anything the classifier does not rule out entirely is
335
+ // treated as thenable: the exemption must not depend on proving a union is
336
+ // thenable in every branch.
337
+ func requireAwaitExpressionIsThenable(ctx *Context, expr *shimast.Node) bool {
338
+ if expr == nil {
339
+ return false
340
+ }
341
+ t := ctx.Checker.GetTypeAtLocation(expr)
342
+ if t == nil {
343
+ return false
344
+ }
345
+ return classifyPromiseAwaitability(ctx.Checker, expr, t) != promiseAwaitabilityNever
346
+ }