@ttsc/lint 0.12.3 → 0.13.0

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 (74) hide show
  1. package/lib/defaultFormat.d.ts +10 -11
  2. package/lib/defaultFormat.js +10 -11
  3. package/lib/defaultFormat.js.map +1 -1
  4. package/lib/index.js +233 -135
  5. package/lib/index.js.map +1 -1
  6. package/lib/structures/ITtscLintConfig.d.ts +2 -2
  7. package/lib/structures/ITtscLintFormatConfig.d.ts +53 -55
  8. package/lib/structures/ITtscLintPluginConfig.d.ts +10 -77
  9. package/lib/structures/ITtscLintPluginMeta.d.ts +9 -1
  10. package/lib/structures/TtscLintRule.d.ts +10 -1
  11. package/lib/structures/TtscLintRuleMap.d.ts +2 -2
  12. package/lib/structures/TtscLintRuleOptions.d.ts +14 -0
  13. package/linthost/ast_helpers.go +80 -11
  14. package/linthost/compile.go +116 -112
  15. package/linthost/config.go +647 -687
  16. package/linthost/config_format.go +272 -247
  17. package/linthost/contrib_adapter.go +7 -0
  18. package/linthost/directives.go +116 -0
  19. package/linthost/dispatch.go +33 -33
  20. package/linthost/engine.go +156 -43
  21. package/linthost/fix.go +47 -27
  22. package/linthost/flags_gen.go +31 -0
  23. package/linthost/format.go +119 -3
  24. package/linthost/host.go +115 -5
  25. package/linthost/print_dispatch.go +128 -28
  26. package/linthost/print_doc.go +23 -0
  27. package/linthost/print_engine.go +168 -4
  28. package/linthost/print_nodes_array.go +17 -7
  29. package/linthost/print_nodes_call.go +138 -20
  30. package/linthost/print_nodes_function.go +353 -0
  31. package/linthost/print_nodes_imports.go +46 -29
  32. package/linthost/print_nodes_list.go +86 -5
  33. package/linthost/print_nodes_object.go +56 -11
  34. package/linthost/rules_arrays.go +5 -2
  35. package/linthost/rules_debugger.go +3 -2
  36. package/linthost/rules_dupes.go +7 -4
  37. package/linthost/rules_empty.go +3 -2
  38. package/linthost/rules_escape.go +35 -3
  39. package/linthost/rules_eval.go +3 -0
  40. package/linthost/rules_finally.go +11 -0
  41. package/linthost/rules_format_jsdoc.go +7 -0
  42. package/linthost/rules_format_print_width.go +270 -15
  43. package/linthost/rules_format_quotes.go +3 -0
  44. package/linthost/rules_format_sort_imports.go +4 -0
  45. package/linthost/rules_gap.go +75 -3
  46. package/linthost/rules_imports.go +5 -7
  47. package/linthost/rules_logic.go +75 -5
  48. package/linthost/rules_loops.go +4 -0
  49. package/linthost/rules_misc.go +4 -2
  50. package/linthost/rules_params.go +7 -11
  51. package/linthost/rules_problems.go +89 -22
  52. package/linthost/rules_promise.go +15 -0
  53. package/linthost/rules_protos.go +3 -2
  54. package/linthost/rules_self.go +6 -0
  55. package/linthost/rules_strings.go +10 -0
  56. package/linthost/rules_suggestions.go +174 -9
  57. package/linthost/rules_throw.go +2 -0
  58. package/linthost/rules_ts.go +13 -0
  59. package/linthost/rules_ts_extra.go +25 -8
  60. package/linthost/rules_var.go +15 -2
  61. package/package.json +3 -3
  62. package/plugin/main.go +4 -4
  63. package/rule/astutil/astutil.go +12 -0
  64. package/rule/rule.go +123 -123
  65. package/src/defaultFormat.ts +10 -11
  66. package/src/index.ts +257 -168
  67. package/src/structures/ITtscLintConfig.ts +2 -2
  68. package/src/structures/ITtscLintFormatConfig.ts +53 -55
  69. package/src/structures/ITtscLintPluginConfig.ts +10 -83
  70. package/src/structures/ITtscLintPluginMeta.ts +9 -1
  71. package/src/structures/TtscLintRule.ts +10 -1
  72. package/src/structures/TtscLintRuleMap.ts +17 -18
  73. package/src/structures/TtscLintRuleOptions.ts +15 -0
  74. package/linthost/eslint_runtime.go +0 -320
@@ -1,6 +1,8 @@
1
1
  package linthost
2
2
 
3
3
  import (
4
+ "strings"
5
+
4
6
  shimast "github.com/microsoft/typescript-go/shim/ast"
5
7
  shimscanner "github.com/microsoft/typescript-go/shim/scanner"
6
8
  )
