@ttsc/lint 0.12.4 → 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 (37) hide show
  1. package/lib/index.js +227 -135
  2. package/lib/index.js.map +1 -1
  3. package/lib/structures/ITtscLintPluginConfig.d.ts +10 -77
  4. package/lib/structures/TtscLintRuleOptions.d.ts +14 -0
  5. package/linthost/ast_helpers.go +68 -16
  6. package/linthost/compile.go +91 -124
  7. package/linthost/config.go +518 -707
  8. package/linthost/config_format.go +16 -4
  9. package/linthost/contrib_adapter.go +7 -0
  10. package/linthost/directives.go +44 -0
  11. package/linthost/engine.go +152 -43
  12. package/linthost/fix.go +24 -33
  13. package/linthost/flags_gen.go +31 -0
  14. package/linthost/format.go +96 -3
  15. package/linthost/host.go +104 -5
  16. package/linthost/print_dispatch.go +121 -23
  17. package/linthost/print_doc.go +19 -0
  18. package/linthost/print_engine.go +168 -4
  19. package/linthost/print_nodes_array.go +17 -7
  20. package/linthost/print_nodes_call.go +129 -20
  21. package/linthost/print_nodes_function.go +353 -0
  22. package/linthost/print_nodes_imports.go +46 -29
  23. package/linthost/print_nodes_list.go +86 -5
  24. package/linthost/print_nodes_object.go +56 -11
  25. package/linthost/rules_escape.go +20 -3
  26. package/linthost/rules_format_print_width.go +267 -15
  27. package/linthost/rules_gap.go +55 -3
  28. package/linthost/rules_logic.go +64 -5
  29. package/linthost/rules_problems.go +65 -21
  30. package/linthost/rules_promise.go +3 -0
  31. package/linthost/rules_suggestions.go +160 -5
  32. package/linthost/rules_var.go +1 -1
  33. package/package.json +3 -3
  34. package/src/index.ts +245 -168
  35. package/src/structures/ITtscLintPluginConfig.ts +10 -83
  36. package/src/structures/TtscLintRuleOptions.ts +15 -0
  37. package/linthost/eslint_runtime.go +0 -351
@@ -5,7 +5,10 @@
5
5
  // AST-only, no scope analysis.
6
6
  package linthost
7
7
 
8
- 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
+ )
9
12
 
10
13
  // no-extra-boolean-cast: `if (!!x)`, `if (Boolean(x))`, `Boolean(!!x)` —
11
14
  // the conversion is implicit in a boolean context.
