Skip to Content

@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 mainrunswitch 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 build/check host invokes it through five primary subcommands:

SubcommandTriggerWhat it does
checkttsc checkTypecheck + lint. No emit. Exit non-zero on any error-severity finding.
fixttsc fixApply lint and format autofixes in cascading passes, then re-lint. Emit stays disabled.
formatttsc formatApply only format-class rule edits. Write-only, no diagnostics, no emit.
buildttsc buildSame as check, plus tsgo’s emit pipeline if --noEmit is not set.
transform@ttsc/unplugin and programmatic APISingle-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 TtscCompiler.transform() API call needs single-file output.

The VS Code path also uses the LSP sidecar verbs because @ttsc/lint sets capabilities.lsp: lsp-command-ids, lsp-code-action-kinds, lsp-diagnostics, lsp-code-actions, lsp-execute-command, and lsp-hints.

The minimum consumer wiring:

// tsconfig.json { "compilerOptions": { "plugins": [{ "transform": "@ttsc/lint" }], }, }
// lint.config.ts — next to tsconfig.json import type { ITtscLintConfig } from "@ttsc/lint"; export default { rules: { "no-var": "error", "no-console": "warn", }, } satisfies ITtscLintConfig;

@ttsc/lint discovers lint.config.ts by walking upward from the tsconfig directory (or from the launcher-provided TTSC_PLUGIN_CONFIG_DIR project root when the compile runs through a generated wrapper tsconfig). To point at a specific file, set configFile on the tsconfig entry:

// tsconfig.json { "compilerOptions": { "plugins": [ { "transform": "@ttsc/lint", "configFile": "./config/lint.config.ts" }, ], }, }

With either configuration, npx ttsc check typechecks the project, runs every active rule, and renders findings through the same diagnostic format tsc --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 │ ├── 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 reuse

Two things are different from the earlier transform plugins:

  1. The sidecar is a one-liner. plugin/main.go is just linthost.Main(os.Args[1:]). The dispatcher lives in the library package linthost, which is also linked by the WASM playground at packages/wasm/. Keeping the dispatch logic in a library lets two different binaries (native + WASM) share one entrypoint.
  2. 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|lsp-*|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", "lsp-command-ids", "lsp-code-action-kinds", "lsp-diagnostics", "lsp-code-actions", "lsp-execute-command", "lsp-hints": default: fmt.Fprintf(os.Stderr, "@ttsc/lint: unknown command %q\n", args[0]) return 2 } registerContributorsOnce() 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:]) case "lsp-command-ids": return RunLSPCommandIDs(args[1:]) case "lsp-code-action-kinds": return RunLSPCodeActionKinds(args[1:]) case "lsp-diagnostics": return RunLSPDiagnostics(args[1:]) case "lsp-code-actions": return RunLSPCodeActions(args[1:]) case "lsp-execute-command": return RunLSPExecuteCommand(args[1:]) case "lsp-hints": return RunLSPHints(args[1:]) } return 2 }

The two-switch shape is intentional:

  • The first switch validates the subcommand. Unknown commands and version exit before any side-effect (the version banner does not need to register rules, parse configs, or load a Program).
  • registerContributorsOnce() uses sync.Once to inspect the public contributor registries and adapt every contributor rule onto the engine’s internal maps during the first valid command. Concurrent callers wait for that bootstrap to finish, and later calls reuse the published maps. 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 } 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. For type-aware rules, create a standalone Checker over the Program. // 8. Return *program with that Checker owned by the lint lifecycle. }

(Each step expanded in packages/lint/linthost/host.go.)

The Program keeps its configured checker pool for parallel semantic diagnostics. Type-aware rules walk files serially through a separate checker.NewChecker(program, nil), so every lint type belongs to one checker without forcing the Program pool down to one worker. AST-only rule sets create no standalone checker and keep the engine’s parallel file walk.

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/driver from 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 Go sidecar source that ttsc builds on demand; the same engine package 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 follows tsconfig roots. Every lint pass walks the TS/JS source roots from parsedConfig.fileNames, including user-authored *.d.ts files. Imported implementation files, library declarations, generated output, and JSON modules may still appear in Program.SourceFiles(), so host.go filters them before lint and format run.

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 emit

