@ttsc/lint 0.25.0 → 0.26.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.
package/README.md CHANGED
@@ -138,7 +138,7 @@ Each `format` key controls one behavior:
138
138
  | `sortImports` (opt-in) | Sort named specifiers and erased type-only imports. Runtime declaration sorting requires `unsafeSortRuntimeImports`. |
139
139
  | `jsDoc` (on by default) | Normalize JSDoc blocks toward [prettier-plugin-jsdoc](https://github.com/hosseinmd/prettier-plugin-jsdoc). |
140
140
 
141
- `sortImports` is **opt-in** — it takes effect only when you set it. Every other key takes effect as soon as the `format` block is present (JSDoc normalization included; set `jsDoc: false` to opt out), which also applies several keyless layout behaviors (statement splitting, indentation, whitespace normalization, clause joining, declaration-header reflow, ternary-nullish parens, leading-semicolon merging, and parameter-property breaking).
141
+ `sortImports` is **opt-in** — it takes effect only when you set it. Every other key takes effect as soon as the `format` block is present (JSDoc normalization included; set `jsDoc: false` to opt out), which also applies several keyless layout behaviors (statement splitting, indentation, whitespace normalization, clause joining, continuation-keyword placement, declaration-header reflow, ternary-nullish parens, leading-semicolon merging, and parameter-property breaking).
142
142
 
143
143
  The safe default preserves the source order of every runtime-bearing import, including default, namespace, named, and bare imports, because each form can evaluate its dependency module. It still alphabetizes named specifiers within one declaration and can sort or merge a block made entirely of erased `import type` declarations. Set `unsafeSortRuntimeImports: true` only when every dependency in the block is order-independent. `combineTypeAndValue` affects a mixed type/value block only under that unsafe opt-in.
144
144
 
@@ -67,52 +67,105 @@ export interface ITtscLintFunctionalNoThrowStatementsRuleOptions {
67
67
  }
68
68
  /** `functional/no-mixed-types` rule options. */
69
69
  export interface ITtscLintFunctionalNoMixedTypesRuleOptions {
70
- /** Check interface member kinds. */
70
+ /**
71
+ * Check interface member kinds.
72
+ *
73
+ * @default true
74
+ */
71
75
  checkInterfaces?: boolean;
72
- /** Check type-literal member kinds. */
76
+ /**
77
+ * Check type-literal member kinds.
78
+ *
79
+ * @default true
80
+ */
73
81
  checkTypeLiterals?: boolean;
74
82
  }
75
83
  /** `functional/no-return-void` rule options. */
76
84
  export interface ITtscLintFunctionalNoReturnVoidRuleOptions {
77
- /** Permit a function that returns `null` to satisfy the rule. */
85
+ /**
86
+ * Permit a function whose declared return type is `null`. Set `false` to
87
+ * reject it the way a declared `void` is rejected.
88
+ *
89
+ * @default true
90
+ */
78
91
  allowNull?: boolean;
79
- /** Permit a function that returns `undefined` to satisfy the rule. */
92
+ /**
93
+ * Permit a function whose declared return type is `undefined`. Set `false` to
94
+ * reject it the way a declared `void` is rejected.
95
+ *
96
+ * @default true
97
+ */
80
98
  allowUndefined?: boolean;
81
99
  /**
82
- * Skip functions whose return type is inferred to be `void` rather than
83
- * declared explicitly.
100
+ * Skip a bare `return;` inside a function that declares no return type. That
101
+ * statement is the one place the rule rejects a void-ness it inferred rather
102
+ * than read from an annotation.
103
+ *
104
+ * @default false
84
105
  */
85
106
  ignoreInferredTypes?: boolean;
86
107
  }
87
108
  /** `functional/prefer-immutable-types` rule options. */
88
109
  export interface ITtscLintFunctionalPreferImmutableTypesRuleOptions extends ITtscLintFunctionalPatternOptions {
89
110
  /**
90
- * Minimum accepted immutability. The native subset treats any configured
111
+ * Minimum accepted immutability. Reserved for upstream-compatible configs;
112
+ * the native subset computes no immutability level and treats any configured
91
113
  * value as readonly-required.
92
114
  */
93
115
  enforcement?: "ReadonlyShallow" | "ReadonlyDeep" | "Immutable" | "None" | false;
94
116
  }
95
117
  /** `functional/prefer-readonly-type` rule options. */
96
118
  export interface ITtscLintFunctionalPreferReadonlyTypeRuleOptions extends ITtscLintFunctionalPatternOptions {
97
- /** Permit mutation of locals while still policing exported types. */
119
+ /**
120
+ * Permit mutation of locals while still policing exported types. Reserved for
121
+ * upstream-compatible configs; the native subset reads type annotations and
122
+ * models no local-versus-exported distinction.
123
+ */
98
124
  allowLocalMutation?: boolean;
99
- /** Permit a mutable return type even when parameters must be readonly. */
125
+ /**
126
+ * Permit a mutable return type even when parameters must be readonly. Covers
127
+ * every signature that can declare one, including call and construct
128
+ * signatures, constructor types, and a get accessor.
129
+ *
130
+ * @default false
131
+ */
100
132
  allowMutableReturnType?: boolean;
101
- /** Also check property positions that have no explicit type annotation. */
133
+ /**
134
+ * Also check property positions that have no explicit type annotation.
135
+ * Reserved for upstream-compatible configs; judging an unannotated position
136
+ * needs the type checker, which this rule does not use.
137
+ */
102
138
  checkImplicit?: boolean;
103
- /** Skip array / tuple / `Map` / `Set` types. */
139
+ /**
140
+ * Skip array / tuple / `Map` / `Set` types.
141
+ *
142
+ * @default false
143
+ */
104
144
  ignoreCollections?: boolean;
105
145
  /**
106
- * Skip class fields. `"fieldsOnly"` keeps the rule active for non-field class
107
- * members.
146
+ * Skip class members. `true` skips anything under a class, its heritage
147
+ * clause and type parameters included; `"fieldsOnly"` narrows that to field
148
+ * declarations and keeps methods, accessors, and constructor parameters
149
+ * checked.
150
+ *
151
+ * @default false
108
152
  */
109
153
  ignoreClass?: boolean | "fieldsOnly";
110
- /** Skip interface members entirely. */
154
+ /**
155
+ * Skip interface members entirely.
156
+ *
157
+ * @default false
158
+ */
111
159
  ignoreInterface?: boolean;
112
160
  }
113
161
  /** `functional/prefer-tacit` rule options. */
114
162
  export interface ITtscLintFunctionalPreferTacitRuleOptions {
115
- /** Check member expressions such as `x => service.map(x)`. */
163
+ /**
164
+ * Check member expressions such as `x => service.map(x)`. Set `false` to keep
165
+ * the rule on bare-identifier callees only.
166
+ *
167
+ * @default true
168
+ */
116
169
  checkMemberExpressions?: boolean;
117
170
  }
118
171
  /** `functional/readonly-type` rule options. */
@@ -133,7 +186,11 @@ export interface ITtscLintFunctionalTypeDeclarationImmutabilityRule {
133
186
  * every value as readonly-required.
134
187
  */
135
188
  immutability?: "ReadonlyShallow" | "ReadonlyDeep" | "Immutable" | "Mutable";
136
- /** Comparator applied to the immutability level above. */
189
+ /**
190
+ * Comparator applied to the immutability level above. Reserved for
191
+ * upstream-compatible configs alongside `immutability`: the native subset
192
+ * computes no immutability level, so there is nothing to compare.
193
+ */
137
194
  comparator?: "Less" | "AtMost" | "Exactly" | "AtLeast" | "More" | -2 | -1 | 0 | 1 | 2;
138
195
  }
139
196
  /** `functional/type-declaration-immutability` rule options. */
@@ -29,6 +29,7 @@ import (
29
29
  // - `format/trailing-comma`, always on with the requested mode.
30
30
  // - `format/print-width`, always on, driven by printWidth/tabWidth/useTabs/endOfLine.
31
31
  // - `format/clause-join`, always on, joins a single-statement clause body that fits printWidth.
32
+ // - `format/brace-continuation`, always on, places `else`/`catch`/`finally`/do-`while` against the clause they continue.
32
33
  // - `format/declaration-header`, always on, reflows a class/interface header's type params and heritage clauses.
33
34
  // - `format/ternary-nullish-parens`, always on, parenthesizes a `??` operand of a conditional expression.
34
35
  // - `format/orphan-semi`, always on; under semi:false, merges a leading-semicolon ASI guard onto its statement.
@@ -220,6 +221,12 @@ func expandFormatBlock(raw map[string]any) (map[string]any, error) {
220
221
  }
221
222
  out["format/whitespace"] = ruleEntry(wsOpts)
222
223
 
224
+ // formatBraceContinuation, always on. Needs only endOfLine: the pushed-down
225
+ // direction copies the owning statement's own indent verbatim rather than
226
+ // synthesizing one, so it shares the whitespace rule's trimmed surface rather
227
+ // than the layout one.
228
+ out["format/brace-continuation"] = ruleEntry(cloneStringAnyMap(wsOpts))
229
+
223
230
  // formatSortImports, opt-in by `sortImports` (a boolean or options object).
224
231
  // The top-level `endOfLine` is threaded in (like the other layout rules)
225
232
  // so the rebuilt import block joins declarations with the file's line
@@ -337,18 +337,15 @@ func directivePayload(text, marker string) (string, bool) {
337
337
  return strings.TrimSpace(rest), true
338
338
  }
339
339
 
340
- // parseDirectiveRules converts the payload (the text after the directive
341
- // marker) into a lintDirectiveRules value. The `--` separator strips an
342
- // optional human-readable description. An empty rule list means "all rules".
343
- func parseDirectiveRules(payload string) lintDirectiveRules {
344
- return parseDirectivePayload(payload).rules
345
- }
346
-
347
340
  type parsedDirectivePayload struct {
348
341
  rules lintDirectiveRules
349
342
  ruleList []string
350
343
  }
351
344
 
345
+ // parseDirectivePayload converts the payload (the text after the directive
346
+ // marker) into the directive's rule scope plus the raw list the engine reports
347
+ // unknown names from. The `--` separator strips an optional human-readable
348
+ // description. An empty rule list means "all rules".
352
349
  func parseDirectivePayload(payload string) parsedDirectivePayload {
353
350
  ruleText := stripDirectiveDescription(payload)
354
351
  ruleText = strings.ReplaceAll(ruleText, ",", " ")
package/linthost/fix.go CHANGED
@@ -289,10 +289,12 @@ func selectTextEdits(sourceLen int, edits []TextEdit) []TextEdit {
289
289
  // insert at that same offset must be dropped: two coincident inserts both
290
290
  // pass the `edit.Pos < lastEnd` gate, then apply in reverse sort order and
291
291
  // concatenate at one point — silently corrupting the source (e.g. a `;`
292
- // insert and a `\n` insert at EOF yielding `\n;`). The host contract keeps
293
- // one winner and drops the rest (see rule.TextEdit). A zero-width insert
294
- // sitting at the end of a prior NON-empty edit is left alone: it applies
295
- // cleanly after the replacement and is a legitimate adjacency.
292
+ // insert and a `\n` insert at EOF yielding `\n;`). This edit-level selector
293
+ // keeps one winner and drops the rest; selectTextEditGroups turns that drop
294
+ // into a whole-finding skip, which is the contract rule.TextEdit states. A
295
+ // zero-width insert sitting at the end of a prior NON-empty edit is left
296
+ // alone: it applies cleanly after the replacement and is a legitimate
297
+ // adjacency.
296
298
  lastInsertAt := -1
297
299
  for _, edit := range sorted {
298
300
  if edit.Pos < lastEnd {
@@ -29,6 +29,7 @@
29
29
  "eqeqeq": 15647,
30
30
  "for-direction": 11936,
31
31
  "format/arrow-parens": 12778,
32
+ "format/brace-continuation": 11770,
32
33
  "format/bracket-spacing": 17869,
33
34
  "format/clause-join": 16481,
34
35
  "format/declaration-header": 11817,
@@ -0,0 +1,267 @@
1
+ package linthost
2
+
3
+ import (
4
+ "strings"
5
+
6
+ shimast "github.com/microsoft/typescript-go/shim/ast"
7
+ shimscanner "github.com/microsoft/typescript-go/shim/scanner"
8
+ )
9
+
10
+ // formatBraceContinuation places a continuation keyword against the clause it
11
+ // continues, mirroring Prettier. The rule is one decision read in two
12
+ // directions, taken from the shape of the preceding clause:
13
+ //
14
+ // - the clause is a block, so the keyword shares its closing brace's line:
15
+ // `} else {`, `} catch (e) {`, `} finally {`, `} while (ready);`
16
+ // - the clause is not a block, so the keyword starts its own line:
17
+ // `if (a) x();` then `else y();`, and `do tick();` then `while (ready);`
18
+ //
19
+ // Both halves belong to one rule because they are the same decision. Splitting
20
+ // them across two owners lets a source satisfy neither, which is what the
21
+ // formatter did before: it never touched this boundary at all, so an
22
+ // Allman-braced file survived `ttsc format` unchanged and a one-line
23
+ // `if (a) x(); else y();` stayed on one line.
24
+ //
25
+ // The rule rewrites only the gap between the preceding clause's last byte and
26
+ // the continuation keyword's first byte. No structural format rule contends for
27
+ // it: `format/clause-join` rewrites the gap AFTER a header token,
28
+ // `format/statement-split` splits statement-list members, and `format/indent`
29
+ // visits statement-list members, closing-brace lines, and member headers.
30
+ // `format/whitespace`'s trailing-whitespace trim does land inside the gap when
31
+ // the clause line ends in spaces; the host drops one of the two findings and the
32
+ // survivor re-fires on the next cascade pass, so the file still converges.
33
+ //
34
+ // A comment in the gap makes the rule abstain rather than relocate it, the same
35
+ // abstention `format/clause-join` and `format/statement-split` apply. Idempotent
36
+ // in both directions: once the keyword sits where it belongs the gap already
37
+ // holds the target text and the rule emits nothing.
38
+ type formatBraceContinuation struct{ optionsRule }
39
+
40
+ // formatBraceContinuationOptions carries only the EOL setting. The push-down
41
+ // direction copies the owning statement's own indent verbatim rather than
42
+ // synthesizing one, so tabWidth and useTabs would decide nothing here and the
43
+ // rule takes the same trimmed option surface `format/whitespace` does. The JSON
44
+ // tag matches the `format` block key the config layer mirrors in (see
45
+ // expandFormatBlock).
46
+ type formatBraceContinuationOptions struct {
47
+ EndOfLine *string `json:"endOfLine"`
48
+ }
49
+
50
+ func (formatBraceContinuation) Name() string { return "format/brace-continuation" }
51
+ func (formatBraceContinuation) IsFormat() bool { return true }
52
+
53
+ func (formatBraceContinuation) Visits() []shimast.Kind {
54
+ return []shimast.Kind{
55
+ shimast.KindIfStatement,
56
+ shimast.KindTryStatement,
57
+ shimast.KindDoStatement,
58
+ }
59
+ }
60
+
61
+ // braceContinuation is one keyword placement: the clause that precedes it and
62
+ // the keyword that continues it.
63
+ type braceContinuation struct {
64
+ // previous is the clause the keyword continues. Its `End()` is the left edge
65
+ // of the gap, and whether it is a block decides the direction.
66
+ previous *shimast.Node
67
+ // keyword is the exact lexeme expected at the right edge of the gap. The
68
+ // following node cannot bound the scan: a catch clause and a finally block
69
+ // both start at the preceding clause's end, so their Pos() is the gap's left
70
+ // edge, not its right. Matching the lexeme instead is also what makes a
71
+ // comment in the gap detectable, since the first non-whitespace byte is then
72
+ // the comment rather than the keyword.
73
+ keyword string
74
+ }
75
+
76
+ func (formatBraceContinuation) Check(ctx *Context, node *shimast.Node) {
77
+ if ctx == nil || ctx.File == nil || node == nil {
78
+ return
79
+ }
80
+ var opts formatBraceContinuationOptions
81
+ _ = ctx.DecodeOptions(&opts)
82
+ eol := "\n"
83
+ if opts.EndOfLine != nil && *opts.EndOfLine == "crlf" {
84
+ eol = "\r\n"
85
+ }
86
+ src := ctx.File.Text()
87
+ for _, continuation := range braceContinuations(node) {
88
+ placeBraceContinuation(ctx, src, node, continuation, eol)
89
+ }
90
+ }
91
+
92
+ // braceContinuations returns every continuation keyword `node` carries, paired
93
+ // with the clause it continues.
94
+ func braceContinuations(node *shimast.Node) []braceContinuation {
95
+ switch node.Kind {
96
+ case shimast.KindIfStatement:
97
+ stmt := node.AsIfStatement()
98
+ if stmt == nil || stmt.ElseStatement == nil || stmt.ThenStatement == nil {
99
+ return nil
100
+ }
101
+ return []braceContinuation{{previous: stmt.ThenStatement, keyword: "else"}}
102
+ case shimast.KindTryStatement:
103
+ stmt := node.AsTryStatement()
104
+ if stmt == nil || stmt.TryBlock == nil {
105
+ return nil
106
+ }
107
+ out := make([]braceContinuation, 0, 2)
108
+ previous := stmt.TryBlock.AsNode()
109
+ if stmt.CatchClause != nil {
110
+ out = append(out, braceContinuation{previous: previous, keyword: "catch"})
111
+ previous = stmt.CatchClause
112
+ }
113
+ if stmt.FinallyBlock != nil {
114
+ out = append(out, braceContinuation{previous: previous, keyword: "finally"})
115
+ }
116
+ return out
117
+ case shimast.KindDoStatement:
118
+ stmt := node.AsDoStatement()
119
+ if stmt == nil || stmt.Statement == nil || stmt.Expression == nil {
120
+ return nil
121
+ }
122
+ return []braceContinuation{{previous: stmt.Statement, keyword: "while"}}
123
+ }
124
+ return nil
125
+ }
126
+
127
+ func placeBraceContinuation(
128
+ ctx *Context,
129
+ src string,
130
+ node *shimast.Node,
131
+ continuation braceContinuation,
132
+ eol string,
133
+ ) {
134
+ previous := continuation.previous
135
+ if previous == nil {
136
+ return
137
+ }
138
+ gapStart := previous.End()
139
+ if gapStart < 0 || gapStart > len(src) {
140
+ return
141
+ }
142
+ // The gap is the whitespace run after the preceding clause. The bytes that
143
+ // follow it must be the continuation keyword itself: a comment there is
144
+ // content the rewrite would delete, and it shows up as a first non-whitespace
145
+ // byte that is not the keyword.
146
+ keywordStart := gapStart
147
+ for keywordStart < len(src) && isBraceContinuationGapByte(src[keywordStart]) {
148
+ keywordStart++
149
+ }
150
+ if !braceContinuationKeywordAt(src, keywordStart, continuation.keyword) {
151
+ return
152
+ }
153
+
154
+ want := " "
155
+ if !braceContinuationEndsInBlock(previous) {
156
+ // A non-block clause pushes the keyword onto its own line, indented to the
157
+ // statement that owns it. The whole gap is the target, so a keyword already
158
+ // on its own line at the wrong column is corrected by the same comparison
159
+ // instead of being ceded to a rule that never visits that line.
160
+ indent, ok := braceContinuationIndent(src, node)
161
+ if !ok {
162
+ return
163
+ }
164
+ want = eol + indent
165
+ }
166
+ if src[gapStart:keywordStart] == want {
167
+ return
168
+ }
169
+ ctx.ReportRangeFix(
170
+ gapStart,
171
+ keywordStart,
172
+ "Continuation keyword should sit against the clause it continues.",
173
+ TextEdit{Pos: gapStart, End: keywordStart, Text: want},
174
+ )
175
+ }
176
+
177
+ // braceContinuationIndent returns the leading whitespace of the line the
178
+ // statement starts on, which is the column Prettier gives the pushed-down
179
+ // keyword, and reports whether that column is knowable yet.
180
+ //
181
+ // A statement sharing its line with something else has no column of its own,
182
+ // and nothing would repair a keyword pushed to column zero: `format/indent`
183
+ // visits statement-list members, closing-brace lines, and member headers, and a
184
+ // continuation-keyword line is none of the three. So the rule abstains, lets
185
+ // `format/statement-split` give the statement its own line first, and reads the
186
+ // real column on the next cascade pass. `format/indent` takes the same
187
+ // not-first-on-its-line abstention for the same reason.
188
+ func braceContinuationIndent(src string, node *shimast.Node) (string, bool) {
189
+ start := shimscanner.SkipTrivia(src, node.Pos())
190
+ if start < 0 || start > len(src) {
191
+ return "", false
192
+ }
193
+ lineStart := lineStartOffset(src, start)
194
+ indentEnd := lineStart
195
+ for indentEnd < start && (src[indentEnd] == ' ' || src[indentEnd] == '\t') {
196
+ indentEnd++
197
+ }
198
+ // A label prefix is part of the statement's own line rather than another
199
+ // statement sharing it, and nothing downstream would ever give a labeled body
200
+ // its own line, so refusing here would strand the keyword permanently.
201
+ if !isBraceContinuationLabelPrefix(src[indentEnd:start]) {
202
+ return "", false
203
+ }
204
+ return src[lineStart:indentEnd], true
205
+ }
206
+
207
+ // isBraceContinuationLabelPrefix reports whether `prefix` is empty or a run of
208
+ // `identifier :` labels, the only thing allowed to precede a statement on the
209
+ // line whose indent the pushed-down keyword adopts.
210
+ func isBraceContinuationLabelPrefix(prefix string) bool {
211
+ for {
212
+ prefix = strings.TrimLeft(prefix, " \t")
213
+ if prefix == "" {
214
+ return true
215
+ }
216
+ name := 0
217
+ for name < len(prefix) && isBraceContinuationIdentifierByte(prefix[name]) {
218
+ name++
219
+ }
220
+ if name == 0 {
221
+ return false
222
+ }
223
+ rest := strings.TrimLeft(prefix[name:], " \t")
224
+ if !strings.HasPrefix(rest, ":") {
225
+ return false
226
+ }
227
+ prefix = rest[1:]
228
+ }
229
+ }
230
+
231
+ // braceContinuationEndsInBlock reports whether the keyword shares the preceding
232
+ // clause's last line, which is what decides the direction. The predicate is the
233
+ // clause's kind, not its last byte: a `switch` consequent and an Annex-B
234
+ // `function` consequent both end in `}` and Prettier still pushes `else` onto
235
+ // its own line after them. A catch clause is the one non-block kind that shares
236
+ // its line, because the try printer always spaces `} finally`.
237
+ func braceContinuationEndsInBlock(previous *shimast.Node) bool {
238
+ return previous.Kind == shimast.KindBlock || previous.Kind == shimast.KindCatchClause
239
+ }
240
+
241
+ // braceContinuationKeywordAt reports whether `keyword` starts at `offset` as a
242
+ // whole token. The trailing boundary matters: `finally` must not match the
243
+ // `final` prefix of an identifier a comment left exposed.
244
+ func braceContinuationKeywordAt(src string, offset int, keyword string) bool {
245
+ end := offset + len(keyword)
246
+ if offset < 0 || end > len(src) || src[offset:end] != keyword {
247
+ return false
248
+ }
249
+ return end == len(src) || !isBraceContinuationIdentifierByte(src[end])
250
+ }
251
+
252
+ // isBraceContinuationIdentifierByte reports whether `c` can appear inside an
253
+ // identifier, so a keyword is only accepted as a whole token.
254
+ func isBraceContinuationIdentifierByte(c byte) bool {
255
+ return c == 0x5f || c == 0x24 ||
256
+ (c >= 0x61 && c <= 0x7a) || (c >= 0x41 && c <= 0x5a) || (c >= 0x30 && c <= 0x39)
257
+ }
258
+
259
+ // isBraceContinuationGapByte reports whether `c` is whitespace that may appear
260
+ // between a clause and the keyword continuing it.
261
+ func isBraceContinuationGapByte(c byte) bool {
262
+ return c == ' ' || c == '\t' || c == '\r' || c == '\n'
263
+ }
264
+
265
+ func init() {
266
+ Register(formatBraceContinuation{})
267
+ }