@ttsc/lint 0.10.2 → 0.11.0-dev.20260518

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 (113) hide show
  1. package/README.md +172 -33
  2. package/lib/defineConfig.d.ts +55 -0
  3. package/lib/defineConfig.js +39 -0
  4. package/lib/defineConfig.js.map +1 -0
  5. package/lib/index.d.ts +25 -1
  6. package/lib/index.js +622 -2
  7. package/lib/index.js.map +1 -1
  8. package/lib/structures/ITtscLintPlugin.d.ts +53 -0
  9. package/lib/structures/ITtscLintPlugin.js +3 -0
  10. package/lib/structures/ITtscLintPlugin.js.map +1 -0
  11. package/lib/structures/ITtscLintPluginConfig.d.ts +82 -5
  12. package/lib/structures/ITtscLintPluginMeta.d.ts +12 -0
  13. package/lib/structures/ITtscLintPluginMeta.js +3 -0
  14. package/lib/structures/ITtscLintPluginMeta.js.map +1 -0
  15. package/lib/structures/PluginRuleNames.d.ts +14 -0
  16. package/lib/structures/PluginRuleNames.js +3 -0
  17. package/lib/structures/PluginRuleNames.js.map +1 -0
  18. package/lib/structures/TtscLintConfig.d.ts +24 -8
  19. package/lib/structures/TtscLintConfigEntry.d.ts +39 -0
  20. package/lib/structures/TtscLintConfigEntry.js +3 -0
  21. package/lib/structures/TtscLintConfigEntry.js.map +1 -0
  22. package/lib/structures/TtscLintFormatConfig.d.ts +154 -0
  23. package/lib/structures/TtscLintFormatConfig.js +3 -0
  24. package/lib/structures/TtscLintFormatConfig.js.map +1 -0
  25. package/lib/structures/TtscLintPlugins.d.ts +9 -0
  26. package/lib/structures/TtscLintPlugins.js +3 -0
  27. package/lib/structures/TtscLintPlugins.js.map +1 -0
  28. package/lib/structures/TtscLintRule.d.ts +1 -1
  29. package/lib/structures/TtscLintRuleEntry.d.ts +22 -0
  30. package/lib/structures/TtscLintRuleEntry.js +3 -0
  31. package/lib/structures/TtscLintRuleEntry.js.map +1 -0
  32. package/lib/structures/TtscLintRuleMap.d.ts +44 -0
  33. package/lib/structures/TtscLintRuleMap.js +3 -0
  34. package/lib/structures/TtscLintRuleMap.js.map +1 -0
  35. package/lib/structures/TtscLintRuleOptions.d.ts +149 -0
  36. package/lib/structures/TtscLintRuleOptions.js +3 -0
  37. package/lib/structures/TtscLintRuleOptions.js.map +1 -0
  38. package/lib/structures/defaultFormat.d.ts +31 -0
  39. package/lib/structures/defaultFormat.js +35 -0
  40. package/lib/structures/defaultFormat.js.map +1 -0
  41. package/lib/structures/index.d.ts +10 -0
  42. package/lib/structures/index.js +10 -0
  43. package/lib/structures/index.js.map +1 -1
  44. package/{plugin → linthost}/ast_helpers.go +92 -1
  45. package/{plugin → linthost}/compile.go +1 -1
  46. package/{plugin → linthost}/config.go +583 -66
  47. package/linthost/config_format.go +360 -0
  48. package/linthost/contrib_adapter.go +134 -0
  49. package/{plugin → linthost}/directives.go +1 -1
  50. package/linthost/dispatch.go +65 -0
  51. package/{plugin → linthost}/engine.go +156 -11
  52. package/{plugin → linthost}/eslint_runtime.go +76 -2
  53. package/linthost/fix.go +249 -0
  54. package/linthost/format.go +107 -0
  55. package/{plugin → linthost}/host.go +1 -1
  56. package/linthost/print_dispatch.go +118 -0
  57. package/linthost/print_doc.go +168 -0
  58. package/linthost/print_engine.go +343 -0
  59. package/linthost/print_nodes_array.go +46 -0
  60. package/linthost/print_nodes_call.go +180 -0
  61. package/linthost/print_nodes_imports.go +171 -0
  62. package/linthost/print_nodes_list.go +64 -0
  63. package/linthost/print_nodes_object.go +51 -0
  64. package/{plugin → linthost}/rules_arrays.go +1 -1
  65. package/{plugin → linthost}/rules_console.go +1 -1
  66. package/{plugin → linthost}/rules_debugger.go +1 -1
  67. package/{plugin → linthost}/rules_dupes.go +1 -1
  68. package/{plugin → linthost}/rules_empty.go +1 -1
  69. package/linthost/rules_escape.go +238 -0
  70. package/{plugin → linthost}/rules_eval.go +1 -1
  71. package/{plugin → linthost}/rules_finally.go +1 -1
  72. package/linthost/rules_format_jsdoc.go +230 -0
  73. package/linthost/rules_format_print_width.go +332 -0
  74. package/linthost/rules_format_quotes.go +219 -0
  75. package/linthost/rules_format_semi.go +190 -0
  76. package/linthost/rules_format_sort_imports.go +462 -0
  77. package/linthost/rules_format_trailing_comma.go +460 -0
  78. package/{plugin → linthost}/rules_gap.go +101 -6
  79. package/linthost/rules_imports.go +101 -0
  80. package/{plugin → linthost}/rules_logic.go +55 -3
  81. package/{plugin → linthost}/rules_loops.go +1 -1
  82. package/{plugin → linthost}/rules_misc.go +1 -1
  83. package/linthost/rules_params.go +64 -0
  84. package/{plugin → linthost}/rules_problems.go +10 -2
  85. package/linthost/rules_promise.go +111 -0
  86. package/{plugin → linthost}/rules_protos.go +1 -1
  87. package/{plugin → linthost}/rules_self.go +1 -1
  88. package/{plugin → linthost}/rules_strings.go +1 -1
  89. package/{plugin → linthost}/rules_suggestions.go +51 -9
  90. package/{plugin → linthost}/rules_throw.go +1 -1
  91. package/{plugin → linthost}/rules_ts.go +12 -2
  92. package/{plugin → linthost}/rules_ts_extra.go +90 -4
  93. package/{plugin → linthost}/rules_var.go +36 -5
  94. package/package.json +8 -6
  95. package/plugin/main.go +14 -27
  96. package/rule/astutil/astutil.go +148 -0
  97. package/rule/rule.go +291 -0
  98. package/src/defineConfig.ts +69 -0
  99. package/src/index.ts +807 -3
  100. package/src/structures/ITtscLintPlugin.ts +56 -0
  101. package/src/structures/ITtscLintPluginConfig.ts +86 -5
  102. package/src/structures/ITtscLintPluginMeta.ts +14 -0
  103. package/src/structures/PluginRuleNames.ts +19 -0
  104. package/src/structures/TtscLintConfig.ts +27 -8
  105. package/src/structures/TtscLintConfigEntry.ts +50 -0
  106. package/src/structures/TtscLintFormatConfig.ts +169 -0
  107. package/src/structures/TtscLintPlugins.ts +10 -0
  108. package/src/structures/TtscLintRule.ts +14 -1
  109. package/src/structures/TtscLintRuleEntry.ts +28 -0
  110. package/src/structures/TtscLintRuleMap.ts +52 -0
  111. package/src/structures/TtscLintRuleOptions.ts +162 -0
  112. package/src/structures/defaultFormat.ts +33 -0
  113. package/src/structures/index.ts +10 -0