runProject(opts):

  1. Resolves cwd.
  2. Calls loadProgram to get the Program + Checker.
  3. Calls loadRules(pluginsJSON, cwd, tsconfig) to resolve the rule severity map. This is where lint.config.ts evaluation lands; see Config resolution.
  4. Constructs an Engine via NewEngineWithResolver(rules).
  5. Calls collectDiagnostics(prog, engine) which runs both tsgo’s Bind + Semantic passes and the lint engine. Returns separate slices: astDiags (compiler) and lintDiags (lint).
  6. Renders both diagnostic streams through shimdw.FormatMixedDiagnostics so the user sees one unified output.
  7. If opts.noEmit is false, calls prog.tsProgram.Emit(EmitOptions{}) to write JavaScript and declarations.
  8. 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/ (~1,470 LOC) because it owns every way a user can declare rules:

  • configFile path in tsconfig: "plugins": [{ "transform": "@ttsc/lint", "configFile": "./lint.config.ts" }]
  • Discovery walk when configFile is absent: walk upward looking for lint.config.{ts,mts,cts,mjs,cjs,js,json}, or the ttsc-lint.config.* equivalent, for projects that prefer the tool name in the filename. The walk anchors at the launcher-provided TTSC_PLUGIN_CONFIG_DIR project root when set (the tsconfig may be a generated wrapper in a temp directory), otherwise at the tsconfig directory with the working directory as a fallback origin.
  • extends in a config file: a lint.config.* file may set extends to a base config file’s path. The base is loaded first, then this file’s plugins, rules, and format fields override it. Cyclic and over-deep extends chains are rejected with a typed error rather than recursed into.
  • 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 and writes the result to a private temporary file. The loader records the evaluated module graph plus package-manifest and directory topology that can change module resolution; final graph reachability separates local watch inputs from package-only cache inputs without depending on load order. Config and contributor logs are redirected to stderr so they cannot corrupt CLI JSON or LSP framing. The factory parses the private result 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", "demo/no-demo"); 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.

Declaration files (.d.ts, .d.mts, .d.cts) are dispatched selectively. Executable grammar cannot appear in a declaration file, so the engine skips built-in value-level rules there instead of paying the dispatch on declaration-heavy projects. Three doors stay open: format rules always run (so ttsc format / ttsc fix keep covering hand-written .d.ts), built-in type/comment/naming rules participate through the curated allowlist in linthost/declaration_rules.go, and contributor rules run by default. A contributor whose rule only inspects executable code can implement the optional rule.DeclarationFileRule marker — VisitsDeclarationFiles() bool { return false } — to opt out of declaration files and skip the wasted dispatch.

A rule can classify its findings with the optional rule.TaggedRule marker: DiagnosticTags() []rule.DiagnosticTag. The tags reach the editor as LSP DiagnosticTag values, so a rule flagging unused code declares DiagnosticTagUnnecessary and the editor fades the finding; DiagnosticTagDeprecated strikes it through. Every finding a tagged rule produces carries its tags, read once at dispatch and stamped onto each finding. The grain fits the rules that want it. A rule that flags unused code flags only unused code, so there is no per-finding tag. Tag by what the code is, not how severe the finding is: Unnecessary says “safe to delete”, so a rule whose findings mean “not done yet” must return none rather than tell the author to delete unfinished work. A rule that implements no marker, or returns nil, produces untagged findings, which is what most findings are. Among the built-ins, solid/no-react-deps, storybook/no-redundant-story-name, and storybook/no-title-property-in-meta declare Unnecessary; storybook/no-stories-of and storybook/hierarchy-separator declare Deprecated. The name-based security rules stay untagged because a user-defined Buffer constructor or crypto object can match the same syntax as the deprecated Node APIs.

Registry

