@ttsc/lint β Diagnostics Engine (Deep Dive)
@ttsc/lint is the advanced tier of the tour. The first three plugins (banner, strip, paths) each keep transform logic in package-owned driver/ files; @ttsc/lint ships ~13,000 lines of Go across 50 files, plus a TypeScript-side factory that evaluates lint.config.ts through ttsx, plus a public Go module for third-party rule contributors. This walkthrough is long because the engine is multi-file, but every section corresponds to one concrete file you can open in packages/lint/.
Prerequisites. From the tour: banner (the main β run β switch dispatcher, exit-code conventions), strip (NodeList filtering, the dottedName recursion), and paths (the constructor-caches-Program shape that linthost/host.go mirrors). The synthesize-flag invariant from paths does not apply here β lint never mutates leaf-text. The recover() panic barrier is introduced for the first time on this page. Optional: skim AST & Checker β Checker Basics before reading βA type-aware ruleβ below.
What it does for the consumer
@ttsc/lint is a check-stage plugin: it reports ESLint-shaped diagnostics from TypeScript-Goβs Program and Checker before emit. The host invokes it through five subcommands:
| Subcommand | Trigger | What it does |
|---|---|---|
check | ttsc check | Typecheck + lint. No emit. Exit non-zero on any error-severity finding. |
fix | ttsc fix | Apply lint and format autofixes in cascading passes, then re-lint. Emit stays disabled. |
format | ttsc format | Apply only format-class rule edits. Write-only β no diagnostics, no emit. |
build | ttsc build | Same as check, plus tsgoβs emit pipeline if --noEmit is not set. |
transform | @ttsc/unplugin and programmatic API | Single-file emit. Lint still runs over every user source file in the Program. No user-facing ttsc transform command β the host spawns this verb when a bundler adapter or ttsc.transform() API call needs single-file output. |
The minimum consumer wiring:
// tsconfig.json
{
"compilerOptions": {
"plugins": [
{ "transform": "@ttsc/lint", "rules": { "no-var": "error", "no-console": "warn" } }
]
}
}Or, more idiomatically, externalize the rules into lint.config.ts:
// lint.config.ts
import type { ITtscLintConfig } from "@ttsc/lint";
export default {
rules: {
"no-var": "error",
"no-console": "warn",
},
} satisfies ITtscLintConfig;// tsconfig.json β point the plugin entry at the file
{
"compilerOptions": {
"plugins": [{ "transform": "@ttsc/lint", "extends": "./lint.config.ts" }]
}
}With either configuration, npx ttsc check typechecks the project, runs every active rule, and renders findings through the same diagnostic format tsgo --noEmit uses. A var x = 1; lands as a red no-var error; the build fails.
Directory layout
packages/lint/
βββ package.json
βββ src/ β TypeScript-side: factory + public config types
β βββ index.ts β createTtscPlugin factory (~800 LOC)
β βββ structures/ β public option interfaces
βββ go.mod
βββ plugin/
β βββ main.go β one-line wrapper: os.Exit(linthost.Main(os.Args[1:]))
βββ linthost/ β the real engine β 50 files, ~13k LOC
β βββ dispatch.go β subcommand router
β βββ compile.go β project orchestration (check, build, transform)
β βββ fix.go β fix-cascade orchestration
β βββ format.go β format-only orchestration
β βββ host.go β Program/Checker bootstrap (third-party-shaped)
β βββ engine.go β rule registry + AST walker
β βββ config.go β rule-config resolution
β βββ config_format.go β format-only config knobs
β βββ directives.go β inline-disable comments
β βββ contrib_adapter.go β wraps public rule.Rule into the engine
β βββ eslint_runtime.go β delegates @typescript-eslint rules to Node ESLint
β βββ ast_helpers.go β shared AST helpers (nodeText, identifierText, ...)
β βββ print_*.go β diagnostic + pretty-print formatters
β βββ rules_*.go β 30 rule files, one rule family per file
βββ rule/ β public Go API for third-party rule contributors
βββ rule.go β Rule, Context, Severity, Register, TextEdit
βββ astutil/astutil.go β byte-oriented AST helpers contributors can reuseTwo things are different from the earlier transform plugins:
- The sidecar is a one-liner.
plugin/main.gois justlinthost.Main(os.Args[1:]). The dispatcher lives in the library packagelinthost, which is also linked by the WASM playground atpackages/wasm/. Keeping the dispatch logic in a library lets two different binaries (native + WASM) share one entrypoint. - The βreal logicβ is reusable. Every symbol in
linthost/is callable by anyone who imports the package. Third-party plugins that want the same βcheck + fix + formatβ surface can copy this pattern verbatim.
If you are building a complex plugin yourself, this is the shape to copy β not banner/strip/paths.
The sidecar (plugin/main.go)
package main
import (
"os"
"github.com/samchon/ttsc/packages/lint/linthost"
)
func main() {
os.Exit(linthost.Main(os.Args[1:]))
}Thatβs the entire file. Every subcommand router, every flag parser, every recovery barrier lives in the library at linthost/.
Subcommand dispatch (linthost/dispatch.go)
func Main(args []string) int { return run(args) }
func run(args []string) int {
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "@ttsc/lint: command required (expected check|fix|format|build|transform|version)")
return 2
}
switch args[0] {
case "-v", "--version", "version":
fmt.Fprintf(os.Stdout, "@ttsc/lint %s\n", Version)
return 0
case "check", "fix", "format", "build", "transform":
default:
fmt.Fprintf(os.Stderr, "@ttsc/lint: unknown command %q\n", args[0])
return 2
}
registerContributors()
switch args[0] {
case "check": return RunCheck(args[1:])
case "fix": return RunFix(args[1:])
case "format": return RunFormat(args[1:])
case "build": return RunBuild(args[1:])
case "transform": return RunTransform(args[1:])
}
return 2
}The two-switch shape is intentional:
- The first switch validates the subcommand. Unknown commands and
versionexit before any side-effect (theversionbanner does not need to register rules, parse configs, or load a Program). registerContributors()walks the publicrule.Registered()slice and adapts every contributor rule onto the engineβs internalRuleinterface. See Contributor adapter below.- The second switch dispatches to the actual implementation in
compile.go/fix.go/format.go.
Version is a package-level var Version = "dev" overridden at link time by -ldflags "-X github.com/samchon/ttsc/packages/lint/linthost.Version=β¦". The release pipeline sets it; local go build calls leave it as "dev".
Program + Checker bootstrap (linthost/host.go)
Banner, strip, and paths run as linked transform packages inside a generic host. @ttsc/lint is a check-stage executable sidecar, so it owns its own Program bootstrap. It inlines a loadProgram function that mirrors the canonical third-party bootstrap:
type program struct {
cwd string
tsProgram *shimcompiler.Program
parsed *tsoptions.ParsedCommandLine
checker *shimchecker.Checker
releaseChecker func()
}
func loadProgram(cwd, tsconfigPath string, options loadProgramOptions) (*program, []*shimast.Diagnostic, error) {
// 1. Normalize cwd and tsconfigPath to absolute, forward-slash paths.
// 2. Build a VFS: bundled.WrapFS(cachedvfs.From(osvfs.FS())).
// 3. Build a CompilerHost from the VFS.
// 4. Parse the tsconfig: tsoptions.GetParsedCommandLineOfConfigFile.
// 5. Apply ForceEmit / ForceNoEmit / OutDir overrides.
// 6. Build the Program: shimcompiler.NewProgram(ProgramOptions{...}).
// 7. Lease a Checker: tsProgram.GetTypeChecker(context.Background()).
// 8. Return *program with a release callback bound to the lease.
}(Each step expanded in packages/lint/linthost/host.go.)
Two design decisions worth highlighting:
Why not driver.LoadProgram? The doc-comment in host.go explains:
We donβt import
github.com/samchon/ttsc/packages/ttsc/driverfrom a source plugin because that would force every consumer of @ttsc/lint to have the in-tree samchon/ttsc/packages/ttsc module on their go.work β a dependency the public proxy cannot satisfy and that conflicts with ttscβs runtime-generated go.work overlay.
In practice: driver.LoadProgram is the right choice for third-party plugins, but @ttsc/lint itself lives inside the monorepo and ships a pre-built sidecar that is also linked into the WASM playground. The library copy of the bootstrap keeps the dependency graph clean for both consumers.
For your own plugin, ignore this complication. Call driver.LoadProgram directly; the doc-comment caveat does not apply to plugins built and consumed outside this repo.
userSourceFiles filters declaration files. Every lint pass walks user code, never *.d.ts. The driver does the same filtering, but host.go re-implements it because *program (lowercase, package-private) does not expose *driver.Programβs convenience methods.
Subcommand orchestration (linthost/compile.go)
compile.go owns every side effect for the check, build, and transform subcommands. The shape:
func RunCheck(args []string) int {
opts, err := parseSubcommandFlags("check", args)
if err != nil { ... }
opts.noEmit = true
return runProject(opts)
}
func RunBuild(args []string) int { ... } // emit, same diagnostic flow
func RunTransform(args []string) int { ... } // single-file emitrunProject(opts):
- Resolves cwd.
- Calls
loadProgramto get the Program + Checker. - Calls
loadRules(pluginsJSON, cwd, tsconfig)to resolve the rule severity map. This is wherelint.config.tsevaluation lands; see Config resolution. - Constructs an
EngineviaNewEngineWithResolver(rules). - Calls
collectDiagnostics(prog, engine)which runs both tsgoβsBind+Semanticpasses and the lint engine. Returns separate slices:astDiags(compiler) andlintDiags(lint). - Runs the ESLint runtime delegation when the resolved config asked for it β see ESLint runtime delegation.
- Renders both diagnostic streams through
shimdw.FormatMixedDiagnosticsso the user sees one unified output. - If
opts.noEmitis false, callsprog.tsProgram.Emit(EmitOptions{})to write JavaScript and declarations. - Returns exit code based on
CountErrors(astDiags) + CountErrors(lintDiags).
The Run* functions are intentionally thin wrappers; the shared work is in runProject and collectDiagnostics. This is the same separation you saw between main/run in banner β testable functions with explicit inputs, exit codes returned not called.
Config resolution
The config layer lives in linthost/config.go β the longest file in linthost/ (~1700 LOC) because it owns every way a user can declare rules:
- Inline in tsconfig:
"plugins": [{ "transform": "@ttsc/lint", "rules": { "no-var": "error" } }] extendspath in tsconfig:"extends": "./lint.config.ts"- Discovery walk when neither is given: walk upward from the tsconfig directory looking for
lint.config.{ts,mts,cts,mjs,cjs,js,json},ttsc-lint.config.*, oreslint.config.*. - ESLint flat-config in
eslint.config.tsβ including per-file overrides via thefilesglob array. - Inline disable comments in source files:
// ttsc-lint-disable-next-line no-var.
The config loader returns a RuleResolver interface:
type RuleResolver interface {
ActiveRuleNames() []string
RuleOptions(name string) json.RawMessage
EnabledRuleConfig() RuleConfig
ResolveRules(fileName string) ResolvedRuleConfig
}The engine queries this resolver per file, so two files with different scopes can get different rule sets. The resolver also handles the .gitignore-like ignores field, returning a ResolvedRuleConfig with Ignored: true for excluded paths so the engine can short-circuit without walking the AST.
For the TypeScript-side discovery (lint.config.ts evaluation through ttsx), see packages/lint/src/index.ts β the JS factory spawns ttsx against a synthesized loader that extracts the userβs plugins map, then writes the result to stdout as JSON. The Go side parses that JSON and feeds the resulting contributors into the plugin descriptor.
The engine (linthost/engine.go)
This is the heart of the plugin and the file you should read in full before writing a custom rule.
Rule interface
type Rule interface {
Name() string
Visits() []shimast.Kind
Check(ctx *Context, node *shimast.Node)
}Three methods. Name() is the user-facing rule name ("no-debugger", "@typescript/no-explicit-any"); Visits() returns the AST kinds the rule cares about; Check is invoked once per relevant node. The optional FormatRule extension adds an IsFormat() bool method that tags the rule as belonging to the format-class.
Registry
var registered = ®istry{rules: map[string]Rule{}}
func Register(rule Rule) {
if rule == nil { panic("...") }
if _, exists := registered.rules[rule.Name()]; exists {
panic("@ttsc/lint: rule " + rule.Name() + " registered twice")
}
registered.rules[rule.Name()] = rule
}Every built-in rule registers from its fileβs init(). By the time main.run calls RunCheck, the registry holds every rule the binary will ever see. Contributors register the same way but go through rule.Register instead; registerContributors adapts them onto the internal Rule interface after every init() has fired.
Dispatch table
NewEngineWithResolver builds a map[shimast.Kind][]Rule. The key is the AST kind; the value is the list of active rules that registered for that kind. Per-node dispatch is then linear in active rules of that kind, not total rules:
type Engine struct {
config RuleResolver
rules map[shimast.Kind][]Rule
enabled map[string]Severity
unknown []string
}The constructor also deduplicates kinds per rule β if a contributor accidentally listed KindCallExpression twice in Visits(), it only registers once. This is a deliberate kindness; the alternative would be a rule that silently fires twice per call.
unknown collects names from the userβs config that have no registered implementation. The CLI surfaces these as configuration warnings instead of silent typos.
AST walk
func (e *Engine) runFile(file *shimast.SourceFile, checker *shimchecker.Checker) []*Finding {
var collected []*Finding
collect := func(f *Finding) { collected = append(collected, f) }
resolved := e.config.ResolveRules(file.FileName())
if resolved.Ignored {
return collected
}
fileRules := resolved.Rules
var walk func(node *shimast.Node)
walk = func(node *shimast.Node) {
if node == nil { return }
if rules, ok := e.rules[node.Kind]; ok {
for _, rule := range rules {
severity := fileRules.Severity(rule.Name())
if severity == SeverityOff { continue }
ctx := &Context{ File: file, Checker: checker, Severity: severity, Options: ..., rule: rule, ... }
runRuleCheck(rule, ctx, node, collect)
}
}
node.ForEachChild(func(child *shimast.Node) bool {
walk(child)
return false
})
}
// SourceFile-kind rules dispatch on file.AsNode() BEFORE the statement walk β
// the walk closure only ever receives statements, not the SourceFile node itself.
if rules, ok := e.rules[shimast.KindSourceFile]; ok {
for _, rule := range rules { /* ...build ctx, runRuleCheck against file.AsNode()... */ }
}
for _, stmt := range file.Statements.Nodes { walk(stmt) }
return filterInlineDisabledFindings(file, collected)
}Four things to take away:
Per-node fan-out by kind. The map lookup is O(1); the inner loop is O(active rules for this kind). For 100 rules and 100,000 nodes, the work is roughly 100,000 Γ (lookup) + sum over rules Γ matching nodes.
KindSourceFile rules are dispatched separately. A rule whose Visits() returns []shimast.Kind{shimast.KindSourceFile} β the canonical shape for rules that scan comments or run once per file (e.g. ban-ts-comment, the contributor demoβs no-todo-comment) β fires from the pre-walk block, not from inside the closure. The closure only ever receives statement nodes. If you ever need both per-file-and-per-node dispatch in one rule, register multiple kinds in Visits() and branch on node.Kind inside Check.
Severity is per-file. fileRules.Severity(name) is queried at dispatch time, not engine bootstrap, because files: ["src/**.test.ts"] scopes can shift the severity per file.
runRuleCheck has a recover() barrier. A panicking rule does not abort the whole ttsc check run; the panic is converted into a SeverityError finding tagged with the ruleβs name. This is the only place in the repo where recover() is part of the design β the engine treats third-party rule code as adversarial input.
func runRuleCheck(rule Rule, ctx *Context, node *shimast.Node, collect func(*Finding)) {
defer func() {
r := recover()
if r == nil { return }
if ctx == nil || ctx.File == nil {
fmt.Fprintf(os.Stderr, "@ttsc/lint: rule %q panicked: %v\n", rule.Name(), r)
return
}
// Synthesize a SeverityError finding at the offending node's position.
collect(&Finding{ Rule: rule.Name(), Severity: SeverityError, File: ctx.File, ..., Message: fmt.Sprintf("rule panicked: %v", r) })
}()
rule.Check(ctx, node)
}Context.Report and trivia skipping
func (c *Context) ReportFix(node *shimast.Node, message string, edits ...TextEdit) {
if c.Severity == SeverityOff || node == nil { return }
pos := node.Pos()
if c.File != nil {
pos = shimscanner.SkipTrivia(c.File.Text(), pos)
}
c.collect(&Finding{ Rule: c.rule.Name(), Severity: c.Severity, File: c.File,
Pos: pos, End: node.End(), Message: message, Fix: cloneTextEdits(edits), IsFormat: c.isFormat })
}The pos is trimmed past leading trivia. This is the canonical token-start trick: Node.Pos() is the position before leading whitespace and comments, which would make every diagnostic banner point at the start of the line. SkipTrivia walks past trivia and returns the first significant tokenβs offset.
This is the same trick built-in rules use. Contributors who use ctx.Report(node, msg) get the trivia-skipped pos for free; contributors who use ctx.ReportRange(pos, end, msg) are responsible for passing the right pos themselves.
A worked rule: no-debugger (rules_debugger.go)
The simplest built-in rule. The whole rule body is seven lines (the file itself is 29 because it also registers no-with from the same init()):
package linthost
import shimast "github.com/microsoft/typescript-go/shim/ast"
type noDebugger struct{}
func (noDebugger) Name() string { return "no-debugger" }
func (noDebugger) Visits() []shimast.Kind { return []shimast.Kind{shimast.KindDebuggerStatement} }
func (noDebugger) Check(ctx *Context, node *shimast.Node) {
ctx.Report(node, "Unexpected `debugger` statement.")
}
func init() {
Register(noDebugger{})
Register(noWith{})
}Three observations:
- The rule is a value receiver.
noDebugger{}is an empty struct; the engine constructsContextand passes it by pointer, so the rule itself does not need fields. Most built-in rules are empty structs because rule state would leak across files. Visits()returns one kind. The engine only invokesCheckonDebuggerStatementnodes. Every other node kind walks past this rule.init()registers the rule. Everyrules_*.gofile ends with one or moreRegistercalls ininit(). Two rules with the sameName()panic at startup βRegisterchecks for the duplicate before storing β which is exactly the failure you want for a typo. (Within one file,initfunctions run in source order; across files in a package, the Go toolchain runs them in filename order. Either way the panic is reachable: only the file that reports it changes.)
no-console (in rules_console.go) is the next-simplest case, adding a dotted-name check on the callee. The pattern is the same dottedName recursion you read in the strip walkthrough.
A type-aware rule: await-thenable
For the canonical βuse the Checkerβ rule, read rules_promise.go. Its isThenableType helper detects whether a type carries a callable .then member β exactly the structural test ECMAScript uses for await:
func isThenableType(checker *shimchecker.Checker, t *shimchecker.Type) bool {
thenSym := checker.GetPropertyOfType(t, "then")
if thenSym == nil { return false }
thenT := checker.GetTypeOfSymbol(thenSym)
sigs := checker.GetSignaturesOfType(thenT, shimchecker.SignatureKindCall)
return len(sigs) > 0
}The ruleβs Check first calls checker.GetTypeAtLocation(expr) on the awaited expression, then passes that *Type here. Two Checker calls inside the helper:
GetPropertyOfType(t, "then")β look up a member by name. Returns*Symbolor nil.GetSignaturesOfType(thenT, SignatureKindCall)β get every callable signature on the property type.
If then is missing or non-callable, the value is not thenable and await on it is meaningless. The rule reports a finding; --fix could autosuggest removing the await (though await-thenable does not β thatβs a design choice, not a technical constraint).
For the full Checker surface and when to reach for Checker_* linkname wrappers, see AST & Checker β Checker Basics.
A rule with autofix: anatomy of ReportFix
Most autofixable rules follow the same shape: the rule emits a single ReportFix (or ReportRangeFix) with one or more TextEdit values that, when applied, would silence the diagnostic on re-lint.
ctx.ReportFix(node, "use const",
TextEdit{ Pos: keywordStart, End: keywordStart + len("let"), Text: "const" },
)TextEdit positions are byte offsets, identical to node.Pos() / node.End(). The host applies edits in a single pass per file; within a pass:
- No overlapping edits. Two edits whose ranges intersect are reduced to the earliest-starting / shortest one. The host silently drops the rest. The contract is documented on
rule.TextEdit(the public Go type). - Empty
Textdeletes the range. No special βdeleteβ verb. - Order does not matter. The host sorts internally and applies right-to-left so earlier edits do not shift later offsets.
Designing fixes that emit one contiguous edit per finding is the safest pattern. no-import-type-side-effects is the one production rule that emits multiple edits per finding, and it carefully constructs the edits to be non-overlapping by position.
Fix cascade (linthost/fix.go)
ttsc fix is more than βapply edits and exit.β The host runs a cascading pass loop:
1. Run the engine over every user source file.
2. Group findings by file.
3. For each file with at least one edit, apply the edits in byte-reverse order.
4. Re-parse the file. Walk the file with the engine again.
5. If new findings emerged (e.g., a fix that fixes A causes B to fire), apply them.
6. Repeat up to a bounded iteration count, then stop.
7. Re-lint without applying anything and render any remaining findings.The cascade is bounded β 10 iterations by default β because rule pairs can in principle oscillate (rule Aβs fix triggers rule Bβs fix, which re-triggers rule Aβs fix). Bounding the iteration count prevents fix-mode from hanging on adversarial inputs.
After every iteration, the Program is reloaded so subsequent passes see the typechecker against the fixed source, not the original. This costs a tsgo re-parse per iteration; for projects with thousands of files the cost matters, which is why the cascade exists in fix.go (separate, optimisable) and not inline in engine.go.
Format-only cascade (linthost/format.go)
ttsc format is a strict subset of ttsc fix:
- Same engine, same cascade.
- Filters every finding through
Finding.IsFormat. Lint-class findings are dropped; only format-class findings are applied. - Write-only. No diagnostics are printed; the userβs invariant is βformat the file, do not tell me about lint problems.β
The split is intentional: a developer can wire ttsc format into a pre-commit hook (zero noise; only reshape) and ttsc check into CI (strict, with errors).
ESLint runtime delegation
The runtime-delegation layer lives in linthost/eslint_runtime.go. @ttsc/lint ships ~36 built-in rules; the TypeScript-ESLint ecosystem has hundreds. When the userβs config is shaped like a real eslint.config.* (or carries the explicit __ttscLintEslintRuntime: true marker the JS factory writes when it recognises ESLint-only fields), the engine sets ConfigStore.eslintRuntime = true, and the orchestrator spawns Node against a tiny harness that runs the real ESLint and prints findings as JSON.
The trigger is opt-in by config shape, not βunknown rule falls through to ESLintβ:
- After the Go engine runs,
compile.gocallsprovider, ok := resolver.(eslintRuntimeProvider)on the resolved config. - If the type assertion succeeds and
provider.WantsESLintRuntime()returns true, the harness runs. - The harnessβs JSON is parsed and converted into the same
Findingshape the engine produces, then rendered through the same writer.
The harness is ESLint-version-aware β it adapts between ESLint 8 (legacy .eslintrc.*) and ESLint 9 (flat config). The key idea: a single ttsc check invocation can mix native Go rules with delegated Node rules and the user sees one diagnostic stream β but only when the userβs config asked for it.
Contributor adapter
The adapter layer lives in linthost/contrib_adapter.go. Third-party rule contributors use a separate Go package β github.com/samchon/ttsc/packages/lint/rule β that defines a public rule.Rule interface, structurally identical to the internal one. The adapter wraps each contributor rule onto the engineβs internal interface:
type contributorAdapter struct {
inner rule.Rule
}
func (a contributorAdapter) Name() string { return a.inner.Name() }
func (a contributorAdapter) Visits() []shimast.Kind { return a.inner.Visits() }
func (a contributorAdapter) Check(ctx *Context, node *shimast.Node) {
pubCtx := rule.NewContext(
ctx.File, ctx.Checker, rule.Severity(ctx.Severity), ctx.Options,
contextReporter{ctx: ctx},
)
a.inner.Check(pubCtx, node)
}Two adapters, not one β contributorAdapter for lint-class rules, formatContributorAdapter for format-class. The format variant adds a single IsFormat() bool { return true } method by struct embedding (type formatContributorAdapter struct { contributorAdapter }) β Goβs substitute for inheritance, where the inner structβs fields and methods are promoted onto the outer struct, and any new method on the outer struct (here IsFormat) is added on top.
Why two parallel interfaces? The engineβs internal Rule is allowed to evolve freely; the public rule.Rule is the stability boundary contributors compile against. By separating the two and bridging through an adapter, we can refactor the internal engine without breaking every published contributor.
The collision policy in registerContributors is βlater wins is dangerous; prefer determinism.β A contributor whose Name() collides with an existing rule is dropped with a stderr warning, not panicked-on. This is the same trade-off @ttsc/lint makes for the panic barrier: keep the build going whenever possible, surface the problem clearly.
How a contributor package ships
Three files:
ttsc-lint-plugin-demo/
βββ package.json β npm manifest
βββ src/index.ts β JS descriptor (ITtscLintPlugin)
βββ rules/ β Go source β NO go.mod
βββ no_todo_comment.go
βββ capitalize_exports.goThe JS descriptor:
import path from "node:path";
import type { ITtscLintPlugin } from "@ttsc/lint";
const plugin = {
meta: { name: "ttsc-lint-plugin-demo", version: "1.0.0", namespace: "demo" },
rules: ["no-todo-comment", "capitalize-exports"] as const,
source: path.resolve(__dirname, "..", "rules"),
} satisfies ITtscLintPlugin;
declare module "@ttsc/lint" {
interface TtscLintRuleOptionsMap {
"demo/no-todo-comment": { markers?: readonly string[] };
}
}
export default plugin;The declare module block is a TypeScript declaration-merge into @ttsc/lintβs public type surface. Users who write ["error", { markers: ["TODO"] }] in their lint.config.ts get autocomplete and type-checking against your option shape, without @ttsc/lint knowing about your plugin at all.
The Go rule file:
package demo
import (
shimast "github.com/microsoft/typescript-go/shim/ast"
"github.com/samchon/ttsc/packages/lint/rule"
)
type noTodoComment struct{}
func (noTodoComment) Name() string { return "demo/no-todo-comment" }
func (noTodoComment) Visits() []shimast.Kind { return []shimast.Kind{shimast.KindSourceFile} }
func (noTodoComment) Check(ctx *rule.Context, node *shimast.Node) {
var opts struct {
Markers []string `json:"markers"`
}
_ = ctx.DecodeOptions(&opts)
if len(opts.Markers) == 0 {
opts.Markers = []string{"TODO", "FIXME"}
}
// ...scan ctx.File.Text() and call ctx.Report or ctx.ReportFix on findings.
}
func init() {
rule.Register(noTodoComment{})
}Several constraints, enforced by the plugin builder (Pitfalls β Contributor merge failures):
- No
go.mod. Contributors ship Go source as a package, not a module. The host pluginβsgo.modgoverns every transitive dependency, which closes the supply-chain surface a contributorgo.modwould otherwise open. - Go package name = post-transform namespace.
react-hooksbecomespackage react_hooksbecause Go identifiers cannot contain hyphens. init()registers the rule. Build-time ttsc synthesizes attsc_contributions.goin the host pluginβs main package withimport _ "github.com/samchon/ttsc/packages/lint/contrib/demo"β that blank import fires the contributorβsinit()beforemain.- Rule names are namespaced.
demo/no-todo-commentis the user-facing name. The convention prevents collisions with built-ins.
Wiring it on the consumer side, two surfaces:
// tsconfig.json β inline plugins map
{
"compilerOptions": {
"plugins": [
{
"transform": "@ttsc/lint",
"plugins": { "demo": "ttsc-lint-plugin-demo" },
"rules": { "demo/no-todo-comment": "error" }
}
]
}
}// lint.config.ts β plugins object
import demoPlugin from "ttsc-lint-plugin-demo";
import type { ITtscLintConfig } from "@ttsc/lint";
export default {
plugins: { demo: demoPlugin },
rules: { "demo/no-todo-comment": "error" },
} satisfies ITtscLintConfig;Both surfaces feed into the same contributors: [...] array on the descriptor that the Go side then merges into one binary at build time.
The contributor autofix path
A contributor can emit fixes the same way built-in rules do:
ctx.ReportFix(node, "drop TODO comment", rule.TextEdit{
Pos: pos, End: end, Text: "",
})rule.Context.ReportFix lives in the public rule package; it forwards to the hostβs FixReporter via a type assertion. The contract:
- Older hosts (and most unit-test fake reporters) implement only the legacy
Report/ReportRangemethods. In that case,ReportFixfalls back toReportand drops the edits silently. Design the rule so the diagnostic alone is useful; treat fixes as best-effort. - The host applies edits between the cascading native passes and the final no-emit check. If the user invoked
ttsc check(no fix mode), edits are dropped entirely βcheckis read-only.
For more autofix shapes, rule/astutil ships four byte-oriented helpers built-in rules already use: NodeText, KeywordStart, FindKeyword, TokenRange.
Config object and per-file overrides
The TypeScript-side config surface is a plain object. Use satisfies ITtscLintConfig for type checking:
import type { ITtscLintConfig } from "@ttsc/lint";
export default {
extends: "./base-lint.config.ts",
files: ["src/**/*.ts"],
ignores: ["dist/**"],
rules: {
"no-var": "error",
"no-console": "off",
},
} satisfies ITtscLintConfig;extends points at one base config file. The base config is loaded first, then the current objectβs plugins, rules, and format fields override it. files and ignores scope the object when the resolver (linthost/config.go) builds the rule severity map for each FileName().
What to copy, what to ignore for your own plugin
Copy:
- The two-layer structure: thin
main.gowrapper + library package holding every subcommand handler. Lets the same engine power native binaries, WASM playgrounds, and editor extensions. - The
Ruleinterface (Name,Visits,Check) and theinit()-driven registry. Even if your plugin is not a linter, βone entrypoint per concern, registered at initβ scales well. - The
recover()barrier around third-party-authored code. If you accept user-provided callbacks, wrap them. - The cascade loop for βapply edits, re-parse, re-walkβ workflows. The bounded iteration count is the right answer to βrules might fight each other.β
- The contributor adapter pattern (
rule.Ruleβ internalRule) when you ship a public Go API surface that needs to evolve independently from your internal engine.
Do not copy without understanding:
- The inlined
loadPrograminhost.go. As noted above, that exists to avoid a circular dependency between@ttsc/lint(which lives inside the ttsc monorepo) and the publicdriverpackage. For your own plugin, calldriver.LoadProgram. - The ESLint runtime delegation. Worth reading once, but spawning Node from a Go plugin is operationally complex (need to find the right
nodebinary, marshal results, handle missing-dependency cases). Most plugins donβt need it. - The format-class marker interface (
FormatRule) unless your plugin has a clear βlint vs formatβ distinction. For most plugins, one severity ladder is enough.
Test coverage
Lint is the most-tested package in the repo:
- Go unit tests in
packages/lint/test/β direct calls intolinthosthelpers, AST helper coverage. - End-to-end TypeScript tests in
tests/test-lint/src/features/β over 200 cases, every rule pinned by at least one assertion. Subdirectories:rules/,fix/,format/,contributor/,eslint/. - Contributor demo plugin at
tests/lint-contributor-demo/β the canonical βbuild a contributor from scratchβ reference, used by every contributor-related e2e test.
When you write your own rule, start by copying the test shape from tests/test-lint/src/features/rules/no-debugger/ β one assertion per file, materialize a fixture, run the real ttsc check, assert exit code and stderr substring.
Where to read next
Operational follow-ups:
- Architecture β the cache, the Go toolchain resolver, the
TTSC_*_BINARYenv vars@ttsc/lintitself relies on when delegating to ESLint. - Driver API β the curated faΓ§ade your own plugins should call (the same surface
@ttsc/lintwould call if it lived outside this monorepo). - Pitfalls β every failure mode this tour referenced, with exact host error strings.
- Authoring β Recipes β copy-paste patterns for the techniques the tour explained at depth.