package/plugin/main.go CHANGED
@@ -1,11 +1,15 @@
1
1
  // Command @ttsc/lint is the native backend for the `@ttsc/lint` plugin.
2
2
  //
3
- // The plugin host (ttsc / ttsx) spawns this binary with one of four
3
+ // The plugin host (ttsc / ttsx) spawns this binary with one of five
4
4
  // subcommands:
5
5
  //
6
6
  // - `version` / `-v` / `--version` — print the binary banner.
7
7
  // - `check` — typecheck + lint without emit. Failure exit code if any
8
8
  // error-severity diagnostic fires.
9
+ // - `fix` — apply lint-rule autofixes, then typecheck + lint without
10
+ // emit.
11
+ // - `format` — apply format-rule edits only. Write-only: no diagnostic
12
+ // output, no typecheck recheck.
9
13
  // - `build` — typecheck + lint, then run the standard tsgo emit pipeline
10
14
  // so JS files land on disk.
11
15
  // - `transform --file=PATH` — single-file emit with the same lint pass.
@@ -13,36 +17,19 @@
13
17
  // Diagnostics share the renderer with tsgo's own output, so warnings come
14
18
  // out yellow, errors come out red, and the trailing `Found N errors`
15
19
  // summary is consistent with `tsc --noEmit`.