var registered = &registry{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(). Contributors register the same way but go through rule.Register instead. After every init() has fired, registerContributorsOnce adapts them onto the internal Rule interface exactly once before the first valid command dispatch, so every later run call reads the same registry state.

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.

Before it builds that table, the constructor validates every options payload declared for a registered rule, including payloads on "off" rules and every file-scoped variant. Built-in rules opt into an options slot through a structural capability on their implementation. A severity-only built-in rejects any object, scalar, or positional payload instead of silently ignoring it.

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 is slightly longer 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 constructs Context and 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 invokes Check on DebuggerStatement nodes. Every other node kind walks past this rule.
  • init() registers the rule. Every rules_*.go file ends with one or more Register calls in init(). Two rules with the same Name() panic at startup, Register checks for the duplicate before storing, which is exactly the failure you want for a typo. (Within one file, init functions 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 classifyPromiseAwaitability helper separates definitely thenable, definitely non-thenable, and uncertain types so each syntax family can apply the correct quantifier:

switch classifyPromiseAwaitability(checker, expression, t) { case promiseAwaitabilityAlways: // Promise or valid structural thenable case promiseAwaitabilityNever: // definitely non-awaitable case promiseAwaitabilityMay: // any, unknown, or an unconstrained type parameter }

The rule uses the classification in two ways:

  • An ordinary await reports only a Never operand, while any, unknown, unconstrained type parameters, and maybe-Promise unions stay clean.
  • A native Promise aggregator reports an array-literal member only when all of its union constituents are Never, but reports a typed container when any possible element constituent is Never.

Aggregator dispatch first resolves the receiver type to a default-library PromiseConstructor, so aliases of native Promise are covered while shadowed lookalikes are ignored.

Arrays and tuples use Checker index or type-argument APIs, and generic Iterable<T> inputs use their first type argument.

The for await...of and await using arms resolve [Symbol.asyncIterator] and [Symbol.asyncDispose] through Checker_getPropertyNameForKnownSymbolName, then verify that the resolved property type has a call signature.

Those findings have no autofix because removing await changes runtime behavior. The ordinary await diagnostic exposes token removal only as an opt-in editor suggestion; ttsc fix and source fix-all never consume suggestion edits.

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:

  • Conflicts resolve per finding, not per edit. The host groups a finding’s edits, considers the earliest group first, and accepts a group only when every member coexists with what it already accepted. One colliding member skips the whole group, so a multi-edit fix never half-applies. The skipped finding is re-run on the next cascade pass and applies then, or the cascade converges without it. The contract is documented on rule.TextEdit (the public Go type).
  • A finding’s own edits must not overlap each other. Siblings enter the same group, so a finding that collides with itself can never apply. An exact duplicate inside one finding is collapsed, not treated as a conflict.
  • Empty Text deletes 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.

Emit the narrowest edits that express the rewrite. Several small non-overlapping edits contend for less source than one wide replacement, and the atomic applier exists so that shape is safe. Built-ins that ship multi-edit fixes include typescript/no-import-type-side-effects, format/whitespace, format/indent, unicorn/prevent-abbreviations, and unicorn/template-indent.

A rule with a choice of fixes: ReportSuggestion

ReportFix imposes one rewrite. When a rule knows several valid repairs and cannot choose among them for the author — three plausible renames for an abbreviation, two types that could replace a banned one — it offers them instead, and the editor presents the choice:

ctx.ReportSuggestion(node, "avoid the abbreviation `frm`", rule.Suggestion{Title: "Rename to `frames`", Edits: []rule.TextEdit{{Pos: p, End: e, Text: "frames"}}}, rule.Suggestion{Title: "Rename to `framework`", Edits: []rule.TextEdit{{Pos: p, End: e, Text: "framework"}}}, )

There is a ReportRangeSuggestion for a sub-token range, mirroring ReportRangeFix.

The distinction is not cosmetic: a fix is applied automatically in a ttsc fix pass, a suggestion never is. Suggestions are surfaced only in an editor, as code actions the author invokes. A Suggestion with empty Edits is a label — a “did you mean” the author acts on by hand.

Like ReportFix, this degrades gracefully: a host that does not implement SuggestionReporter still receives the diagnostic, without the choices. Design the rule so the message alone is useful, and reach for suggestions only when there genuinely is a choice — a single correct rewrite is a fix, and imposing it is the right thing.

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).

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 acceptsOptions bool name string visits []shimast.Kind visitsDeclarationFiles bool } func (a contributorAdapter) AcceptsTtscLintOptions() bool { return a.acceptsOptions } func (a contributorAdapter) Name() string { return a.name } func (a contributorAdapter) Visits() []shimast.Kind { return a.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.

registerContributors evaluates Name, Visits, IsFormat, VisitsDeclarationFiles, and the optional OptionsRule capability once behind a recover barrier and stores the results in the adapter. Existing contributors default to accepting options because the public Context.Options field predates this capability. A genuinely optionless contributor returns false from AcceptsTtscLintOptions; the host then rejects a payload before linting. The domain-specific method name does not capture an unrelated AcceptsOptions method from an older contributor API. A panic in those startup methods drops only that contributor entry with a stderr warning. A panic in Check becomes an error finding for that rule, and the engine continues running the remaining contributor and built-in rules.

The collision policy is “later wins is dangerous; prefer determinism.” A contributor whose cached 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.

Contributor packages are statically linked into the lint binary. Recovery can isolate ordinary Go panics raised synchronously by metadata or Check, but it cannot survive a panic in init() or a contributor-started goroutine, os.Exit, a fatal runtime fault, or a non-returning rule. Those failures require process isolation and are outside the in-process contributor contract.

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.go

The JS descriptor:

import type { ITtscLintPlugin, TtscLintRuleSetting, } from "@ttsc/lint"; import path from "node:path"; 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 ITtscLintRuleOptionsMap { "demo/no-todo-comment": { markers?: readonly string[] }; } interface ITtscLintContributorRules { "demo/capitalize-exports"?: TtscLintRuleSetting; } } export default plugin;

The declare module block is a TypeScript declaration-merge into @ttsc/lint’s public rule types. An option-bearing rule augments ITtscLintRuleOptionsMap; ITtscLintRules consumes its mapped overlay, so importing the plugin makes ["error", { markers: ["TODO"] }] autocomplete and type-check against the exact shape. An optionless rule augments ITtscLintContributorRules directly with TtscLintRuleSetting, which rejects a second tuple slot. Contributor names with no imported augmentation keep the open unknown-options fallback for compatibility.

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) AcceptsTtscLintOptions() bool { return true } 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’s go.mod governs every transitive dependency, which closes the supply-chain surface a contributor go.mod would otherwise open.
  • Go package name = post-transform namespace. react-hooks becomes package react_hooks because Go identifiers cannot contain hyphens.
  • init() registers the rule. Build-time ttsc synthesizes a ttsc_contributions.go in the host plugin’s main package with import _ "github.com/samchon/ttsc/packages/lint/contrib/demo", that blank import fires the contributor’s init() before main.
  • Rule names are namespaced. demo/no-todo-comment is the user-facing name. The convention prevents collisions with built-ins.

