@ttsc/lint 0.5.0-dev.20260429

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.
@@ -0,0 +1,259 @@
1
+ // Package lint hosts the rule registry, the AST-walking engine, and the
2
+ // orchestration glue that the `@ttsc/lint` native plugin uses to run rules
3
+ // against a tsgo Program.
4
+ //
5
+ // Layering:
6
+ //
7
+ // - `Rule` is the interface every rule implements. Rules are
8
+ // registered at package init time and never mutated.
9
+ // - `Engine` walks every user source file once, dispatching each visited
10
+ // node to the rules that opted in via `Visits()`.
11
+ // - `Context` is what a rule receives when it fires; it owns the
12
+ // report channel back to the engine.
13
+ //
14
+ // Rules are stateless across files: each invocation gets a fresh `Context`
15
+ // and may not retain references to the previous file. This keeps the
16
+ // engine concurrent-friendly even though the v0 implementation runs
17
+ // serially.
18
+ package lint
19
+
20
+ import (
21
+ "sort"
22
+
23
+ shimast "github.com/microsoft/typescript-go/shim/ast"
24
+ shimchecker "github.com/microsoft/typescript-go/shim/checker"
25
+ shimscanner "github.com/microsoft/typescript-go/shim/scanner"
26
+ )
27
+
28
+ // Rule is the contract every lint rule satisfies.
29
+ type Rule interface {
30
+ // Name is the identifier that users put in their `rules` map. Use
31
+ // the same names as `eslint` / `@typescript-eslint` where possible —
32
+ // this plugin is a host, not a renaming exercise.
33
+ Name() string
34
+
35
+ // Visits returns the AST kinds the rule cares about. The engine only
36
+ // dispatches to rules that registered for the visited node's kind,
37
+ // which keeps the per-node hot path linear in active rules rather
38
+ // than total rules.
39
+ Visits() []shimast.Kind
40
+
41
+ // Check is invoked once per relevant node. Use `ctx.Report` to emit
42
+ // findings.
43
+ Check(ctx *Context, node *shimast.Node)
44
+ }
45
+
46
+ // Context is the per-(file, rule) handle the engine passes to `Check`.
47
+ type Context struct {
48
+ File *shimast.SourceFile
49
+ Checker *shimchecker.Checker
50
+ Severity Severity
51
+
52
+ rule Rule
53
+ collect func(*Finding)
54
+ }
55
+
56
+ // Finding is one rule-emitted diagnostic before it gets converted into a
57
+ // driver Diagnostic.
58
+ type Finding struct {
59
+ Rule string
60
+ Severity Severity
61
+ File *shimast.SourceFile
62
+ Pos int
63
+ End int
64
+ Message string
65
+ }
66
+
67
+ // Report records a finding at the given node's source range. The pos is
68
+ // trimmed past leading trivia (whitespace + comments) so the renderer's
69
+ // `path:line:col` banner points at the offending token, not the start of
70
+ // the surrounding indentation. A finding is silently dropped if the
71
+ // configured severity is `off` (defensive — the engine already filters
72
+ // by severity before calling Check, but a rule might lazy-evaluate in
73
+ // the future).
74
+ func (c *Context) Report(node *shimast.Node, message string) {
75
+ if c.Severity == SeverityOff || node == nil {
76
+ return
77
+ }
78
+ pos := node.Pos()
79
+ if c.File != nil {
80
+ pos = shimscanner.SkipTrivia(c.File.Text(), pos)
81
+ }
82
+ c.collect(&Finding{
83
+ Rule: c.rule.Name(),
84
+ Severity: c.Severity,
85
+ File: c.File,
86
+ Pos: pos,
87
+ End: node.End(),
88
+ Message: message,
89
+ })
90
+ }
91
+
92
+ // ReportRange records a finding at an explicit byte range inside the
93
+ // current file. Use this when the rule wants to highlight a sub-token of
94
+ // a node (e.g. an operator inside a BinaryExpression).
95
+ func (c *Context) ReportRange(pos, end int, message string) {
96
+ if c.Severity == SeverityOff || c.File == nil {
97
+ return
98
+ }
99
+ if end <= pos {
100
+ end = pos + 1
101
+ }
102
+ c.collect(&Finding{
103
+ Rule: c.rule.Name(),
104
+ Severity: c.Severity,
105
+ File: c.File,
106
+ Pos: pos,
107
+ End: end,
108
+ Message: message,
109
+ })
110
+ }
111
+
112
+ // registry stores the package-global rule list keyed by name. Tests can
113
+ // also reach into it via `LookupRule`.
114
+ type registry struct {
115
+ rules map[string]Rule
116
+ }
117
+
118
+ var registered = &registry{rules: map[string]Rule{}}
119
+
120
+ // Register adds a rule to the global registry. Called from each rule's
121
+ // `init()`. Duplicate names are a programmer error and panic.
122
+ func Register(rule Rule) {
123
+ if rule == nil {
124
+ panic("@ttsc/lint: Register called with nil rule")
125
+ }
126
+ if _, exists := registered.rules[rule.Name()]; exists {
127
+ panic("@ttsc/lint: rule " + rule.Name() + " registered twice")
128
+ }
129
+ registered.rules[rule.Name()] = rule
130
+ }
131
+
132
+ // LookupRule returns the registered rule by name, or nil if missing.
133
+ func LookupRule(name string) Rule { return registered.rules[name] }
134
+
135
+ // AllRuleNames returns the registry sorted alphabetically. Useful for
136
+ // `--list-rules` style introspection and stable test snapshots.
137
+ func AllRuleNames() []string {
138
+ names := make([]string, 0, len(registered.rules))
139
+ for n := range registered.rules {
140
+ names = append(names, n)
141
+ }
142
+ sort.Strings(names)
143
+ return names
144
+ }
145
+
146
+ // Engine binds a rule configuration to a Program and walks the AST once
147
+ // per source file, dispatching each visited node to its interested rules.
148
+ type Engine struct {
149
+ config RuleConfig
150
+ rules map[shimast.Kind][]Rule
151
+ enabled map[string]Severity
152
+ unknown []string
153
+ }
154
+
155
+ // NewEngine returns an engine configured for `config`. Rules whose
156
+ // severity is `off` are skipped entirely. Configuration entries that name
157
+ // an unknown rule are recorded so the caller can surface them as a
158
+ // configuration warning rather than a silent typo.
159
+ func NewEngine(config RuleConfig) *Engine {
160
+ eng := &Engine{
161
+ config: config,
162
+ rules: make(map[shimast.Kind][]Rule),
163
+ enabled: make(map[string]Severity),
164
+ }
165
+ for name, sev := range config {
166
+ rule, ok := registered.rules[name]
167
+ if !ok {
168
+ eng.unknown = append(eng.unknown, name)
169
+ continue
170
+ }
171
+ if sev == SeverityOff {
172
+ continue
173
+ }
174
+ eng.enabled[name] = sev
175
+ for _, kind := range rule.Visits() {
176
+ eng.rules[kind] = append(eng.rules[kind], rule)
177
+ }
178
+ }
179
+ sort.Strings(eng.unknown)
180
+ return eng
181
+ }
182
+
183
+ // UnknownRules returns the names of rules that appeared in the config but
184
+ // have no registered implementation.
185
+ func (e *Engine) UnknownRules() []string { return e.unknown }
186
+
187
+ // EnabledRules returns the active rule set keyed by name. Mostly for
188
+ // tests + introspection.
189
+ func (e *Engine) EnabledRules() map[string]Severity { return e.enabled }
190
+
191
+ // Run walks every non-declaration source file in the program and
192
+ // returns the collected findings.
193
+ func (e *Engine) Run(files []*shimast.SourceFile, checker *shimchecker.Checker) []*Finding {
194
+ var findings []*Finding
195
+ for _, file := range files {
196
+ if file == nil || file.IsDeclarationFile {
197
+ continue
198
+ }
199
+ findings = append(findings, e.runFile(file, checker)...)
200
+ }
201
+ return findings
202
+ }
203
+
204
+ // runFile is the per-file driver. The visitor is allocated once per file
205
+ // to keep the per-node hot path branch-free; it visits children
206
+ // post-order so parents see their already-checked subtrees.
207
+ func (e *Engine) runFile(file *shimast.SourceFile, checker *shimchecker.Checker) []*Finding {
208
+ var collected []*Finding
209
+ collect := func(f *Finding) { collected = append(collected, f) }
210
+
211
+ var walk func(node *shimast.Node)
212
+ walk = func(node *shimast.Node) {
213
+ if node == nil {
214
+ return
215
+ }
216
+ if rules, ok := e.rules[node.Kind]; ok {
217
+ for _, rule := range rules {
218
+ ctx := &Context{
219
+ File: file,
220
+ Checker: checker,
221
+ Severity: e.enabled[rule.Name()],
222
+ rule: rule,
223
+ collect: collect,
224
+ }
225
+ rule.Check(ctx, node)
226
+ }
227
+ }
228
+ node.ForEachChild(func(child *shimast.Node) bool {
229
+ walk(child)
230
+ return false // visit every child
231
+ })
232
+ }
233
+
234
+ // SourceFile dispatches into its statement list directly; we walk
235
+ // statements explicitly so the file node itself can be inspected by
236
+ // rules (e.g., `ban-ts-comment` reads CommentDirectives off the
237
+ // SourceFile).
238
+ if rules, ok := e.rules[shimast.KindSourceFile]; ok {
239
+ for _, rule := range rules {
240
+ ctx := &Context{
241
+ File: file,
242
+ Checker: checker,
243
+ Severity: e.enabled[rule.Name()],
244
+ rule: rule,
245
+ collect: collect,
246
+ }
247
+ rule.Check(ctx, file.AsNode())
248
+ }
249
+ }
250
+
251
+ statements := file.Statements
252
+ if statements == nil {
253
+ return collected
254
+ }
255
+ for _, stmt := range statements.Nodes {
256
+ walk(stmt)
257
+ }
258
+ return collected
259
+ }
@@ -0,0 +1,190 @@
1
+ // Bootstrap glue for the @ttsc/lint native binary.
2
+ //
3
+ // We don't import `github.com/samchon/ttsc/packages/ttsc/driver` from a
4
+ // source plugin because that would force every consumer of @ttsc/lint to
5
+ // have the in-tree samchon/ttsc/packages/ttsc module on their go.work — a
6
+ // dependency the public proxy cannot satisfy and that conflicts with
7
+ // ttsc's runtime-generated go.work overlay. Instead, this file inlines a
8
+ // minimal Program/Checker bootstrap (the same pattern documented in
9
+ // 03-tsgo.md and used by every other source-plugin reference fixture).
10
+ package lint
11
+
12
+ import (
13
+ "context"
14
+ "errors"
15
+ "fmt"
16
+ "path/filepath"
17
+
18
+ shimast "github.com/microsoft/typescript-go/shim/ast"
19
+ "github.com/microsoft/typescript-go/shim/bundled"
20
+ shimchecker "github.com/microsoft/typescript-go/shim/checker"
21
+ shimcompiler "github.com/microsoft/typescript-go/shim/compiler"
22
+ shimcore "github.com/microsoft/typescript-go/shim/core"
23
+ "github.com/microsoft/typescript-go/shim/tsoptions"
24
+ "github.com/microsoft/typescript-go/shim/vfs/cachedvfs"
25
+ "github.com/microsoft/typescript-go/shim/vfs/osvfs"
26
+ )
27
+
28
+ // program bundles the tsgo Program with the parsed config and a checker
29
+ // release callback so the orchestration code can clean up after itself.
30
+ type program struct {
31
+ cwd string
32
+ tsProgram *shimcompiler.Program
33
+ parsed *tsoptions.ParsedCommandLine
34
+ checker *shimchecker.Checker
35
+ releaseChecker func()
36
+ }
37
+
38
+ type loadProgramOptions struct {
39
+ forceEmit bool
40
+ forceNoEmit bool
41
+ outDir string
42
+ }
43
+
44
+ // loadProgram parses the given tsconfig, builds a Program, and acquires a
45
+ // type checker. Mirrors the canonical bootstrap pattern from
46
+ // `03-tsgo.md` — the only ttsc-specific bit is that `forceEmit`/
47
+ // `forceNoEmit`/`outDir` overrides are merged into the parsed config
48
+ // before the program is created so `--noEmit` and friends behave like
49
+ // they do in `ttsc check`.
50
+ func loadProgram(cwd, tsconfigPath string, options loadProgramOptions) (*program, []*shimast.Diagnostic, error) {
51
+ if !filepath.IsAbs(cwd) {
52
+ abs, err := filepath.Abs(cwd)
53
+ if err != nil {
54
+ return nil, nil, fmt.Errorf("loadProgram: cwd: %w", err)
55
+ }
56
+ cwd = abs
57
+ }
58
+ resolved := tsconfigPath
59
+ if !filepath.IsAbs(resolved) {
60
+ resolved = filepath.Join(cwd, resolved)
61
+ }
62
+
63
+ fs := bundled.WrapFS(cachedvfs.From(osvfs.FS()))
64
+ host := shimcompiler.NewCompilerHost(cwd, fs, bundled.LibPath(), nil, nil)
65
+
66
+ parsed, parseDiags := tsoptions.GetParsedCommandLineOfConfigFile(
67
+ resolved,
68
+ &shimcore.CompilerOptions{},
69
+ nil,
70
+ host,
71
+ nil,
72
+ )
73
+ if parsed == nil {
74
+ return nil, nil, fmt.Errorf("tsoptions: parsed command line was nil for %s", resolved)
75
+ }
76
+ if len(parseDiags) > 0 {
77
+ return nil, parseDiags, nil
78
+ }
79
+ if len(parsed.Errors) > 0 {
80
+ return nil, parsed.Errors, nil
81
+ }
82
+ if options.forceNoEmit {
83
+ forceNoEmit(parsed)
84
+ }
85
+ if options.forceEmit {
86
+ forceEmit(parsed)
87
+ }
88
+ if options.outDir != "" {
89
+ overrideOutDir(cwd, parsed, options.outDir)
90
+ }
91
+
92
+ tsProgram := shimcompiler.NewProgram(shimcompiler.ProgramOptions{
93
+ Config: parsed,
94
+ SingleThreaded: shimcore.TSTrue,
95
+ Host: host,
96
+ UseSourceOfProjectReference: true,
97
+ })
98
+ if tsProgram == nil {
99
+ return nil, nil, errors.New("compiler.NewProgram returned nil")
100
+ }
101
+ checker, release := tsProgram.GetTypeChecker(context.Background())
102
+ return &program{
103
+ cwd: cwd,
104
+ tsProgram: tsProgram,
105
+ parsed: parsed,
106
+ checker: checker,
107
+ releaseChecker: release,
108
+ }, nil, nil
109
+ }
110
+
111
+ func (p *program) close() {
112
+ if p == nil {
113
+ return
114
+ }
115
+ if p.releaseChecker != nil {
116
+ p.releaseChecker()
117
+ p.releaseChecker = nil
118
+ }
119
+ }
120
+
121
+ // userSourceFiles returns the program's user-authored source files
122
+ // (declaration files filtered out — those belong to library typings).
123
+ func (p *program) userSourceFiles() []*shimast.SourceFile {
124
+ out := make([]*shimast.SourceFile, 0)
125
+ for _, f := range p.tsProgram.SourceFiles() {
126
+ if f == nil || f.IsDeclarationFile {
127
+ continue
128
+ }
129
+ out = append(out, f)
130
+ }
131
+ return out
132
+ }
133
+
134
+ // programDiagnostics returns the bind + semantic diagnostics for the
135
+ // loaded program. Same surface tsgo's CLI prints when you run a regular
136
+ // `tsgo --noEmit`.
137
+ func (p *program) programDiagnostics() []*shimast.Diagnostic {
138
+ if p == nil || p.tsProgram == nil {
139
+ return nil
140
+ }
141
+ ctx := context.Background()
142
+ raw := shimcompiler.GetDiagnosticsOfAnyProgram(
143
+ ctx,
144
+ p.tsProgram,
145
+ nil,
146
+ false,
147
+ p.tsProgram.GetBindDiagnostics,
148
+ p.tsProgram.GetSemanticDiagnostics,
149
+ )
150
+ return shimcompiler.SortAndDeduplicateDiagnostics(raw)
151
+ }
152
+
153
+ // findSourceFile locates a source file in the program by absolute path.
154
+ // tsgo normalizes paths to forward slashes; we do the same on our side.
155
+ func (p *program) findSourceFile(target string) *shimast.SourceFile {
156
+ want := filepath.ToSlash(target)
157
+ for _, file := range p.tsProgram.SourceFiles() {
158
+ if filepath.ToSlash(file.FileName()) == want {
159
+ return file
160
+ }
161
+ }
162
+ return nil
163
+ }
164
+
165
+ func forceEmit(parsed *tsoptions.ParsedCommandLine) {
166
+ if parsed == nil || parsed.ParsedConfig == nil || parsed.ParsedConfig.CompilerOptions == nil {
167
+ return
168
+ }
169
+ options := parsed.ParsedConfig.CompilerOptions
170
+ options.NoEmit = shimcore.TSFalse
171
+ options.EmitDeclarationOnly = shimcore.TSFalse
172
+ }
173
+
174
+ func forceNoEmit(parsed *tsoptions.ParsedCommandLine) {
175
+ if parsed == nil || parsed.ParsedConfig == nil || parsed.ParsedConfig.CompilerOptions == nil {
176
+ return
177
+ }
178
+ parsed.ParsedConfig.CompilerOptions.NoEmit = shimcore.TSTrue
179
+ }
180
+
181
+ func overrideOutDir(cwd string, parsed *tsoptions.ParsedCommandLine, outDir string) {
182
+ if parsed == nil || parsed.ParsedConfig == nil || parsed.ParsedConfig.CompilerOptions == nil {
183
+ return
184
+ }
185
+ if filepath.IsAbs(outDir) {
186
+ parsed.ParsedConfig.CompilerOptions.OutDir = filepath.ToSlash(outDir)
187
+ return
188
+ }
189
+ parsed.ParsedConfig.CompilerOptions.OutDir = filepath.ToSlash(filepath.Join(cwd, outDir))
190
+ }
@@ -0,0 +1,78 @@
1
+ package lint
2
+
3
+ import shimast "github.com/microsoft/typescript-go/shim/ast"
4
+
5
+ // no-sparse-arrays: `[1, , 2]` — the implied undefined slot is almost
6
+ // always a typo.
7
+ // https://eslint.org/docs/latest/rules/no-sparse-arrays
8
+ type noSparseArrays struct{}
9
+
10
+ func (noSparseArrays) Name() string { return "no-sparse-arrays" }
11
+ func (noSparseArrays) Visits() []shimast.Kind { return []shimast.Kind{shimast.KindArrayLiteralExpression} }
12
+ func (noSparseArrays) Check(ctx *Context, node *shimast.Node) {
13
+ arr := node.AsArrayLiteralExpression()
14
+ if arr == nil || arr.Elements == nil {
15
+ return
16
+ }
17
+ for _, el := range arr.Elements.Nodes {
18
+ if el != nil && el.Kind == shimast.KindOmittedExpression {
19
+ ctx.Report(node, "Unexpected comma in middle of array.")
20
+ return
21
+ }
22
+ }
23
+ }
24
+
25
+ // no-array-constructor: forbid `new Array(0)` / `Array(1, 2, 3)` (use
26
+ // array literals). The 1-arg numeric form is also banned because its
27
+ // behavior depends on the runtime — see ESLint defaults.
28
+ // https://eslint.org/docs/latest/rules/no-array-constructor
29
+ type noArrayConstructor struct{}
30
+
31
+ func (noArrayConstructor) Name() string { return "no-array-constructor" }
32
+ func (noArrayConstructor) Visits() []shimast.Kind {
33
+ return []shimast.Kind{shimast.KindNewExpression, shimast.KindCallExpression}
34
+ }
35
+ func (noArrayConstructor) Check(ctx *Context, node *shimast.Node) {
36
+ var callee *shimast.Node
37
+ var argCount int
38
+ switch node.Kind {
39
+ case shimast.KindNewExpression:
40
+ ne := node.AsNewExpression()
41
+ if ne == nil {
42
+ return
43
+ }
44
+ if ne.TypeArguments != nil && len(ne.TypeArguments.Nodes) > 0 {
45
+ return // `new Array<string>()` is a typed empty array
46
+ }
47
+ callee = ne.Expression
48
+ if ne.Arguments != nil {
49
+ argCount = len(ne.Arguments.Nodes)
50
+ }
51
+ case shimast.KindCallExpression:
52
+ call := node.AsCallExpression()
53
+ if call == nil {
54
+ return
55
+ }
56
+ if call.TypeArguments != nil && len(call.TypeArguments.Nodes) > 0 {
57
+ return
58
+ }
59
+ callee = call.Expression
60
+ if call.Arguments != nil {
61
+ argCount = len(call.Arguments.Nodes)
62
+ }
63
+ }
64
+ if identifierText(callee) != "Array" {
65
+ return
66
+ }
67
+ // Single-arg numeric — ambiguous (length vs single element). Skip
68
+ // so existing patterns aren't flagged when the intent is preallocate.
69
+ if argCount == 1 {
70
+ return
71
+ }
72
+ ctx.Report(node, "The array literal notation [] is preferable.")
73
+ }
74
+
75
+ func init() {
76
+ Register(noSparseArrays{})
77
+ Register(noArrayConstructor{})
78
+ }
@@ -0,0 +1,37 @@
1
+ package lint
2
+
3
+ import shimast "github.com/microsoft/typescript-go/shim/ast"
4
+
5
+ // no-console: forbid `console.*` calls. Default mode flags every method;
6
+ // users who want to allow specific methods can configure ESLint-style
7
+ // allowlists in a future iteration.
8
+ // https://eslint.org/docs/latest/rules/no-console
9
+ type noConsole struct{}
10
+
11
+ func (noConsole) Name() string { return "no-console" }
12
+ func (noConsole) Visits() []shimast.Kind { return []shimast.Kind{shimast.KindCallExpression} }
13
+ func (noConsole) Check(ctx *Context, node *shimast.Node) {
14
+ call := node.AsCallExpression()
15
+ if call == nil || call.Expression == nil {
16
+ return
17
+ }
18
+ if call.Expression.Kind != shimast.KindPropertyAccessExpression {
19
+ return
20
+ }
21
+ access := call.Expression.AsPropertyAccessExpression()
22
+ if access == nil {
23
+ return
24
+ }
25
+ if identifierText(access.Expression) != "console" {
26
+ return
27
+ }
28
+ method := identifierText(access.Name())
29
+ if method == "" {
30
+ return
31
+ }
32
+ ctx.Report(node, "Unexpected console statement.")
33
+ }
34
+
35
+ func init() {
36
+ Register(noConsole{})
37
+ }
@@ -0,0 +1,29 @@
1
+ package lint
2
+
3
+ import shimast "github.com/microsoft/typescript-go/shim/ast"
4
+
5
+ // no-debugger: forbid `debugger` statements.
6
+ // https://eslint.org/docs/latest/rules/no-debugger
7
+ type noDebugger struct{}
8
+
9
+ func (noDebugger) Name() string { return "no-debugger" }
10
+ func (noDebugger) Visits() []shimast.Kind { return []shimast.Kind{shimast.KindDebuggerStatement} }
11
+ func (noDebugger) Check(ctx *Context, node *shimast.Node) {
12
+ ctx.Report(node, "Unexpected `debugger` statement.")
13
+ }
14
+
15
+ // no-with: forbid `with` statements (already disallowed in strict mode,
16
+ // but lint catches it before the parse error).
17
+ // https://eslint.org/docs/latest/rules/no-with
18
+ type noWith struct{}
19
+
20
+ func (noWith) Name() string { return "no-with" }
21
+ func (noWith) Visits() []shimast.Kind { return []shimast.Kind{shimast.KindWithStatement} }
22
+ func (noWith) Check(ctx *Context, node *shimast.Node) {
23
+ ctx.Report(node, "Unexpected `with` statement.")
24
+ }
25
+
26
+ func init() {
27
+ Register(noDebugger{})
28
+ Register(noWith{})
29
+ }