20
+ //
21
+ // Behavior lives in the sibling `linthost` library package. This binary is a
22
+ // thin wrapper so out-of-process consumers (the native CLI here) and
23
+ // in-process consumers (the ttsc.dev playground wasm) share the same
24
+ // dispatch surface.
16
25
  package main
17
26
 
18
27
  import (
19
- "fmt"
20
- "os"
21
- )
28
+ "os"
22
29
 
23
- const version = "0.0.1"
30
+ "github.com/samchon/ttsc/packages/lint/linthost"
31
+ )
24
32
 
25
33
  func main() {
26
- os.Exit(run(os.Args[1:]))
27
- }
28
-
29
- func run(args []string) int {
30
- if len(args) == 0 {
31
- fmt.Fprintln(os.Stderr, "@ttsc/lint: command required (expected check|build|transform|version)")
32
- return 2
33
- }
34
- switch args[0] {
35
- case "-v", "--version", "version":
36
- fmt.Fprintf(os.Stdout, "@ttsc/lint %s\n", version)
37
- return 0
38
- case "check":
39
- return RunCheck(args[1:])
40
- case "build":
41
- return RunBuild(args[1:])
42
- case "transform":
43
- return RunTransform(args[1:])
44
- default:
45
- fmt.Fprintf(os.Stderr, "@ttsc/lint: unknown command %q\n", args[0])
46
- return 2
47
- }
34
+ os.Exit(linthost.Main(os.Args[1:]))
48
35
  }