Wiring it on the consumer side via lint.config.ts:

// lint.config.ts import type { ITtscLintConfig } from "@ttsc/lint"; import demoPlugin from "ttsc-lint-plugin-demo"; export default { plugins: { demo: demoPlugin }, rules: { "demo/no-todo-comment": "error" }, } satisfies ITtscLintConfig;

This feeds into the same contributors: [...] array on the descriptor that the Go side then merges into one binary at build time.

Project-scoped contributor rules

Use a rule.ProjectRule when a check belongs to the loaded Program rather than one source-file visit:

type noCycles struct{} func (noCycles) Name() string { return "architecture/no-cycles" } func (noCycles) Check(ctx *rule.ProjectContext) { if cycle := findCycle(ctx.Sources, ctx.Checker); cycle != "" { ctx.Report(cycle) } } func init() { rule.RegisterProject(noCycles{}) }

ctx.Sources is the population the host read for that cycle: the project’s own tsconfig file list plus every TypeScript source the Program pulled in through an import, minus globally ignored paths. A rule that selects its population by glob therefore reaches a sibling workspace package resolving to source, the same way the type-check pass does. See Which files are linted.

One exception is worth knowing when a rule reasons about coverage. ttsc format writes files and reports nothing, so it walks the project’s own file list alone, and a project rule evaluated during a format run receives that narrower population. Draw a conclusion that must hold for the whole workspace from a lint or check run.

The host runs each project rule once per Program, before file rules, even when ctx.Sources is empty. ctx.Identity keeps the caller’s logical config and root spelling separate from the real paths used by the compiler, and also carries the invocation cwd, optional explicit project root, plugin-config origin, and lifecycle id. ctx.Report records a project finding and fails the rule; ctx.Fail fails it silently. A later file rule reads the same cycle through ctx.ProjectResult(name), whose status is one of absent, off, not_evaluated, passed, or failed.

If the rule reads local files that are not TypeScript Program inputs, publish their configured topology through the optional rule.ProjectInputRule contract:

func (evidenceRule) ProjectInputs(ctx *rule.ProjectInputContext) []rule.ProjectInput { var options struct { Markdown []string `json:"markdown"` OpenAPI string `json:"openapi"` } if err := ctx.DecodeOptions(&options); err != nil { panic(err) } inputs := []rule.ProjectInput{{ Kind: rule.ProjectInputFile, Pattern: options.OpenAPI, }} for _, pattern := range options.Markdown { inputs = append(inputs, rule.ProjectInput{ Kind: rule.ProjectInputGlob, Pattern: pattern, }) } return inputs }

