Reference Plugins
This repository ships four package-shaped plugins. Study them in this order:
@ttsc/banner@ttsc/strip@ttsc/paths@ttsc/lint
The order is by implementation difficulty. strip is easier than paths: strip only needs the source AST in front of it; paths needs tsconfig and Program data to map aliases through the final output layout.
Shared Package Shape
Each package has a JavaScript descriptor factory and a Go plugin module:
packages/<name>/
|- package.json
|- src/index.cjs # simple descriptor factory, when no typed surface is needed
|- src/index.ts # typed package surface, compiled to lib/index.js when present
|- go.mod
`- plugin/
|- main.go # native sidecar entrypoint
`- <name>.go # package-local helper file or wrapperFor a package with no public TypeScript types, the descriptor factory can live in src/index.cjs:
const path = require("node:path");
module.exports = function createPlugin() {
return {
name: "@ttsc/name",
source: path.resolve(__dirname, "..", "plugin"),
stage: "transform",
};
};The plugin directory is inside the package root, so the source builder finds the package go.mod by walking upward.
For a package that exposes config types, put the descriptor in src/index.ts and export those types from the same root index.
@ttsc/banner, @ttsc/lint, @ttsc/paths, and @ttsc/strip are package-contract examples for plugin authors. Their user-facing READMEs describe install and config files; this chapter focuses on how each package is wired internally.
@ttsc/banner
Path: packages/banner
Purpose: add a configured @packageDocumentation source JSDoc block so JavaScript and declaration emit both carry the banner.
Consumer config:
// banner.config.ts
import type { TtscBannerConfig } from "@ttsc/banner";
export default {
text: "License MIT",
} satisfies TtscBannerConfig;Use compilerOptions.plugins only for inline text or a non-default config path:
{
"compilerOptions": {
"plugins": [
{
"transform": "@ttsc/banner",
"config": "./config/banner.config.ts",
},
],
},
}If no inline text, explicit config, or discovered banner config file exists, the build fails.
What to learn:
- Minimal transform plugin descriptor.
- Finding the pluginβs config from
--plugins-json. - Loading project config from
banner.config.ts. - Formatting user banner text into compiler-owned JSDoc.
- Clean error messages for invalid config.
Read:
packages/banner/src/index.tspackages/banner/plugin/main.gopackages/ttsc/utility/host.gopackages/banner/testpackages/ttsc/test/utility
Use this as the template for simple source comment transforms.
@ttsc/strip
Path: packages/strip
Purpose: remove configured call-expression statements and debugger statements from TypeScript source AST before emit.
Install:
npm install -D @ttsc/stripWith no plugin options, @ttsc/strip removes console.log, console.debug, assert.*, and debugger. Add a compilerOptions.plugins[] entry when the project needs a different call or statement list.
What to learn:
- Mutate source
SourceFile.Statementsdirectly. - Walk
SourceFile.Statementsand recurse withnode.ForEachChild. - Match
ExpressionStatement -> CallExpression. - Convert a callee AST into a dotted name such as
console.log. - Remove a whole statement by filtering the parent statement list.
Key AST flow:
SourceFile
`- Statements
`- ExpressionStatement
`- CallExpression
`- PropertyAccessExpression
|- Identifier(console)
`- Identifier(log)Read:
packages/strip/src/index.cjspackages/strip/plugin/main.gopackages/ttsc/utility/host.gopackages/strip/testpackages/ttsc/test/utility
Then compare the AST discussion in AST and Checker.
@ttsc/paths
Path: packages/paths
Purpose: rewrite source module specifiers that match compilerOptions.paths into relative output paths. Declaration emit follows the same source AST rewrite.
Install:
npm install -D @ttsc/pathsConsumer tsconfig.json:
{
"compilerOptions": {
"paths": {
"@lib/*": ["./src/modules/*"],
},
"rootDir": "src",
"outDir": "dist",
},
}What to learn:
- Transform plugins can still load tsconfig and Program data.
tsoptions.GetParsedCommandLineOfConfigFileis the right way to read compiler options.- Path alias resolution must use real project source files, not string guessing alone.
- More-specific path patterns should win before broad wildcard patterns.
- The plugin must handle module-specifier syntax that can affect emitted JS and declarations:
import ... from "..."export ... from "..."require("...")- dynamic
import("...") import("...").Ttype queries
Mental model:
emitted specifier "@lib/message"
-> match compilerOptions.paths pattern "@lib/*"
-> candidate source "./src/modules/message.ts"
-> Program confirms that source file exists
-> map source path through rootDir/outDir
-> rewrite to "./modules/message.js"Read:
packages/paths/src/index.cjspackages/paths/plugin/main.gopackages/ttsc/utility/host.gopackages/paths/testpackages/ttsc/test/utilitytests/test-paths/src/features
Then compare AST and Checker.
@ttsc/lint
Path: packages/lint
Purpose: report ESLint-style diagnostics from TypeScript-Goβs Program and Checker path.
Install:
npm install -D @ttsc/lintWhen neither rules (inline severity map) nor extends (file path) is written in tsconfig.json, use lint.config.*, ttsc-lint.config.*, or a supported ESLint flat config file (eslint.config.js, .mjs, .cjs, .ts, .mts, or .cts). If no config file exists, the build fails.
Run ttsc fix to apply supported lint fixes before the final no-emit check. Native fixers are attached as source-text edits on rule findings; ESLint-backed configs delegate to ESLintβs fix runtime and then reload the TypeScript-Go Program before diagnostics are rendered.
What to learn:
- Reporting diagnostics before emit.
- Program/Checker bootstrap for diagnostics.
- Rule registry keyed by rule name.
- Rule dispatch by
shimast.Kind. - Token-oriented diagnostic ranges with
shim/scanner. - Autofix text edits for selected native rules and ESLint runtime delegation.
- Rendering lint diagnostics beside TypeScript-Go diagnostics.
Core architecture:
compile.go
parses CLI flags
loads Program
runs compiler diagnostics
runs lint Engine
renders diagnostics
fix.go
applies native text edits
delegates ESLint runtime fixes
reloads Program before final diagnostics
engine.go
maps Kind -> active rules
walks user SourceFiles
calls rule.Check(ctx, node)
rules_*.go
implement Rule{Name, Visits, Check}Read:
packages/lint/src/index.tspackages/lint/src/structurespackages/lint/linthost/config.gopackages/lint/linthost/host.gopackages/lint/linthost/engine.gopackages/lint/linthost/compile.gopackages/lint/linthost/fix.gopackages/lint/plugin/main.goβ thin wrapper that dispatches intolinthost.packages/lint/linthostβ engine directory.tests/test-lint/src/cases
Use this design only when you need source diagnostics or semantic analysis. For source transforms, prefer the smaller banner, strip, or paths shapes.
Authoring a Lint Rule Contributor
@ttsc/lint exposes a public Go module β github.com/samchon/ttsc/packages/lint/rule β that third-party packages import to register rules. ttscβs plugin builder statically links the contributorβs Go source into @ttsc/lintβs binary via the protocol-level contributors field (see Protocol: Contributors), so contributor rules share the same single AST walk and diagnostic stream as the built-in corpus.
A contributor package has three parts:
-
JS descriptor (
lib/index.js, built fromsrc/index.ts) β exports anITtscLintPluginobject pointing at the Go source directory: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"] as const, source: path.resolve(__dirname, "..", "rules"), } satisfies ITtscLintPlugin; export default plugin; -
Go rule package (
rules/*.go) βpackage <name>, nogo.mod, registers each rule frominit(). The Go package name is the user-facing namespace with hyphens replaced by underscores (react-hooksβpackage react_hooks); the namespace itself accepts/^[a-z][a-z0-9_-]*$/:package demo import ( shimast "github.com/microsoft/typescript-go/shim/ast" "github.com/samchon/ttsc/packages/lint/rule" ) func init() { rule.Register(noTodoComment{}) } 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) { // ctx.File, ctx.Checker, ctx.Severity available // ctx.Report(node, msg) or ctx.ReportRange(pos, end, msg) } -
User registration β either inline in tsconfig (
plugins: { demo: "ttsc-lint-plugin-demo" }) or as an ESLint-flat-config plugin object insidelint.config.ts:import demoPlugin from "ttsc-lint-plugin-demo"; import { defineConfig } from "@ttsc/lint"; export default defineConfig([ { plugins: { demo: demoPlugin }, rules: { "demo/no-todo-comment": "error" }, }, ]);
What to learn:
- Public rule registration without entering
package main. - AST surface symmetry β contributor rules use the same
shim/ast/shim/checker/shim/scannerpackages first-party plugins consume. - Build-time source merging through ttscβs plugin builder, with the cache key including each contributorβs source hash.
- Two discovery surfaces in
@ttsc/lintβs JS factory: inlinepluginsmap on the tsconfig entry, and flat-configpluginsfield inside alint.config.ts/eslint.config.ts(evaluated through ttsx).
Read:
packages/lint/rule/rule.goβ the public Go surface (Rule, Context, Severity, Register).packages/lint/linthost/contrib_adapter.goβ host-side adapter that wrapsrule.Ruleinto the engineβs internalRule.tests/lint-contributor-demoβ the canonical reference contributor used by the e2e tests.tests/test-lint/src/features/contributorβ end-to-end coverage for both discovery surfaces.
Emitting Autofixes
A contributor rule can attach source-text edits to a finding by calling ctx.ReportFix (single edit) or ctx.ReportRangeFix (explicit pos/end). The host applies edits between the cascading native passes and the final no-emit check; if the host build did not opt into fix mode the edits are silently dropped and only the diagnostic is rendered β see rule.ReportFix GoDoc for the silent-fallback contract. Do not rely on edits being applied; design the rule so the diagnostic alone is useful.
package demo
import (
shimast "github.com/microsoft/typescript-go/shim/ast"
"github.com/samchon/ttsc/packages/lint/rule"
)
func init() { rule.Register(noTodoComment{}) }
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) {
// Resolve pos/end from a shim/scanner trivia walk over ctx.File.Text();
// see tests/lint-contributor-demo/rules/no_todo_comment.go for the runnable form.
ctx.ReportFix(node, "drop TODO comment", rule.TextEdit{
Pos: pos,
End: end,
Text: "",
})
}See tests/lint-contributor-demo/rules/no_todo_comment.go for the original diagnostic-only contributor and tests/lint-contributor-demo/rules/capitalize_exports.go for the first contributor that emits a real ReportRangeFix covered end-to-end by tests/test-lint/src/features/fix/test_lint_fix_contributor_rule_single_edit_applies_through_native_engine.ts.
Within a single fix pass, edits must not overlap; ordering inside the slice does not matter, and an empty Text deletes the range. When two edits in the same pass cover overlapping ranges (within one finding or across two rules), the host keeps the earliest-starting / shortest edit and silently drops the rest β there is no diagnostic for dropped edits today. Prefer one contiguous edit per finding over speculative multi-edit batches; no-import-type-side-effects is the canonical multi-edit example because the inserts and per-specifier deletes are guaranteed non-overlapping.
rule/astutil β byte-oriented helpers for contributor fixers
The github.com/samchon/ttsc/packages/lint/rule/astutil package exposes the same byte-range helpers that @ttsc/lintβs built-in rules use:
NodeText(file, node)β node source with leading trivia stripped, for splicing into a replacement string.KeywordStart(file, node, "var")β offset of the leading keyword token of a node, anchoring the keyword-swap shape.FindKeyword(file, pos, end, "import")β identifier-aware keyword scan over an arbitrary byte range; matches do not cross identifier boundaries (importβ theimportprefix ofimportMap). Note: the scan is byte-only and not comment-aware; for contributor fixers that need to locate a token inside a node, prefer combiningshim/scannerβsSkipTriviawith a manualbytecheck, the wayno-import-type-side-effectsdoes.TokenRange(file, node)β[pos, end)with leading trivia skipped, for βreplace the whole nodeβ edits.
The API is intentionally narrow in round 1 / round 2 β IdentifierText, StripParens, HasModifier, and a function-like body walker are the most likely next additions; in the meantime contributor rules can implement them inline.
rule.FixReporter β host-side reporter shape
The hostβs fix-aware reporter implements rule.FixReporter, which the public Context.ReportFix / ReportRangeFix discover via a type assertion. Contributor rules do not implement this interface. When unit-testing a contributor rule with a fake rule.Reporter, declare var _ rule.FixReporter = &myReporter{} in the test to compile-check that the fake supports the fix path; Go interface satisfaction is all-or-nothing, so a fake that implements only ReportFix and not ReportRangeFix will silently fall through to the legacy Report path.
Reading User Options
A user can configure your rule with an [severity, options] tuple. The options blob arrives on ctx.Options as a json.RawMessage; decode it into a struct that you control:
type noMarkerCommentOptions struct {
Markers []string `json:"markers"`
}
func (noMarkerComment) Check(ctx *rule.Context, node *shimast.Node) {
var opts noMarkerCommentOptions
_ = ctx.DecodeOptions(&opts)
if len(opts.Markers) == 0 {
opts.Markers = []string{"TODO", "FIXME"} // defaults
}
// ...inspect ctx.File.Text() against opts.Markers...
}DecodeOptions is a thin json.Unmarshal wrapper that returns nil (no error, struct untouched) when the user configured the rule with a bare severity literal. The pattern above β declare struct, call DecodeOptions, apply defaults β is the same one the built-in format/* rules use.
Per-rule option typing on the TypeScript side
When a consumer writes ["error", { markers: ["TODO"] }] in their lint.config.ts, TypeScript needs to know that "demo/no-marker-comment" accepts an object with a markers: string[] field. The public TtscLintRuleOptionsMap interface is declaration-mergeable β augment it from your plugin packageβs entry point so user configs autocomplete and reject typos:
// ttsc-lint-plugin-demo/src/index.ts
import type { ITtscLintPlugin } from "@ttsc/lint";
import path from "node:path";
const plugin: ITtscLintPlugin = {
meta: {
name: "ttsc-lint-plugin-demo",
version: "1.0.0",
namespace: "demo",
},
rules: ["no-marker-comment"] as const,
source: path.resolve(__dirname, "..", "rules"),
};
declare module "@ttsc/lint" {
interface TtscLintRuleOptionsMap {
"demo/no-marker-comment": {
/** Comment markers to flag. Defaults to TODO / FIXME. */
markers?: readonly string[];
};
}
}
export default plugin;The key in TtscLintRuleOptionsMap must exactly equal the rule name ("<namespace>/<rule>") that the Go side reports through Rule.Name(), and the field names in the interface must match the JSON tags on the Go struct (Markers []string \json:βmarkersββ). The two layers are otherwise independent β the TS interface controls editor autocomplete; the Go struct controls runtime decoding.
Tagging a Format Rule
ttsc fix is the run-everything entry point β its cascade applies edits from every enabled rule, lint-class and format-class together. ttsc format is the format-only convenience: it filters to format-class rule edits so lint rewrites are skipped. Opt into the format category by implementing the optional rule.FormatRule marker so the format subcommand can pick your rule out of the engineβs full finding stream:
package demo
import (
shimast "github.com/microsoft/typescript-go/shim/ast"
"github.com/samchon/ttsc/packages/lint/rule"
)
func init() { rule.Register(demoTrimTrailingSpace{}) }
type demoTrimTrailingSpace struct{}
func (demoTrimTrailingSpace) Name() string { return "demo/trim-trailing-space" }
func (demoTrimTrailingSpace) IsFormat() bool { return true }
func (demoTrimTrailingSpace) Visits() []shimast.Kind { return []shimast.Kind{shimast.KindSourceFile} }
func (demoTrimTrailingSpace) Check(ctx *rule.Context, node *shimast.Node) {
// Emit ReportFix / ReportRangeFix with formatter-class edits.
}IsFormat is a structural marker β returning false is equivalent to omitting the interface entirely, and the host treats either form the same way. Diagnostics from format-tagged rules still respect the severity ladder when reported through ttsc check, so a project can pair a developer-local ttsc format with a CI ttsc check that fails on any unformatted file.
Combined Project
{
"compilerOptions": {
"paths": {
"@lib/*": ["./src/modules/*"],
},
"rootDir": "src",
"outDir": "dist",
"plugins": [
{ "transform": "@ttsc/banner", "config": "./banner.config.ts" },
{
"transform": "@ttsc/strip",
"calls": ["console.log", "console.debug", "assert.*"],
"statements": ["debugger"],
},
],
},
}banner.config.ts:
import type { TtscBannerConfig } from "@ttsc/banner";
export default {
text: "License MIT",
} satisfies TtscBannerConfig;lint.config.json:
{
"no-var": "error"
}Behavior:
@ttsc/lintreports diagnostics before emit. It can uselint.config.*,ttsc-lint.config.*, supported ESLint flat config files, or direct plugin config.@ttsc/banneruses inline text, an explicitconfigpath, or a banner config file.@ttsc/pathsreadscompilerOptions.paths,rootDir, andoutDir.@ttsc/stripuses its defaults unless a direct plugin config overrides them.- TypeScript-Go emits JavaScript, declarations, and maps.
Pinned by: ttsc first-party plugins: lint, banner, paths, and strip run together in ttsc build in tests/test-ttsc/src/features/first-party-plugins/test_ttsc_first_party_plugins_lint_banner_paths_and_strip_run_together_in_ttsc_build.ts.
See also
- Architecture β the build/cache pipeline behind every plugin.
- Pitfalls β failure modes specific to plugin authoring.
- Plugins (consumer view) β how end-users wire these plugins into
tsconfig.json.