@@ -26,9 +29,43 @@ func (noExtraBooleanCast) Check(ctx *Context, node *shimast.Node) {
26
29
  if identifierText(call.Expression) != "Boolean" {
27
30
  return
28
31
  }
29
- if isInBooleanContext(node) {
30
- 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
31
63
  }
64
+ ctx.ReportFix(
65
+ node,
66
+ message,
67
+ TextEdit{Pos: editPos, End: node.End(), Text: src[argStart:argEnd]},
68
+ )
32
69
  case shimast.KindPrefixUnaryExpression:
33
70
  outer := node.AsPrefixUnaryExpression()
34
71
  if outer == nil || outer.Operator != shimast.KindExclamationToken {
@@ -42,9 +79,31 @@ func (noExtraBooleanCast) Check(ctx *Context, node *shimast.Node) {
42
79
  if inner == nil || inner.Operator != shimast.KindExclamationToken {
43
80
  return
44
81
  }
45
- if isInBooleanContext(node) {
46
- 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
47
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
+ )
48
107
  }
49
108
  }
50
109
 
@@ -74,7 +74,9 @@ func (noExAssign) Check(ctx *Context, node *shimast.Node) {
74
74
  }
75
75
 
76
76
  // walkAssignments invokes `report` on every `<name> = ...` shape inside
77
- // `root`. Used by no-ex-assign and friends.
77
+ // `root`. Used by no-ex-assign to scan a single catch block; the
78
+ // file-wide no-func-assign / no-class-assign scans go through
79
+ // reportAssignmentsToDeclarations instead.
78
80
  func walkAssignments(root *shimast.Node, name string, report func(*shimast.Node)) {
79
81
  if root == nil {
80
82
  return
@@ -230,38 +232,80 @@ func numericLiteralLosesPrecision(text string) bool {
230
232
  type noClassAssign struct{}
231
233
 
232
234
  func (noClassAssign) Name() string { return "no-class-assign" }
233
- func (noClassAssign) Visits() []shimast.Kind { return []shimast.Kind{shimast.KindClassDeclaration} }
235
+ func (noClassAssign) Visits() []shimast.Kind { return []shimast.Kind{shimast.KindSourceFile} }
234
236
  func (noClassAssign) Check(ctx *Context, node *shimast.Node) {
235
- decl := node.AsClassDeclaration()
236
- if decl == nil || decl.Name() == nil {
237
- return
238
- }
239
- name := identifierText(decl.Name())
240
- if name == "" {
241
- return
242
- }
243
- walkAssignments(ctx.File.AsNode(), name, func(target *shimast.Node) {
244
- ctx.Report(target, "'"+name+"' is a class.")
245
- })
237
+ reportAssignmentsToDeclarations(ctx, node, shimast.KindClassDeclaration, "is a class.")
246
238
  }
247
239
 
248
240
  // no-func-assign: same idea, but for function declarations.
249
241
  type noFuncAssign struct{}
250
242
 
251
243
  func (noFuncAssign) Name() string { return "no-func-assign" }
252
- func (noFuncAssign) Visits() []shimast.Kind { return []shimast.Kind{shimast.KindFunctionDeclaration} }
244
+ func (noFuncAssign) Visits() []shimast.Kind { return []shimast.Kind{shimast.KindSourceFile} }
253
245
  func (noFuncAssign) Check(ctx *Context, node *shimast.Node) {
254
- decl := node.AsFunctionDeclaration()
255
- if decl == nil || decl.Name() == nil {
246
+ reportAssignmentsToDeclarations(ctx, node, shimast.KindFunctionDeclaration, "is a function.")
247
+ }
248
+
249
+ // reportAssignmentsToDeclarations flags every `<name> = …` assignment whose
250
+ // target identifier names a `declKind` declaration found anywhere in the
251
+ // file. It walks the file exactly once — gathering declared names and
252
+ // assignment targets in the same pass — so the cost is linear in file size.
253
+ //
254
+ // The earlier shape registered for `declKind` directly and, on every
255
+ // declaration, re-scanned the whole file for assignments: O(declarations ×
256
+ // file size), which blows up quadratically on a file with many top-level
257
+ // functions. Visiting `KindSourceFile` once and cross-referencing afterward
258
+ // keeps the same findings without the repeated scans.
259
+ func reportAssignmentsToDeclarations(
260
+ ctx *Context,
261
+ file *shimast.Node,
262
+ declKind shimast.Kind,
263
+ noun string,
264
+ ) {
265
+ if ctx == nil || file == nil {
256
266
  return
257
267
  }
258
- name := identifierText(decl.Name())
259
- if name == "" {
268
+ declared := map[string]struct{}{}
269
+ var targets []*shimast.Node
270
+ walkDescendants(file, func(n *shimast.Node) {
271
+ switch n.Kind {
272
+ case declKind:
273
+ if name := declarationName(n); name != "" {
274
+ declared[name] = struct{}{}
275
+ }
276
+ case shimast.KindBinaryExpression:
277
+ if expr := n.AsBinaryExpression(); expr != nil &&
278
+ expr.OperatorToken != nil && isAssignmentOperator(expr.OperatorToken.Kind) &&
279
+ expr.Left != nil && expr.Left.Kind == shimast.KindIdentifier {
280
+ targets = append(targets, expr.Left)
281
+ }
282
+ }
283
+ })
284
+ if len(declared) == 0 || len(targets) == 0 {
260
285
  return
261
286
  }
262
- walkAssignments(ctx.File.AsNode(), name, func(target *shimast.Node) {
263
- ctx.Report(target, "'"+name+"' is a function.")
264
- })
287
+ for _, target := range targets {
288
+ name := identifierText(target)
289
+ if _, ok := declared[name]; ok {
290
+ ctx.Report(target, "'"+name+"' "+noun)
291
+ }
292
+ }
293
+ }
294
+
295
+ // declarationName returns the bound name of a class or function declaration
296
+ // node, or "" when the node is neither (or is anonymous).
297
+ func declarationName(n *shimast.Node) string {
298
+ switch n.Kind {
299
+ case shimast.KindFunctionDeclaration:
300
+ if d := n.AsFunctionDeclaration(); d != nil {
301
+ return identifierText(d.Name())
302
+ }
303
+ case shimast.KindClassDeclaration:
304
+ if d := n.AsClassDeclaration(); d != nil {
305
+ return identifierText(d.Name())
306
+ }
307
+ }
308
+ return ""
265
309
  }
266
310
 
267
311
  // no-prototype-builtins: `obj.hasOwnProperty(x)` — should be
@@ -19,6 +19,9 @@ import (
19
19
  type awaitThenable struct{}
20
20
 
21
21
  func (awaitThenable) Name() string { return "await-thenable" }
22
+ func (awaitThenable) NeedsTypeChecker() bool {
23
+ return true
24
+ }
22
25
  func (awaitThenable) Visits() []shimast.Kind {
23
26
  return []shimast.Kind{shimast.KindAwaitExpression}
24
27
  }
@@ -7,6 +7,7 @@ import (
7
7
  "strings"
8
8
 
9
9
  shimast "github.com/microsoft/typescript-go/shim/ast"
10
+ shimscanner "github.com/microsoft/typescript-go/shim/scanner"
10
11
  )
11
12
 
12
13
  // no-alert: `alert()` / `confirm()` / `prompt()`. Rarely the right
@@ -742,16 +743,72 @@ func (noUnneededTernary) Visits() []shimast.Kind {
742
743
  }
743
744
  func (noUnneededTernary) Check(ctx *Context, node *shimast.Node) {
744
745
  cond := node.AsConditionalExpression()
745
- if cond == nil {
746
+ if cond == nil || cond.Condition == nil {
746
747
  return
747
748
  }
748
749
  t := stripParens(cond.WhenTrue)
749
750
  f := stripParens(cond.WhenFalse)
750
751
  tBool, tOk := isLiteralBoolean(t)
751
752
  fBool, fOk := isLiteralBoolean(f)
752
- if tOk && fOk && tBool != fBool {
753
- ctx.Report(node, "Unnecessary use of conditional expression for boolean.")
753
+ if !(tOk && fOk && tBool != fBool) {
754
+ return
755
+ }
756
+ message := "Unnecessary use of conditional expression for boolean."
757
+ src := ctx.File.Text()
758
+ condStart := shimscanner.SkipTrivia(src, cond.Condition.Pos())
759
+ if condStart < 0 || condStart >= cond.Condition.End() {
760
+ ctx.Report(node, message)
761
+ return
762
+ }
763
+ condText := src[condStart:cond.Condition.End()]
764
+ var replacement string
765
+ if tBool {
766
+ // `cond ? true : false` → `Boolean(cond)`
767
+ replacement = "Boolean(" + condText + ")"
768
+ } else {
769
+ // `cond ? false : true` → `!cond`. Wrap the condition in parentheses
770
+ // when it is not already a primary expression so operator precedence
771
+ // does not flip the meaning (e.g. `a || b` must become `!(a || b)`).
772
+ if needsParensForUnaryNegation(cond.Condition) {
773
+ replacement = "!(" + condText + ")"
774
+ } else {
775
+ replacement = "!" + condText
776
+ }
777
+ }
778
+ editPos := shimscanner.SkipTrivia(src, node.Pos())
779
+ if editPos < 0 || editPos >= node.End() {
780
+ ctx.Report(node, message)
781
+ return
782
+ }
783
+ ctx.ReportFix(
784
+ node,
785
+ message,
786
+ TextEdit{Pos: editPos, End: node.End(), Text: replacement},
787
+ )
788
+ }
789
+
790
+ // needsParensForUnaryNegation reports whether `cond` must be wrapped in
791
+ // parentheses before prefixing with `!`. Anything looser than a unary /
792
+ // member / primary expression flips precedence when negated. Mirrors
793
+ // ESLint's `no-unneeded-ternary` autofix safety check.
794
+ func needsParensForUnaryNegation(node *shimast.Node) bool {
795
+ inner := stripParens(node)
796
+ if inner == nil {
797
+ return false
798
+ }
799
+ switch inner.Kind {
800
+ case shimast.KindBinaryExpression,
801
+ shimast.KindConditionalExpression,
802
+ shimast.KindYieldExpression,
803
+ shimast.KindAwaitExpression,
804
+ shimast.KindArrowFunction,
805
+ shimast.KindFunctionExpression,
806
+ shimast.KindAsExpression,
807
+ shimast.KindSatisfiesExpression,
808
+ shimast.KindTypeAssertionExpression:
809
+ return true
754
810
  }
811
+ return false
755
812
  }
756
813
 
757
814
  // no-unused-expressions: an expression statement whose value isn't used.
@@ -1099,9 +1156,27 @@ func (preferTemplate) Check(ctx *Context, node *shimast.Node) {
1099
1156
  }
1100
1157
  }
1101
1158
  hasString, hasOther := concatChainShape(node)
1102
- if hasString && hasOther {
1103
- ctx.Report(node, "Unexpected string concatenation.")
1159
+ if !(hasString && hasOther) {
1160
+ return
1161
+ }
1162
+ message := "Unexpected string concatenation."
1163
+ src := ctx.File.Text()
1164
+ operands := flattenConcatOperands(node)
1165
+ template, ok := renderConcatAsTemplate(src, operands)
1166
+ if !ok {
1167
+ ctx.Report(node, message)
1168
+ return
1104
1169
  }
1170
+ editPos := shimscanner.SkipTrivia(src, node.Pos())
1171
+ if editPos < 0 || editPos >= node.End() {
1172
+ ctx.Report(node, message)
1173
+ return
1174
+ }
1175
+ ctx.ReportFix(
1176
+ node,
1177
+ message,
1178
+ TextEdit{Pos: editPos, End: node.End(), Text: template},
1179
+ )
1105
1180
  }
1106
1181
 
1107
1182
  func concatChainShape(node *shimast.Node) (hasString bool, hasOther bool) {
@@ -1122,6 +1197,86 @@ func concatChainShape(node *shimast.Node) (hasString bool, hasOther bool) {
1122
1197
  return false, true
1123
1198
  }
1124
1199
 
1200
+ // flattenConcatOperands walks a `+` chain left-to-right and returns each
1201
+ // leaf operand in source order. Parenthesized sub-expressions are kept as
1202
+ // a single operand so the rendered template literal does not lose their
1203
+ // grouping. Mirrors ESLint's behavior of treating `("a" + b)` as one
1204
+ // expression slot when it appears inside a larger chain.
1205
+ func flattenConcatOperands(node *shimast.Node) []*shimast.Node {
1206
+ if node == nil {
1207
+ return nil
1208
+ }
1209
+ if node.Kind == shimast.KindBinaryExpression {
1210
+ bin := node.AsBinaryExpression()
1211
+ if bin != nil && bin.OperatorToken != nil && bin.OperatorToken.Kind == shimast.KindPlusToken {
1212
+ out := flattenConcatOperands(bin.Left)
1213
+ out = append(out, flattenConcatOperands(bin.Right)...)
1214
+ return out
1215
+ }
1216
+ }
1217
+ return []*shimast.Node{node}
1218
+ }
1219
+
1220
+ // renderConcatAsTemplate renders the flattened concat operands as a single
1221
+ // backtick template literal. String-like literals contribute their value
1222
+ // directly (with template-specific escaping); any other expression becomes
1223
+ // a `${…}` placeholder copied verbatim from the source text. Returns ok=false
1224
+ // when an operand cannot be rendered (typically because its source range is
1225
+ // unavailable), so the caller falls back to detection-only.
1226
+ func renderConcatAsTemplate(src string, operands []*shimast.Node) (string, bool) {
1227
+ if len(operands) == 0 {
1228
+ return "", false
1229
+ }
1230
+ var sb strings.Builder
1231
+ sb.WriteByte('`')
1232
+ for _, operand := range operands {
1233
+ if operand == nil {
1234
+ return "", false
1235
+ }
1236
+ inner := stripParens(operand)
1237
+ if isStringLikeLiteral(inner) {
1238
+ sb.WriteString(escapeTemplateLiteralBody(stringLiteralText(inner)))
1239
+ continue
1240
+ }
1241
+ pos := shimscanner.SkipTrivia(src, operand.Pos())
1242
+ end := operand.End()
1243
+ if pos < 0 || pos >= end || end > len(src) {
1244
+ return "", false
1245
+ }
1246
+ sb.WriteString("${")
1247
+ sb.WriteString(src[pos:end])
1248
+ sb.WriteByte('}')
1249
+ }
1250
+ sb.WriteByte('`')
1251
+ return sb.String(), true
1252
+ }
1253
+
1254
+ // escapeTemplateLiteralBody escapes the characters that would otherwise
1255
+ // terminate or interpolate a template literal body: backslash, backtick,
1256
+ // and the `${` sequence. Matches the canonical ESLint `prefer-template`
1257
+ // fixer escape set.
1258
+ func escapeTemplateLiteralBody(text string) string {
1259
+ var sb strings.Builder
1260
+ sb.Grow(len(text))
1261
+ for i := 0; i < len(text); i++ {
1262
+ ch := text[i]
1263
+ switch ch {
1264
+ case '\\', '`':
1265
+ sb.WriteByte('\\')
1266
+ sb.WriteByte(ch)
1267
+ case '$':
1268
+ if i+1 < len(text) && text[i+1] == '{' {
1269
+ sb.WriteString("\\$")
1270
+ } else {
1271
+ sb.WriteByte('$')
1272
+ }
1273
+ default:
1274
+ sb.WriteByte(ch)
1275
+ }
1276
+ }
1277
+ return sb.String()
1278
+ }
1279
+
1125
1280
  // require-yield: `function* gen() { return 1; }` — generators that
1126
1281
  // never yield are usually unintended.
1127
1282
  type requireYield struct{}
@@ -71,7 +71,7 @@ func (preferConst) Check(ctx *Context, node *shimast.Node) {
71
71
  if expr == nil || expr.OperatorToken == nil || !isAssignmentOperator(expr.OperatorToken.Kind) {
72
72
  return
73
73
  }
74
- for _, name := range bindingIdentifierNames(expr.Left) {
74
+ for _, name := range assignmentTargetNames(expr.Left) {
75
75
  assigned[name] = true
76
76
  }
77
77
  case shimast.KindPrefixUnaryExpression:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ttsc/lint",
3
- "version": "0.12.4",
3
+ "version": "0.13.0",
4
4
  "description": "Reference ttsc plugin: ESLint-style lint rules hosted in the same Program/Checker as the type-check pass.",
5
5
  "main": "lib/index.js",
6
6
  "types": "lib/index.d.ts",
@@ -33,10 +33,10 @@
33
33
  "src"
34
34
  ],
35
35
  "devDependencies": {
36
- "@typescript/native-preview": "7.0.0-dev.20260519.1",
36
+ "@typescript/native-preview": "7.0.0-dev.20260522.1",
37
37
  "@types/node": "^25.3.0",
38
38
  "rimraf": "^6.1.2",
39
- "ttsc": "0.12.4"
39
+ "ttsc": "0.13.0"
40
40
  },
41
41
  "repository": {
42
42
  "type": "git",