@@ -25,16 +27,22 @@ import (
25
27
  // printer's StartingIndent so continuation lines align under the
26
28
  // opening token and fit measurement charges the prefix against
27
29
  // the budget.
28
- // 3. Build the node's Doc via PrintNode.
29
- // 4. Render with the configured printWidth / tabWidth / useTabs /
30
+ // 3. Build the node's Doc via PrintNode, which also reports a
31
+ // `covered` flag.
32
+ // 4. Abstain when `covered` is false: the subtree holds a multi-line
33
+ // verbatim node whose frozen columns would not survive a reflow.
34
+ // 5. Render with the configured printWidth / tabWidth / useTabs /
30
35
  // endOfLine.
31
- // 5. Slice the original source bytes for the node's range.
32
- // 6. If the rendered output differs, emit one TextEdit replacing
36
+ // 6. Slice the original source bytes for the node's range.
37
+ // 7. If the rendered output differs, emit one TextEdit replacing
33
38
  // [start, end) with the new bytes.
34
39
  //
35
40
  // The "no diff → no edit" invariant is what keeps `ttsc format`
36
41
  // idempotent: a second pass renders identical bytes, the comparison
37
- // short-circuits, and the cascade converges.
42
+ // short-circuits, and the cascade converges. The `covered` abstain in
43
+ // step 4 is the safety floor: `ttsc format` either reflows correctly or
44
+ // leaves the node byte-identical — it never emits a half-reflowed,
45
+ // inconsistently indented shape.
38
46
  //
39
47
  // The rule is a format-class rule (IsFormat == true) so `ttsc format`
40
48
  // applies its edits while `ttsc check` only emits diagnostics for
@@ -44,11 +52,19 @@ import (
44
52
  type formatPrintWidth struct{}
45
53
 
46
54
  // formatPrintWidthOptions mirrors `TtscLintRuleOptions.PrintWidth`.
55
+ //
56
+ // TrailingComma reaches this rule because the printer's reflow decides
57
+ // whether to emit a trailing comma on every multi-line list — and that
58
+ // decision must match the user's `format.trailingComma` setting or the
59
+ // reflow oscillates against `format/trailing-comma` on every cascade
60
+ // pass. The config layer mirrors `format.trailingComma` into both
61
+ // rules' option blobs (see `expandFormatBlock` in config_format.go).
47
62
  type formatPrintWidthOptions struct {
48
- PrintWidth *int `json:"printWidth"`
49
- TabWidth *int `json:"tabWidth"`
50
- UseTabs *bool `json:"useTabs"`
51
- EndOfLine *string `json:"endOfLine"`
63
+ PrintWidth *int `json:"printWidth"`
64
+ TabWidth *int `json:"tabWidth"`
65
+ UseTabs *bool `json:"useTabs"`
66
+ EndOfLine *string `json:"endOfLine"`
67
+ TrailingComma *string `json:"trailingComma"`
52
68
  }
53
69
 
54
70
  func (formatPrintWidth) Name() string { return "format/print-width" }
@@ -66,6 +82,29 @@ func (formatPrintWidth) Visits() []shimast.Kind {
66
82
  }
67
83
  }
68
84
 
85
+ // Known residual divergence from Prettier 3 (investigated against the
86
+ // nestjs / typeorm / vscode benchmark fixtures, not closed in this
87
+ // pass):
88
+ //
89
+ // - Multi-line `reduce(...)` (or other single-arg method) calls where
90
+ // Prettier 3 keeps the inline form because it fits print-width
91
+ // minus the trailing-suffix budget. The current shrunk-budget
92
+ // re-render still over-breaks some of these; the next slice should
93
+ // measure fitsFirstLine against `pw - col - trailingNonComment`
94
+ // before committing to the broken layout.
95
+ // - Single-line `export type { X } from "long-path"` reexports.
96
+ // Prettier 3 keeps them flat even when the whole declaration
97
+ // overflows; ttsc-lint visits `KindNamedExports` in isolation and
98
+ // breaks the brace clause. A real fix requires teaching the rule
99
+ // about the surrounding ExportDeclaration so the brace clause is
100
+ // measured against the full declaration line, or visiting
101
+ // ExportDeclaration directly so the `from "..."` tail joins the
102
+ // reflow surface.
103
+ //
104
+ // Both cases are tracked benchmark cases that forced
105
+ // `format/print-width: 'off'` on the ttsc-lint branch. They are listed
106
+ // here so a future slice can pick them up without rediscovering the
107
+ // divergence.
69
108
  func (formatPrintWidth) Check(ctx *Context, node *shimast.Node) {
70
109
  if ctx == nil || ctx.File == nil || node == nil {
71
110
  return
@@ -85,6 +124,9 @@ func (formatPrintWidth) Check(ctx *Context, node *shimast.Node) {
85
124
  if opts.EndOfLine != nil {
86
125
  printOpts.EndOfLine = *opts.EndOfLine
87
126
  }
127
+ if opts.TrailingComma != nil {
128
+ printOpts.TrailingComma = *opts.TrailingComma
129
+ }
88
130
 
89
131
  src := ctx.File.Text()
90
132
  start := shimscanner.SkipTrivia(src, node.Pos())
@@ -101,6 +143,16 @@ func (formatPrintWidth) Check(ctx *Context, node *shimast.Node) {
101
143
  return
102
144
  }
103
145
 
146
+ // Abstain on any node nested inside a template-literal substitution.
147
+ // Prettier renders `${…}` expressions at printWidth:Infinity — it
148
+ // never breaks an interpolation the source wrote on one line — so
149
+ // reflowing a call or literal inside `${…}` would split the template
150
+ // across lines and diverge from Prettier. See the printWidth:Infinity
151
+ // branch in Prettier's printTemplateExpression.
152
+ if hasTemplateSubstitutionAncestor(node) {
153
+ return
154
+ }
155
+
104
156
  // Safety: abstain when the node carries comments outside its
105
157
  // children. The per-node printers join child docs with a fresh
106
158
  // `, ` separator and have no path for trivia between siblings, so
@@ -113,29 +165,85 @@ func (formatPrintWidth) Check(ctx *Context, node *shimast.Node) {
113
165
  }
114
166
 
115
167
  printOpts.StartingColumn = leadingColumn(src, start, printOpts.TabWidth)
116
- printOpts.BaseIndent = lineLeadingIndent(src, start, printOpts.TabWidth)
168
+ // A node reflowed on a ternary-arm continuation line (`? expr` or
169
+ // `: expr`) hangs its broken continuation under the arm's expression,
170
+ // two columns past the `?`/`:` marker — not under the marker itself.
171
+ printOpts.BaseIndent = lineLeadingIndent(src, start, printOpts.TabWidth) +
172
+ ternaryArmIndentBonus(src, start)
173
+
174
+ // trailingWidth is the column span of the tokens that stay on the
175
+ // node's last line after `end` — a `;`, a `);`, a `) {`. The reflow
176
+ // replaces only [start, end) and cannot move them, so both the fast
177
+ // path and the layout budget must reserve those columns; otherwise
178
+ // the rule emits a line that overflows by exactly the suffix.
179
+ trailingWidth := trailingLineWidth(src, end, printOpts.TabWidth)
117
180
 
118
181
  // Fast path: if the node's existing single-line bytes already fit
119
- // the printWidth budget, the reflowed output cannot differ from
120
- // the source (the printer would render the same flat shape). Skip
121
- // the Doc build + render entirely. This is the common case on
182
+ // the printWidth budget prefix column, node width and trailing
183
+ // suffix all charged the reflowed output cannot differ from the
184
+ // source (the printer would render the same flat shape). Skip the
185
+ // Doc build + render entirely. This is the common case on
122
186
  // well-formatted code — every short call, every short literal —
123
187
  // and saves the allocations from PrintNode + Print.
124
188
  if !sliceContainsNewline(src, start, end) &&
125
- printOpts.StartingColumn+(end-start) <= printOpts.PrintWidth {
189
+ printOpts.StartingColumn+(end-start)+trailingWidth <= printOpts.PrintWidth {
126
190
  return
127
191
  }
128
192
 
129
193
  printCtx := NewPrintContext(ctx.File, printOpts)
130
- doc, _ := PrintNode(printCtx, node)
194
+ doc, covered := PrintNode(printCtx, node)
131
195
  if doc.IsNil() {
132
196
  return
133
197
  }
198
+ // Safety abstain: the printed subtree contains a multi-line verbatim
199
+ // node — one the dispatcher has no printer for. Such a node keeps the
200
+ // source columns its lines were written at, while the reflow
201
+ // re-indents everything around it. Emitting the edit would produce
202
+ // inconsistently indented, corrupt output (a callback header at one
203
+ // indent, its body frozen at another). Abstaining leaves the bytes
204
+ // byte-identical, which is always safe. See the coverage-signal note
205
+ // in print_dispatch.go.
206
+ if !covered {
207
+ return
208
+ }
209
+ // Render at the full printWidth budget. A reflow that breaks across
210
+ // lines then makes every layout decision — which call argument hugs,
211
+ // where a list explodes — against the true column budget. The
212
+ // un-movable trailing suffix (`;`, `) {`, ` satisfies T`) lands on a
213
+ // short last line; charging it against the whole budget would
214
+ // wrongly penalize the interior lines and over-break the node.
134
215
  rendered := Print(doc, printOpts)
216
+ // The one case the suffix genuinely shares the node's line is a
217
+ // reflow that collapses to a single line. When the flat form plus
218
+ // the suffix would overflow, re-render under a budget shrunk by the
219
+ // suffix so the node breaks instead of spilling the suffix past
220
+ // printWidth — the regression that keeps a call flat at exactly
221
+ // printWidth while the trailing `;` runs over.
222
+ if trailingWidth > 0 &&
223
+ !strings.Contains(rendered, "\n") &&
224
+ maxLineWidth(rendered, printOpts.StartingColumn, trailingWidth, printOpts.TabWidth) > printOpts.PrintWidth &&
225
+ printOpts.PrintWidth-trailingWidth >= 1 {
226
+ shrunk := printOpts
227
+ shrunk.PrintWidth -= trailingWidth
228
+ rendered = Print(doc, shrunk)
229
+ }
135
230
  original := src[start:end]
136
231
  if rendered == original {
137
232
  return
138
233
  }
234
+ // Safety floor: never emit an edit that makes the widest line wider
235
+ // than it already was. The reflow may be unable to break an
236
+ // un-breakable token run — a long string literal, a verbatim object
237
+ // member — but it must never *worsen* the worst line. That is exactly
238
+ // the regression this guards: `ttsc format` collapsing an
239
+ // already-broken, fitting call into one over-wide line. A reflow that
240
+ // only fixes indentation and leaves a pre-existing over-wide line
241
+ // untouched is still emitted.
242
+ renderedMax := maxLineWidth(rendered, printOpts.StartingColumn, trailingWidth, printOpts.TabWidth)
243
+ originalMax := maxLineWidth(original, printOpts.StartingColumn, trailingWidth, printOpts.TabWidth)
244
+ if renderedMax > printOpts.PrintWidth && renderedMax > originalMax {
245
+ return
246
+ }
139
247
  ctx.ReportRangeFix(
140
248
  start,
141
249
  end,
@@ -144,6 +252,113 @@ func (formatPrintWidth) Check(ctx *Context, node *shimast.Node) {
144
252
  )
145
253
  }
146
254
 
255
+ // trailingLineWidth returns the visual column width of src[end:] up to
256
+ // the next newline, with trailing whitespace trimmed. The format/print-
257
+ // width reflow replaces only the node's own byte range, so whatever
258
+ // shares the node's last source line — a statement `;`, a `) {` header
259
+ // tail, a `, nextArg)` continuation — stays put. Charging that width
260
+ // against the budget keeps the rule from emitting a line that overflows
261
+ // by exactly the suffix it could never move.
262
+ //
263
+ // Trailing `//` line comments are excluded from the budget. A line
264
+ // comment runs to the end of the source line by definition, so breaking
265
+ // the reflowed node to make the comment fit cannot help — Prettier 3
266
+ // keeps the node inline and lets the comment trail (see typeorm's
267
+ // `comment.replaceAll(...) // Null bytes' shape that pushed
268
+ // `format/print-width: 'off'` onto the ttsc-lint benchmark branch).
269
+ // Excluding the comment from `trailingLineWidth` matches that
270
+ // behavior: the fast path sees just the un-movable punctuation suffix,
271
+ // and the shrunk-budget re-render does not over-shrink and over-break.
272
+ func trailingLineWidth(src string, end int, tabWidth int) int {
273
+ if end < 0 || end > len(src) {
274
+ return 0
275
+ }
276
+ if tabWidth <= 0 {
277
+ tabWidth = 2
278
+ }
279
+ lineEnd := trailingSuffixEnd(src, end)
280
+ col := 0
281
+ for i := end; i < lineEnd; i++ {
282
+ if src[i] == '\t' {
283
+ col += tabWidth - (col % tabWidth)
284
+ } else {
285
+ col++
286
+ }
287
+ }
288
+ return col
289
+ }
290
+
291
+ // trailingSuffixEnd returns the byte offset where the node's un-movable
292
+ // trailing suffix ends on the line that begins at `end`. The walk stops
293
+ // at the first `//` line comment (Prettier-style "free" trailing
294
+ // attachment, see trailingLineWidth) or at the newline, then trims
295
+ // trailing whitespace so a `;<spaces><newline>` tail measures the
296
+ // `;` only. Trailing block comments inside the un-movable suffix span
297
+ // (`} /* note */`) keep their bytes counted; that path is rare and
298
+ // already exercised through the existing block-comment fixture.
299
+ func trailingSuffixEnd(src string, end int) int {
300
+ lineEnd := end
301
+ for lineEnd < len(src) && src[lineEnd] != '\n' {
302
+ if src[lineEnd] == '/' && lineEnd+1 < len(src) {
303
+ next := src[lineEnd+1]
304
+ if next == '/' {
305
+ // `//` line comment — Prettier treats the whole tail as a
306
+ // trailing comment that runs to EOL. Drop it from the suffix
307
+ // budget so the rule does not break the node to chase a
308
+ // comment that cannot be moved or wrapped.
309
+ break
310
+ }
311
+ }
312
+ lineEnd++
313
+ }
314
+ for lineEnd > end {
315
+ c := src[lineEnd-1]
316
+ if c != ' ' && c != '\t' && c != '\r' {
317
+ break
318
+ }
319
+ lineEnd--
320
+ }
321
+ return lineEnd
322
+ }
323
+
324
+ // maxLineWidth returns the widest effective column span among the lines
325
+ // of `text`. The first line is charged `startingColumn` — the prefix
326
+ // already on that source line that the reflow does not re-emit — and
327
+ // the last line is charged `trailingWidth` for the un-movable suffix
328
+ // that follows the node. Tabs count as `tabWidth` columns.
329
+ //
330
+ // The rule compares the rendered output's widest line against the
331
+ // source node's: a reflow that cannot fit an un-breakable token is
332
+ // still allowed through as long as it does not make the worst line any
333
+ // wider than it already was.
334
+ func maxLineWidth(text string, startingColumn, trailingWidth, tabWidth int) int {
335
+ if tabWidth <= 0 {
336
+ tabWidth = 2
337
+ }
338
+ lines := strings.Split(text, "\n")
339
+ widest := 0
340
+ for i, line := range lines {
341
+ width := 0
342
+ for _, r := range line {
343
+ if r == '\t' {
344
+ width += tabWidth - (width % tabWidth)
345
+ } else if r != '\r' {
346
+ width++
347
+ }
348
+ }
349
+ if i == 0 {
350
+ width += startingColumn
351
+ }
352
+ if i == len(lines)-1 {
353
+ width += trailingWidth
354
+ }
355
+ if width > widest {
356
+ widest = width
357
+ }
358
+ }
359
+ return widest
360
+ }
361
+
147
362
  // leadingColumn returns the visual column the byte at `pos` occupies on
148
363
  // its line. Tabs expand to `tabWidth` columns; other bytes count as 1.
149
364
  // The rule uses this to seed the printer's StartingColumn so fit
@@ -240,6 +455,25 @@ func lineStartOffset(src string, pos int) int {
240
455
  return pos
241
456
  }
242
457
 
458
+ // ternaryArmIndentBonus returns 2 when the line containing `pos` begins,
459
+ // after its leading whitespace, with a `? ` or `: ` ternary-arm marker,
460
+ // and 0 otherwise. format/print-width adds it to BaseIndent so a node
461
+ // reflowed inside a ternary arm indents its broken continuation under
462
+ // the arm's expression rather than under the `?`/`:` token. In practice
463
+ // only a ternary arm opens a reflow target's line with `? ` / `: `; the
464
+ // two-byte prefix is a heuristic, and a rare false positive only shifts
465
+ // a broken continuation by two columns — it never corrupts bytes.
466
+ func ternaryArmIndentBonus(src string, pos int) int {
467
+ i := lineStartOffset(src, pos)
468
+ for i < len(src) && (src[i] == ' ' || src[i] == '\t') {
469
+ i++
470
+ }
471
+ if i+1 < len(src) && (src[i] == '?' || src[i] == ':') && src[i+1] == ' ' {
472
+ return 2
473
+ }
474
+ return 0
475
+ }
476
+
243
477
  // hasReflowAncestor reports whether any ancestor of `node` would also
244
478
  // match the format/print-width visitor. The rule uses this to suppress
245
479
  // nested fires when an enclosing reflow target already covers the
@@ -256,6 +490,27 @@ func hasReflowAncestor(node *shimast.Node) bool {
256
490
  return false
257
491
  }
258
492
 
493
+ // hasTemplateSubstitutionAncestor reports whether `node` sits inside a
494
+ // template-literal substitution (`${…}`). format/print-width abstains
495
+ // on such nodes: Prettier prints template interpolations at infinite
496
+ // printWidth and only keeps a break the source already had, so a reflow
497
+ // of a nested call or literal would split a one-line `${…}` and never
498
+ // match Prettier's output.
499
+ func hasTemplateSubstitutionAncestor(node *shimast.Node) bool {
500
+ if node == nil {
501
+ return false
502
+ }
503
+ for parent := node.Parent; parent != nil; parent = parent.Parent {
504
+ if parent.Kind == shimast.KindTemplateExpression {
505
+ return true
506
+ }
507
+ }
508
+ return false
509
+ }
510
+
511
+ // isReflowKind reports whether `k` is one of the node kinds the
512
+ // format/print-width rule visits. Kept in sync with Visits() so
513
+ // hasReflowAncestor does not need to call Visits() at runtime.
259
514
  func isReflowKind(k shimast.Kind) bool {
260
515
  switch k {
261
516
  case shimast.KindObjectLiteralExpression,
@@ -25,6 +25,9 @@ import (
25
25
  // also intentionally out of scope.
26
26
  type formatQuotes struct{}
27
27
 
28
+ // formatQuotesOptions mirrors `TtscLintRuleOptions.Quotes`. Prefer accepts
29
+ // `"double"` (the default, enforces double quotes) or `"single"` (enforces
30
+ // single quotes). Any other value is treated as the default.
28
31
  type formatQuotesOptions struct {
29
32
  Prefer string `json:"prefer"`
30
33
  }
@@ -173,6 +173,8 @@ func loadSortImportsOptions(ctx *Context) resolvedSortImportsOptions {
173
173
  }
174
174
  }
175
175
 
176
+ // hasThirdPartyGroup reports whether at least one group in the list is the
177
+ // third-party catchall. Used to decide whether a sentinel needs to be appended.
176
178
  func hasThirdPartyGroup(groups []sortImportsGroup) bool {
177
179
  for _, g := range groups {
178
180
  if g.thirdParty {
@@ -449,6 +451,8 @@ func reportNamedSpecifierSort(ctx *Context, decl *shimast.Node, caseInsensitive
449
451
  )
450
452
  }
451
453
 
454
+ // joinSortedSpecifiers returns the specifier texts joined with ", " in their
455
+ // already-sorted order. The caller owns the sort; this is a formatting step only.
452
456
  func joinSortedSpecifiers(entries []specifierEntry) string {
453
457
  texts := make([]string, len(entries))
454
458
  for i, e := range entries {
@@ -1,6 +1,17 @@
1
+ // Miscellaneous AST-only rules that cover the gap between the core ESLint
2
+ // "Possible Problems" list and the @typescript-eslint strict/stylistic
3
+ // presets: empty static blocks, setter returns, unused labels, dynamic
4
+ // delete, nullish-coalescing with non-null assertions, unnecessary type
5
+ // constraints, unsafe built-in types, wrapper object types, useless
6
+ // constructors, non-literal enum members, type-assertion style, type
7
+ // definition style, dot notation, and unsafe declaration merging.
8
+ // Each rule is registered in init() at the bottom of the file.
1
9
  package linthost
2
10
 
3
- import shimast "github.com/microsoft/typescript-go/shim/ast"
11
+ import (
12
+ shimast "github.com/microsoft/typescript-go/shim/ast"
13
+ shimscanner "github.com/microsoft/typescript-go/shim/scanner"
14
+ )
4
15
 
5
16
  // no-empty-static-block: `class C { static {} }` has no effect.
6
17
  // ESLint recommended: https://eslint.org/docs/latest/rules/no-empty-static-block
@@ -35,6 +46,9 @@ func (noSetterReturn) Check(ctx *Context, node *shimast.Node) {
35
46
  ctx.Report(node, "Setter should not return a value.")
36
47
  }
37
48
 
49
+ // isInsideDirectSetter reports whether node is lexically nested inside a
50
+ // SetAccessor without an intervening function boundary. "Direct" means the
51
+ // return value would actually be a setter return, not a nested callback's.
38
52
  func isInsideDirectSetter(node *shimast.Node) bool {
39
53
  for p := node.Parent; p != nil; p = p.Parent {
40
54
  if p.Kind == shimast.KindSetAccessor {
@@ -99,6 +113,9 @@ func (noDynamicDelete) Check(ctx *Context, node *shimast.Node) {
99
113
  ctx.Report(node, "Do not delete dynamically computed property keys.")
100
114
  }
101
115
 
116
+ // isStaticPropertyKey reports whether node is a literal key that can
117
+ // appear in a delete expression without triggering no-dynamic-delete:
118
+ // string literals, no-substitution template literals, and numeric literals.
102
119
  func isStaticPropertyKey(node *shimast.Node) bool {
103
120
  node = stripParens(node)
104
121
  if node == nil {
@@ -263,6 +280,9 @@ func fileShadowsWrapperName(file *shimast.SourceFile, name string) bool {
263
280
  return false
264
281
  }
265
282
 
283
+ // wrapperPrimitive maps a boxed object type name (e.g. "String") to its
284
+ // primitive keyword equivalent (e.g. "string"). Returns ("", false) for
285
+ // names that are not recognized wrapper types.
266
286
  func wrapperPrimitive(name string) (string, bool) {
267
287
  switch name {
268
288
  case "String":
@@ -355,16 +375,68 @@ func (dotNotation) Visits() []shimast.Kind {
355
375
  }
356
376
  func (dotNotation) Check(ctx *Context, node *shimast.Node) {
357
377
  access := node.AsElementAccessExpression()
358
- if access == nil {
378
+ if access == nil || access.Expression == nil || access.ArgumentExpression == nil {
359
379
  return
360
380
  }
361
381
  key := stringLiteralText(access.ArgumentExpression)
362
382
  if key == "" || !isSimpleIdentifierName(key) {
363
383
  return
364
384
  }
365
- ctx.Report(node, "Use dot notation instead of a string literal property access.")
385
+ message := "Use dot notation instead of a string literal property access."
386
+ // Conservative: keep reserved words as bracket access. Modern JS accepts
387
+ // `obj.class`/`obj.if`/etc. syntactically but mixing them with member
388
+ // expression syntax is jarring and can confuse minifiers and older runtimes.
389
+ if isReservedWord(key) {
390
+ ctx.Report(node, message)
391
+ return
392
+ }
393
+ src := ctx.File.Text()
394
+ // Determine where the bracket access begins. With an optional chain
395
+ // (`obj?.["foo"]`) the `?.` token already separates object from access, so
396
+ // we replace the `["foo"]` tail with `foo`. Otherwise we replace the
397
+ // `["foo"]` tail with `.foo`.
398
+ var replaceFrom int
399
+ var replacement string
400
+ if access.QuestionDotToken != nil {
401
+ replaceFrom = shimscanner.SkipTrivia(src, access.QuestionDotToken.End())
402
+ replacement = key
403
+ } else {
404
+ replaceFrom = access.Expression.End()
405
+ replacement = "." + key
406
+ }
407
+ if replaceFrom < 0 || replaceFrom >= node.End() {
408
+ ctx.Report(node, message)
409
+ return
410
+ }
411
+ ctx.ReportFix(
412
+ node,
413
+ message,
414
+ TextEdit{Pos: replaceFrom, End: node.End(), Text: replacement},
415
+ )
416
+ }
417
+
418
+ // isReservedWord reports whether `value` is an ECMAScript reserved word. The
419
+ // `dot-notation` autofix uses this to skip the rewrite even though modern
420
+ // parsers accept reserved-word member names: leaving bracket access matches
421
+ // the conservative branch ESLint takes when `allowKeywords: false`.
422
+ func isReservedWord(value string) bool {
423
+ switch value {
424
+ case "break", "case", "catch", "class", "const", "continue",
425
+ "debugger", "default", "delete", "do", "else", "enum",
426
+ "export", "extends", "false", "finally", "for", "function",
427
+ "if", "implements", "import", "in", "instanceof", "interface",
428
+ "let", "new", "null", "package", "private", "protected",
429
+ "public", "return", "static", "super", "switch", "this",
430
+ "throw", "true", "try", "typeof", "var", "void",
431
+ "while", "with", "yield", "await":
432
+ return true
433
+ }
434
+ return false
366
435
  }
367
436
 
437
+ // isSimpleIdentifierName reports whether value is a valid bare identifier
438
+ // (ASCII letters, digits, underscore, dollar sign; digits not first). Used
439
+ // by dot-notation to decide whether bracket access can become dot access.
368
440
  func isSimpleIdentifierName(value string) bool {
369
441
  if value == "" {
370
442
  return false
@@ -67,13 +67,11 @@ func (noImportTypeSideEffects) Check(ctx *Context, node *shimast.Node) {
67
67
  }
68
68
  src := ctx.File.Text()
69
69
  for _, spec := range named.Elements.Nodes {
70
- // Locate the `type` keyword token at the head of the specifier by
71
- // first skipping leading trivia (whitespace + comments). A naive
72
- // `findKeyword` would search the byte range linearly and could
73
- // match `type` *inside* a leading block comment such as
74
- // `/* type alias */`, deleting the comment text and corrupting
75
- // the source. SkipTrivia honors the lexer's notion of trivia, so
76
- // the post-skip position is the actual first token byte.
70
+ // Locate the `type` keyword token at the head of each specifier by
71
+ // skipping leading trivia (whitespace + comments). A naive
72
+ // findKeyword scan would risk matching `type` inside a block comment
73
+ // such as `/* type alias */`, corrupting the source. SkipTrivia
74
+ // anchors the scan at the actual first token byte.
77
75
  typePos := shimscanner.SkipTrivia(src, spec.Pos())
78
76
  if typePos < 0 || typePos+len("type") > len(src) {
79
77
  continue
@@ -1,6 +1,14 @@
1
+ // Correctness and equality rules — a focused set from ESLint's "Possible
2
+ // Problems" and "Suggestions" categories that catch logic errors rather than
3
+ // style issues: redundant boolean casts, unsafe negations, loose equality,
4
+ // NaN comparisons, constant conditions, and assignment-in-condition.
5
+ // AST-only, no scope analysis.
1
6
  package linthost
2
7
 
3
- import shimast "github.com/microsoft/typescript-go/shim/ast"
8
+ import (
9
+ shimast "github.com/microsoft/typescript-go/shim/ast"
10
+ shimscanner "github.com/microsoft/typescript-go/shim/scanner"
11
+ )
4
12
 
5
13
  // no-extra-boolean-cast: `if (!!x)`, `if (Boolean(x))`, `Boolean(!!x)` —
6
14
  // the conversion is implicit in a boolean context.
@@ -21,9 +29,43 @@ func (noExtraBooleanCast) Check(ctx *Context, node *shimast.Node) {
21
29
  if identifierText(call.Expression) != "Boolean" {
22
30
  return
23
31
  }
24
- if isInBooleanContext(node) {
25
- ctx.Report(node, "Redundant Boolean call.")
32
+ if call.QuestionDotToken != nil {
33
+ return
34
+ }
35
+ if !isInBooleanContext(node) {
36
+ return
37
+ }
38
+ message := "Redundant Boolean call."
39
+ // Only autofix when there is exactly one positional argument: zero
40
+ // arguments produce `undefined → false`, and multi-arg calls signal the
41
+ // author may be passing through an unrelated value as the second slot.
42
+ // Spread arguments hide the runtime shape, so skip those as well.
43
+ if call.Arguments == nil || len(call.Arguments.Nodes) != 1 {
44
+ ctx.Report(node, message)
45
+ return
46
+ }
47
+ arg := call.Arguments.Nodes[0]
48
+ if arg == nil || arg.Kind == shimast.KindSpreadElement {
49
+ ctx.Report(node, message)
50
+ return
51
+ }
52
+ src := ctx.File.Text()
53
+ argStart := shimscanner.SkipTrivia(src, arg.Pos())
54
+ argEnd := arg.End()
55
+ if argStart < 0 || argStart >= argEnd || argEnd > len(src) {
56
+ ctx.Report(node, message)
57
+ return
58
+ }
59
+ editPos := shimscanner.SkipTrivia(src, node.Pos())
60
+ if editPos < 0 || editPos >= node.End() {
61
+ ctx.Report(node, message)
62
+ return
26
63
  }
64
+ ctx.ReportFix(
65
+ node,
66
+ message,
67
+ TextEdit{Pos: editPos, End: node.End(), Text: src[argStart:argEnd]},
68
+ )
27
69
  case shimast.KindPrefixUnaryExpression:
28
70
  outer := node.AsPrefixUnaryExpression()
29
71
  if outer == nil || outer.Operator != shimast.KindExclamationToken {
@@ -37,9 +79,31 @@ func (noExtraBooleanCast) Check(ctx *Context, node *shimast.Node) {
37
79
  if inner == nil || inner.Operator != shimast.KindExclamationToken {
38
80
  return
39
81
  }
40
- if isInBooleanContext(node) {
41
- ctx.Report(node, "Redundant double negation.")
82
+ if !isInBooleanContext(node) {
83
+ return
84
+ }
85
+ message := "Redundant double negation."
86
+ if inner.Operand == nil {
87
+ ctx.Report(node, message)
88
+ return
42
89
  }
90
+ src := ctx.File.Text()
91
+ inStart := shimscanner.SkipTrivia(src, inner.Operand.Pos())
92
+ inEnd := inner.Operand.End()
93
+ if inStart < 0 || inStart >= inEnd || inEnd > len(src) {
94
+ ctx.Report(node, message)
95
+ return
96
+ }
97
+ editPos := shimscanner.SkipTrivia(src, node.Pos())
98
+ if editPos < 0 || editPos >= node.End() {
99
+ ctx.Report(node, message)
100
+ return
101
+ }
102
+ ctx.ReportFix(
103
+ node,
104
+ message,
105
+ TextEdit{Pos: editPos, End: node.End(), Text: src[inStart:inEnd]},
106
+ )
43
107
  }
44
108
  }
45
109
 
@@ -71,6 +135,12 @@ func isInBooleanContext(node *shimast.Node) bool {
71
135
  return false
72
136
  }
73
137
 
138
+ // skipParents walks up through any wrapping ParenthesizedExpression nodes and
139
+ // returns the outermost parenthesized wrapper. This is the inverse of
140
+ // stripParens: where stripParens descends into the canonical inner expression,
141
+ // skipParents climbs up to the outermost paren so that structural parent
142
+ // comparisons (e.g. "is this expression the test of an if?") resolve against
143
+ // the node the parser actually attached as a child, not the inner form.
74
144
  func skipParents(node *shimast.Node) *shimast.Node {
75
145
  for node != nil && node.Parent != nil && node.Parent.Kind == shimast.KindParenthesizedExpression {
76
146
  node = node.Parent
@@ -57,6 +57,10 @@ func (forDirection) Check(ctx *Context, node *shimast.Node) {
57
57
  }
58
58
  }
59
59
 
60
+ // updateDirection returns the direction (+1 for incrementing, -1 for
61
+ // decrementing, 0 for unknown) that the incrementor expression moves the
62
+ // named counter variable. Only simple patterns are recognised: i++, i--,
63
+ // ++i, --i, i += N, and i -= N. Compound or computed expressions return 0.
60
64
  func updateDirection(node *shimast.Node, counter string) int {
61
65
  if node == nil || counter == "" {
62
66
  return 0