@@ -0,0 +1,148 @@
1
+ // Package astutil exposes the AST/text helpers `@ttsc/lint` built-in rules
2
+ // already rely on, so third-party contributor rules can build autofixes
3
+ // without re-implementing trivia, keyword location, or token-range math.
4
+ //
5
+ // These helpers are deliberately byte-oriented to match the
6
+ // `rule.TextEdit` contract: positions returned from this package can be
7
+ // fed directly into a `TextEdit{Pos, End, Text}` literal.
8
+ //
9
+ // All functions are pure (no shared state) and safe to call from any
10
+ // goroutine — `*shimast.SourceFile` and `*shimast.Node` are read-only
11
+ // from the rule's perspective.
12
+ package astutil
13
+
14
+ import (
15
+ "strings"
16
+
17
+ shimast "github.com/microsoft/typescript-go/shim/ast"
18
+ shimscanner "github.com/microsoft/typescript-go/shim/scanner"
19
+ )
20
+
21
+ // NodeText returns the source text under `node` with leading trivia
22
+ // (whitespace + comments) stripped. Mirrors `nodeText` in the built-in
23
+ // engine — useful for rules that compare textual identity (the
24
+ // `no-self-assign` / `no-self-compare` shape) or that splice a sub-node's
25
+ // text into a fix string.
26
+ //
27
+ // Returns "" when `file` or `node` is nil, or when the computed range
28
+ // falls outside the file (defensive — shouldn't happen for engine-supplied
29
+ // nodes).
30
+ func NodeText(file *shimast.SourceFile, node *shimast.Node) string {
31
+ if file == nil || node == nil {
32
+ return ""
33
+ }
34
+ src := file.Text()
35
+ end := node.End()
36
+ pos := shimscanner.SkipTrivia(src, node.Pos())
37
+ if pos < 0 || end > len(src) || pos >= end {
38
+ return ""
39
+ }
40
+ return strings.TrimRight(src[pos:end], " \t\r\n")
41
+ }
42
+
43
+ // KeywordStart returns the source offset of a declaration keyword such as
44
+ // `var`, `let`, `const`, `module`, `namespace`, or `function` that lives
45
+ // at the start of `node` (after leading trivia). Returns -1 if not found.
46
+ //
47
+ // Use this to anchor TextEdits that swap a leading keyword:
48
+ //
49
+ // start := astutil.KeywordStart(file, node, "let")
50
+ // if start >= 0 {
51
+ // ctx.ReportFix(node, "use const",
52
+ // rule.TextEdit{Pos: start, End: start + len("let"), Text: "const"})
53
+ // }
54
+ func KeywordStart(file *shimast.SourceFile, node *shimast.Node, keyword string) int {
55
+ if file == nil || node == nil || keyword == "" {
56
+ return -1
57
+ }
58
+ src := file.Text()
59
+ pos := shimscanner.SkipTrivia(src, node.Pos())
60
+ end := pos + len(keyword)
61
+ if pos < 0 || end > len(src) {
62
+ return -1
63
+ }
64
+ if strings.HasPrefix(src[pos:], keyword) && (end == len(src) || !isIdentifierPart(src[end])) {
65
+ return pos
66
+ }
67
+ limit := node.End()
68
+ if limit > len(src) {
69
+ limit = len(src)
70
+ }
71
+ for i := pos; i+len(keyword) <= limit && i < pos+32; i++ {
72
+ end = i + len(keyword)
73
+ if strings.HasPrefix(src[i:], keyword) &&
74
+ (i == 0 || !isIdentifierPart(src[i-1])) &&
75
+ (end == len(src) || !isIdentifierPart(src[end])) {
76
+ return i
77
+ }
78
+ }
79
+ return -1
80
+ }
81
+
82
+ // FindKeyword scans `[pos, end)` for a keyword token whose lexeme is
83
+ // `keyword` and returns its first-byte offset, or -1 if not found.
84
+ // Differs from KeywordStart in that it works on an arbitrary byte range
85
+ // instead of a node's leading-trivia-adjusted start — use this for fixes
86
+ // that need to splice text after `import` or before `from`.
87
+ //
88
+ // The match is identifier-aware: a hit must be flanked by non-identifier
89
+ // bytes (or file edges) so that searching for `import` does not match
90
+ // the `import` prefix of `importMap`.
91
+ func FindKeyword(file *shimast.SourceFile, pos, end int, keyword string) int {
92
+ if file == nil || keyword == "" {
93
+ return -1
94
+ }
95
+ src := file.Text()
96
+ if pos < 0 {
97
+ pos = 0
98
+ }
99
+ if end > len(src) {
100
+ end = len(src)
101
+ }
102
+ limit := end - len(keyword)
103
+ for i := pos; i <= limit; i++ {
104
+ if src[i] != keyword[0] {
105
+ continue
106
+ }
107
+ tail := i + len(keyword)
108
+ if src[i:tail] != keyword {
109
+ continue
110
+ }
111
+ if i > 0 && isIdentifierPart(src[i-1]) {
112
+ continue
113
+ }
114
+ if tail < len(src) && isIdentifierPart(src[tail]) {
115
+ continue
116
+ }
117
+ return i
118
+ }
119
+ return -1
120
+ }
121
+
122
+ // TokenRange returns the `[pos, end)` range of a node's primary token
123
+ // with leading trivia stripped from the start. Useful for rules that
124
+ // want their diagnostic and fix range aligned to the token rather than
125
+ // the surrounding whitespace.
126
+ //
127
+ // Returns `(-1, -1)` when `file` or `node` is nil or when the computed
128
+ // range is malformed.
129
+ func TokenRange(file *shimast.SourceFile, node *shimast.Node) (int, int) {
130
+ if file == nil || node == nil {
131
+ return -1, -1
132
+ }
133
+ src := file.Text()
134
+ pos := shimscanner.SkipTrivia(src, node.Pos())
135
+ end := node.End()
136
+ if pos < 0 || pos > len(src) || end < pos || end > len(src) {
137
+ return -1, -1
138
+ }
139
+ return pos, end
140
+ }
141
+
142
+ func isIdentifierPart(ch byte) bool {
143
+ return (ch >= 'a' && ch <= 'z') ||
144
+ (ch >= 'A' && ch <= 'Z') ||
145
+ (ch >= '0' && ch <= '9') ||
146
+ ch == '_' ||
147
+ ch == '$'
148
+ }
package/rule/rule.go ADDED
@@ -0,0 +1,291 @@
1
+ // Package rule is the public API for `@ttsc/lint` rule contributors.
2
+ //
3
+ // Third-party lint rule packages ("contributors") import this package and
4
+ // register their rules in an `init()`. At build time, ttsc copies a
5
+ // contributor's Go source into a sub-package of the `@ttsc/lint` Go module
6
+ // and synthesizes a blank-import in the host binary, which triggers the
7
+ // contributor's `init()` and populates the registry below.
8
+ //
9
+ // The host (`@ttsc/lint`) walks this registry during engine bootstrap and
10
+ // adapts each contributor rule onto the same dispatch table that drives
11
+ // the built-in rules.
12
+ //
13
+ // Contributors operate on the same shim AST the host's first-party
14
+ // plugins use (`github.com/microsoft/typescript-go/shim/ast` and friends)
15
+ // — there is no facade layer in between. The shim packages are the
16
+ // publicly maintained boundary ttsc already exposes; adding another
17
+ // wrapper here would duplicate that maintenance burden without earning
18
+ // any extra stability. Contributors get the full AST surface the host
19
+ // has, so authoring a contributor rule and authoring a built-in rule are
20
+ // the same exercise.
21
+ //
22
+ // Example contributor:
23
+ //
24
+ // package myrules
25
+ //
26
+ // import (
27
+ // shimast "github.com/microsoft/typescript-go/shim/ast"
28
+ // "github.com/samchon/ttsc/packages/lint/rule"
29
+ // )
30
+ //
31
+ // func init() { rule.Register(noTodoComment{}) }
32
+ //
33
+ // type noTodoComment struct{}
34
+ //
35
+ // func (noTodoComment) Name() string { return "demo/no-todo-comment" }
36
+ // func (noTodoComment) Visits() []shimast.Kind { return []shimast.Kind{shimast.KindSourceFile} }
37
+ // func (noTodoComment) Check(ctx *rule.Context, node *shimast.Node) {
38
+ // // ctx.File, ctx.Checker, ctx.Severity available; ctx.Report(node, msg)
39
+ // // or ctx.ReportRange(pos, end, msg) push a finding through the engine.
40
+ // }
41
+ package rule
42
+
43
+ import (
44
+ "encoding/json"
45
+
46
+ shimast "github.com/microsoft/typescript-go/shim/ast"
47
+ shimchecker "github.com/microsoft/typescript-go/shim/checker"
48
+ )
49
+
50
+ // Severity mirrors the engine's three-level severity ladder. The
51
+ // constants are kept value-compatible with the engine's internal
52
+ // `Severity` type so the adapter layer can cast safely.
53
+ type Severity int
54
+
55
+ const (
56
+ // SeverityOff means the rule is disabled. Engine skips dispatch.
57
+ SeverityOff Severity = iota
58
+ // SeverityWarn produces a warning diagnostic (does not change exit
59
+ // code).
60
+ SeverityWarn
61
+ // SeverityError produces an error diagnostic and fails the command.
62
+ SeverityError
63
+ )
64
+
65
+ // Rule is the contract every contributor rule satisfies. Mirrors the
66
+ // internal host interface so the host can dispatch via a thin adapter
67
+ // without re-implementing the engine.
68
+ type Rule interface {
69
+ // Name is the identifier users put in their `rules` map.
70
+ // Conventionally namespaced as "<plugin-namespace>/<rule-name>" to
71
+ // avoid colliding with built-in rule names.
72
+ Name() string
73
+
74
+ // Visits returns the AST kinds the rule cares about. The engine only
75
+ // dispatches to rules that registered for the visited node's kind.
76
+ Visits() []shimast.Kind
77
+
78
+ // Check is invoked once per relevant node. Use `ctx.Report` /
79
+ // `ctx.ReportRange` to emit findings.
80
+ Check(ctx *Context, node *shimast.Node)
81
+ }
82
+
83
+ // FormatRule is an optional marker contributors implement when a rule
84
+ // belongs to the "format" category instead of the default "lint"
85
+ // category. `ttsc fix` is the run-everything entry point and applies
86
+ // edits from BOTH lint-class and format-class rules. `ttsc format` is
87
+ // the format-only convenience: it filters to FormatRule findings so
88
+ // lint-class rewrites are skipped. Lint rules (rules that do not
89
+ // implement FormatRule) participate only in `ttsc fix` (and in
90
+ // diagnostics during `ttsc check`).
91
+ //
92
+ // `IsFormat` exists as a structural marker, not a runtime toggle:
93
+ // returning `false` is equivalent to not implementing the interface at
94
+ // all, and the host treats either form the same way.
95
+ type FormatRule interface {
96
+ Rule
97
+ IsFormat() bool
98
+ }
99
+
100
+ // Reporter is the engine-supplied callback that records a finding. The
101
+ // host implements this and passes it to `NewContext` when invoking a
102
+ // contributor rule.
103
+ type Reporter interface {
104
+ // Report records a finding at the given node's source range.
105
+ Report(node *shimast.Node, message string)
106
+ // ReportRange records a finding at an explicit byte range inside the
107
+ // current file. Use this when the rule wants to highlight a
108
+ // sub-token.
109
+ ReportRange(pos, end int, message string)
110
+ }
111
+
112
+ // FixReporter is the optional extension a host implements to receive
113
+ // autofix edits alongside a finding. The public `rule.Context` type-asserts
114
+ // against this shape so any host whose reporter exposes both methods opts
115
+ // into fix support without depending on a private interface name.
116
+ //
117
+ // Contributor rules do NOT implement this interface — it is the host-side
118
+ // counterpart to `Context.ReportFix` / `Context.ReportRangeFix`. A
119
+ // contributor authoring a fake reporter for unit tests can declare
120
+ // `var _ rule.FixReporter = &myReporter{}` to compile-check that the fake
121
+ // satisfies the fix surface. Go interface satisfaction is all-or-nothing:
122
+ // a fake that wants the fix path must implement BOTH `ReportFix` and
123
+ // `ReportRangeFix`.
124
+ type FixReporter interface {
125
+ ReportFix(node *shimast.Node, message string, edits ...TextEdit)
126
+ ReportRangeFix(pos, end int, message string, edits ...TextEdit)
127
+ }
128
+
129
+ // TextEdit is one byte-range replacement offered by an autofixable finding.
130
+ // Positions use the same byte offsets as shim AST nodes and must point inside
131
+ // the current source file. An empty `Text` deletes the range; positions are
132
+ // in lexer byte order, not visual order, so a UTF-8 multi-byte sequence must
133
+ // be replaced as a whole.
134
+ //
135
+ // Application policy: a rule may emit several `TextEdit`s in one
136
+ // `ReportFix` / `ReportRangeFix` call, in any order. The host treats the
137
+ // per-pass edit set as a candidate list. Within a single fix pass, edits
138
+ // must not overlap each other; when two edits cover overlapping ranges
139
+ // (either from one rule emitting multiple edits in one call, or from two
140
+ // different rules in the same pass), the host applies the earliest-starting
141
+ // / shortest edit and silently drops the rest. There is no diagnostic for
142
+ // dropped edits, and the host does not currently report when a comment
143
+ // falls inside a deletion range. Design fixes so each finding emits one
144
+ // contiguous TextEdit covering the entire replacement region.
145
+ type TextEdit struct {
146
+ Pos int
147
+ End int
148
+ Text string
149
+ }
150
+
151
+ // Context is the per-(file, rule) handle the engine passes to `Check`.
152
+ // The `Reporter` is supplied by the host when constructing the context;
153
+ // contributors call `ctx.Report` / `ctx.ReportRange` directly through
154
+ // this Context rather than touching the reporter.
155
+ type Context struct {
156
+ // File is the source file currently being walked. Always non-nil
157
+ // when `Check` is invoked.
158
+ File *shimast.SourceFile
159
+
160
+ // Checker is the host's tsgo type checker. Available for type-aware
161
+ // rules; nil-safe enough that AST-only rules can ignore it.
162
+ Checker *shimchecker.Checker
163
+
164
+ // Severity is the rule's resolved severity for this file. Already
165
+ // filtered by the engine — rules do not need to check for
166
+ // SeverityOff.
167
+ Severity Severity
168
+
169
+ // Options is the raw JSON blob the user wrote in the second slot of
170
+ // their `[severity, options]` rule configuration tuple. Nil when the
171
+ // rule was configured with a bare severity literal. Contributors that
172
+ // accept options decode the blob into their own struct via
173
+ // `(*Context).DecodeOptions`.
174
+ Options json.RawMessage
175
+
176
+ reporter Reporter
177
+ }
178
+
179
+ // NewContext constructs a Context for the engine to pass into a
180
+ // contributor rule's `Check`. Reserved for host code; contributors
181
+ // should not need to call this.
182
+ func NewContext(
183
+ file *shimast.SourceFile,
184
+ checker *shimchecker.Checker,
185
+ severity Severity,
186
+ options json.RawMessage,
187
+ reporter Reporter,
188
+ ) *Context {
189
+ return &Context{
190
+ File: file,
191
+ Checker: checker,
192
+ Severity: severity,
193
+ Options: options,
194
+ reporter: reporter,
195
+ }
196
+ }
197
+
198
+ // DecodeOptions unmarshals the rule's options blob into `out`. Returns
199
+ // nil with no side effect when the rule was configured with severity
200
+ // alone, so contributors can write:
201
+ //
202
+ // var opts myRuleOptions
203
+ // _ = ctx.DecodeOptions(&opts)
204
+ // // opts now holds either the user's settings or the zero value.
205
+ func (c *Context) DecodeOptions(out interface{}) error {
206
+ if c == nil || len(c.Options) == 0 {
207
+ return nil
208
+ }
209
+ return json.Unmarshal(c.Options, out)
210
+ }
211
+
212
+ // Report records a finding at the given node's source range. Silently
213
+ // ignored when severity is `off` (defensive — the engine already filters
214
+ // by severity before invoking Check) or when no reporter is attached.
215
+ func (c *Context) Report(node *shimast.Node, message string) {
216
+ if c == nil || c.reporter == nil || c.Severity == SeverityOff || node == nil {
217
+ return
218
+ }
219
+ c.reporter.Report(node, message)
220
+ }
221
+
222
+ // ReportFix records a finding at the given node's source range with optional
223
+ // autofix edits. Older hosts that do not implement fix reporting receive the
224
+ // diagnostic without edits.
225
+ // Treat edits as best-effort: design the rule so the diagnostic alone is useful.
226
+ func (c *Context) ReportFix(node *shimast.Node, message string, edits ...TextEdit) {
227
+ if c == nil || c.reporter == nil || c.Severity == SeverityOff || node == nil {
228
+ return
229
+ }
230
+ if len(edits) == 0 {
231
+ c.reporter.Report(node, message)
232
+ return
233
+ }
234
+ fixer, ok := c.reporter.(FixReporter)
235
+ if !ok {
236
+ c.reporter.Report(node, message)
237
+ return
238
+ }
239
+ fixer.ReportFix(node, message, edits...)
240
+ }
241
+
242
+ // ReportRange records a finding at an explicit byte range inside the
243
+ // current file.
244
+ func (c *Context) ReportRange(pos, end int, message string) {
245
+ if c == nil || c.reporter == nil || c.Severity == SeverityOff {
246
+ return
247
+ }
248
+ c.reporter.ReportRange(pos, end, message)
249
+ }
250
+
251
+ // ReportRangeFix records a finding at an explicit byte range with optional
252
+ // autofix edits. Older hosts that do not implement fix reporting receive the
253
+ // diagnostic without edits.
254
+ // Treat edits as best-effort: design the rule so the diagnostic alone is useful.
255
+ func (c *Context) ReportRangeFix(pos, end int, message string, edits ...TextEdit) {
256
+ if c == nil || c.reporter == nil || c.Severity == SeverityOff {
257
+ return
258
+ }
259
+ if len(edits) == 0 {
260
+ c.reporter.ReportRange(pos, end, message)
261
+ return
262
+ }
263
+ fixer, ok := c.reporter.(FixReporter)
264
+ if !ok {
265
+ c.reporter.ReportRange(pos, end, message)
266
+ return
267
+ }
268
+ fixer.ReportRangeFix(pos, end, message, edits...)
269
+ }
270
+
271
+ var registry []Rule
272
+
273
+ // Register adds a contributor rule to the global registry. Called from a
274
+ // contributor package's `init()`. Duplicate names are NOT checked here
275
+ // — the host's adapter layer surfaces collisions with a clearer error
276
+ // than a raw panic.
277
+ func Register(r Rule) {
278
+ if r == nil {
279
+ panic("rule: Register called with nil rule")
280
+ }
281
+ registry = append(registry, r)
282
+ }
283
+
284
+ // Registered returns every contributor rule registered via `Register`.
285
+ // Called once by the host during engine bootstrap. The returned slice is
286
+ // a defensive copy so the host cannot mutate the registry.
287
+ func Registered() []Rule {
288
+ out := make([]Rule, len(registry))
289
+ copy(out, registry)
290
+ return out
291
+ }
@@ -0,0 +1,69 @@
1
+ import type { TtscLintConfig } from "./structures/TtscLintConfig";
2
+ import type { TtscLintPlugins } from "./structures/TtscLintPlugins";
3
+
4
+ /**
5
+ * Authoring helper that preserves the literal type of a lint config.
6
+ *
7
+ * ESLint's flat-config experience relies on `defineConfig` to capture the
8
+ * const-narrowed type of the configuration array, which is what makes plugin
9
+ * rule names autocomplete across entries. `@ttsc/lint` follows the same
10
+ * pattern: pass your config through this helper and the contributor plugin
11
+ * objects in `plugins` propagate their `rules` tuples into the rule-name
12
+ * union.
13
+ *
14
+ * ```ts
15
+ * import { defineConfig } from "@ttsc/lint";
16
+ * import importPlugin from "@ttsc/lint-plugin-import";
17
+ *
18
+ * export default defineConfig([
19
+ * {
20
+ * plugins: { import: importPlugin },
21
+ * rules: {
22
+ * "no-var": "error",
23
+ * "import/no-cycle": ["error", { maxDepth: 1 }],
24
+ * },
25
+ * },
26
+ * ]);
27
+ * ```
28
+ *
29
+ * The function is a pure pass-through at runtime. The generic gymnastics below
30
+ * gather every `plugins` map across the array of config entries into one
31
+ * intersected `TtscLintPlugins` shape that gets threaded back into the
32
+ * `TtscLintConfig<P>` constraint. Without this, `TtscLintConfig`'s default `P =
33
+ * Record<string, never>` rules out every namespaced rule name and `{
34
+ * "import/no-cycle": "error" }` would be flagged as a typo.
35
+ */
36
+ export function defineConfig<const T extends TtscLintConfig<GatherPlugins<T>>>(
37
+ config: T,
38
+ ): T {
39
+ return config;
40
+ }
41
+
42
+ /**
43
+ * Walks the input type to collect every plugin map declared across entries.
44
+ * Single entries yield the entry's `plugins`; arrays intersect every entry's
45
+ * `plugins` so each entry's rule-name union remains valid for the whole array.
46
+ * The intersection (vs. union) is load-bearing — TypeScript's `keyof (A | B)`
47
+ * collapses to `never`, which would reject every namespaced rule name.
48
+ *
49
+ * Plugins declared exclusively inside an `extends` chain are NOT collected:
50
+ * recursing into `T extends { extends?: infer E } ? GatherPlugins<E> : {}`
51
+ * blows TypeScript's instantiation budget (`extends` is itself a
52
+ * `TtscLintConfigEntry` tree). Authors should declare each plugin's `plugins`
53
+ * map on the outermost entry where its rules are configured. The runtime
54
+ * resolver still picks up plugins from `extends` entries; only the autocomplete
55
+ * domain is bounded.
56
+ */
57
+ type GatherPlugins<T> = T extends { plugins?: infer P }
58
+ ? P extends TtscLintPlugins
59
+ ? P
60
+ : {}
61
+ : T extends readonly (infer Item)[]
62
+ ? UnionToIntersection<Item extends unknown ? GatherPlugins<Item> : never>
63
+ : {};
64
+
65
+ type UnionToIntersection<U> = (
66
+ U extends unknown ? (x: U) => void : never
67
+ ) extends (x: infer I) => void
68
+ ? I
69
+ : never;