ProjectInputs runs after the rule’s global options and physical project identity are resolved, but before a TypeScript Program is loaded. A relative pattern is anchored to ctx.Identity.PhysicalProjectRoot. ProjectInputFile retains an exact path even when it is missing; ProjectInputGlob retains the population even when it currently matches nothing. That distinction lets a host observe later create, change, delete, and rename events without watching unrelated workspace documents. Publish the configured inputs, not only the files a successful Check happened to read, so a parse failure does not erase the dependency that can repair it. The host resolves symlink aliases, normalizes platform spelling, and shares duplicates before exposing one snapshot to CLI and editor consumers.

Only local filesystem paths belong in this contract. HTTP(S) URLs have no filesystem event and need a contributor-owned polling or conditional-revalidation policy.

Attach contributor-owned state when file rules must reuse the exact project binding selected during preflight:

type projectBinding struct { /* contributor-owned fields */ } func (projectGuard) Check(ctx *rule.ProjectContext) { ctx.SetState(loadProjectBinding(ctx.Identity)) } func (guardedFileRule) Check(ctx *rule.Context, node *ast.Node) { result := ctx.ProjectResult("demo/project-guard") if result.Status != rule.ProjectRulePassed { return } binding, ok := result.State.(*projectBinding) if !ok { return } if err := binding.Revalidate(); err != nil { result.Report(err.Error()) return } useGuardedResource(binding, node) }

SetState stores one arbitrary Go value without interpretation or serialization. File rules receive the same interface value in that loaded Program cycle. The host does not carry it into another cycle: separate public API projects and later watch or LSP rebuilds rerun the project check with a new reporter. A contributor that needs a fresh state object creates it during each check.

Each ProjectResult value is a snapshot of status and findings at the time of the call and carries the contributor’s exact state value without copying its contents. Evaluated results also carry live Report and Fail methods until file dispatch finishes. Call ctx.ProjectResult(name) again when a helper needs to observe a failure reported earlier in the dispatch. Once dispatch finishes, a retained result can no longer mutate the cycle. absent, off, and not_evaluated results have nil state and inert mutation methods.

The host serializes status transitions, deduplicates equal messages, and sorts distinct messages before finalization, including when AST-only file rules dispatch concurrently. Project findings are finalized after all file rules return but are placed before file findings in CLI, API, watch, and LSP results. The contributor owns synchronization for mutable fields inside its state value; the host only synchronizes the result wrapper.

Project contributors follow the same options compatibility boundary as file contributors. They accept options by default, while a genuinely optionless implementation can add AcceptsTtscLintOptions() bool { return false } to reject accidental payloads.

The same boundary applies to the checker. A ProjectContext carries a live Checker, so a declared project rule is treated as type-aware and the engine creates the standalone checker for it. That decision is engine-wide rather than per-rule: one type-aware project rule also puts every file rule in the run on the serial walk, so a project rule that reads only ctx.Sources or the filesystem would silently spend a file rule’s own NeedsTypeChecker() bool { return false }. Such a rule can add the same marker to opt out, and it then receives a nil ctx.Checker:

func (noCycles) NeedsTypeChecker() bool { return false }

Returning true is equivalent to not implementing the marker. A project rule that returns false must not read ctx.Checker.

Editor hints from a project rule

A project rule that indexed something an editor could complete against can publish that index as a completion corpus, by implementing rule.HintRule:

func (myRule) Hints(ctx *rule.HintContext) []rule.Hint { index, ok := ctx.State.(*myIndex) if !ok || index == nil { return nil } hints := []rule.Hint{} for _, section := range index.Sections { hints = append(hints, rule.Hint{ Insert: section.Anchor, Detail: section.Title, Trigger: rule.HintTrigger{ Scope: rule.HintScopeJSDoc, After: "@evidence docs/spec.md#", }, }) } return hints }

The corpus travels; the rule does not. The lint engine is a separate process that reloads the Program on every invocation, so nothing can ask a rule a question per keystroke — which is why a trigger is declarative data rather than the Go predicate the API obviously wants. A closure does not survive the process boundary.

Hints is pull, not push: the host calls it at most once per Program, always after Check, and only when a consumer asks for the corpus — never during ttsc check. It is not called unless the rule passed and published state, so a rule configured off publishes nothing with no code of its own, and a rule’s options shape its corpus for free because the corpus is a projection of the state Check built under them.

A trigger applies when the cursor sits inside Scope and the line prefix contains After; the text following the last occurrence of After is what the editor filters on. After must end exactly where the completed token begins"@evidence " with its trailing space, not "@evidence" — or the token swallows the separator and nothing filters. When several triggers match one line, the occurrence nearest the cursor wins; at that occurrence the longest After wins and only hints naming that same trigger are offered. That is what lets a corpus be layered — "@", "@evidence ", and "@evidence docs/spec.md#" can all be published at once — while a later trigger, such as a second "@" further along the line, is never eclipsed by an earlier, longer one.

Slice order is the ranking. The corpus outlives the process, so there is no other channel; return what should be offered first, first.

ttscserver fetches the corpus in the background and answers from memory. It merges its trigger characters into whatever tsgo advertised rather than replacing them, and appends its items to tsgo’s completion response rather than answering in its place. Until the first fetch lands the corpus is empty and the editor sees exactly what it sees without the plugin.

The corpus is refetched after a saved document, a configuration change, or a watched-file change, so a rule enabled mid-session or an index rebuilt from a saved file reaches the editor without restarting the server. The previous corpus keeps answering completion until the new one is stored. Trigger characters are the exception: they were merged into the initialize response the editor already holds, so a trigger character published for the first time by a later fetch needs a restart before the editor opens completion on it — its items are still reachable through explicit completion, and the server logs the character to the editor’s output channel.

Only ProjectRule may publish hints. File rules run in a parallel walk, so their hints would rank nondeterministically, and a corpus keyed to one file cannot answer a keystroke in another.

Configure a project rule in the same rules map, but only in entries without files:

export default { rules: { "architecture/no-cycles": ["error", { packages: ["src/**"] }], }, } satisfies ITtscLintConfig;

An entry containing files is rejected for a project rule even when the selector is empty or the rule is off. extends folds base first, a later global severity wins, and a later bare severity keeps the last explicit tuple options. Global ignores only filter ctx.Sources; they do not turn the project rule off.

Project findings have no source file, range, or edit. Structured API output represents them with file: null. In LSP mode the sidecar returns them separately from document diagnostics, and ttscserver publishes the set at the logical config URI with a zero range and no version. They do not produce fixes or code actions.

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 / ReportRange methods. In that case, ReportFix falls back to Report and 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, check is read-only.

For more autofix shapes, rule/astutil ships four byte-oriented helpers built-in rules already use: NodeText, KeywordStart, FindKeyword, TokenRange.

Pointing at a second location

A finding can name a related source location with ctx.ReportRelated(node, message, related...) (or ctx.ReportRangeRelated), passing rule.RelatedInformation{ Pos, End, Message } values. The editor renders each as a clickable line beneath the diagnostic — this is how the built-in no-redeclare leads “‘x’ is already defined.” to where x was first declared. ReportRelated shares ReportFix’s contract: it forwards to the host’s RelatedReporter via a type assertion and, on a host that predates it, degrades to a plain Report so the diagnostic still lands without the locations. The Pos/End offsets index the file being linted and the host attaches that file’s URI, so a related location stays within the finding’s own file; a cross-file location would need a URI the rule API does not yet carry.

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;

files and ignores scope the object when the resolver (linthost/config.go) builds the rule severity map for each FileName(). When files is present, ignores only refines that selection — the excluded files are still linted by other entries. Without files, a top-level ignores is a global ignore: a config file is a single object, so this is the only way to say “never lint these files”, and the matched paths are excluded from every rule in the resolved chain, including rules inherited through extends. extends points at one base config file (path relative to this file’s directory): the base is loaded first, then this object’s plugins, rules, and format fields override it.

What to copy, what to ignore for your own plugin

Copy:

  • The two-layer structure: thin main.go wrapper + library package holding every subcommand handler. Lets the same engine power native binaries, WASM playgrounds, and editor extensions.
  • The Rule interface (Name, Visits, Check) and the init()-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 ↔ internal Rule) when you ship a public Go API surface that needs to evolve independently from your internal engine.

Do not copy without understanding:

  • The inlined loadProgram in host.go. As noted above, that exists to avoid a circular dependency between @ttsc/lint (which lives inside the ttsc monorepo) and the public driver package. For your own plugin, call driver.LoadProgram.
  • 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 into linthost helpers, 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/, config/, plugin/, contributor/.
  • 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.

Operational follow-ups:

  • Architecture: the cache, the Go toolchain resolver, the TTSC_*_BINARY env vars @ttsc/lint itself relies on.
  • Driver API: the curated façade your own plugins should call (the same surface @ttsc/lint would 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.
Last updated on