Plugin Protocol Reference
This page is the contract between ttsc and a plugin package. It is one page on purpose — linked transform packages, composes, contributors, factory generics, and the wire JSON are all validated in one pipeline, and splitting them would force forward references between two co-canonical files. Most readers can start with the Minimum contract section (descriptor, the five subcommands, --plugins-json, exit codes); skip linked packages / composes / contributors / factory generics on the first read.
Looking for the two-halves model, vocabulary, or the stage/subcommand matrix? Start at Concepts.
Manifest
The consumer points compilerOptions.plugins[] at a JavaScript module:
{
"compilerOptions": {
"plugins": [
{ "transform": "my-plugin", "mode": "strict", "enabled": true },
],
},
}The module exports either a plugin object or a factory:
const path = require("node:path");
module.exports = (context) => ({
name: "my-plugin",
source: path.resolve(__dirname, "go-plugin"),
stage: "transform",
});context contains:
{
binary: string;
cwd: string;
plugin: ITtscProjectPluginConfig;
projectRoot: string;
tsconfig: string;
}context.plugin is the original tsconfig plugin entry. If you want stronger typing, specialize the context type in your factory:
context.binary is the absolute ttsc native helper selected for this invocation. It is not the plugin sidecar binary and not the JavaScript launcher. Most descriptors do not need it; it exists for advanced factories that need to inspect the active native host.
import * as path from "node:path";
import type { ITtscPluginFactoryContext } from "ttsc";
type MyPluginEntry = {
transform: string;
mode?: string;
};
export function createTtscPlugin(
context: ITtscPluginFactoryContext<MyPluginEntry>,
) {
return {
name: "my-plugin",
source: path.resolve(__dirname, "go-plugin"),
stage: "transform",
};
}Shape
interface ITtscPlugin {
name?: string;
source: string;
composes?: string[];
stage?: "transform" | "check";
contributors?: ITtscPluginContributor[];
}
interface ITtscPluginContributor {
name: string;
source: string;
}Field rules:
name: optional display label for diagnostics and build messages. Routing is not based on package identity.source: Go package directory orgo.modfile. Apackage mainsource builds as an executable sidecar. A non-maintransform source is linked into the selected native host and must register itself throughdriver.RegisterPlugin. Relative paths are resolved from the consumer project root; package descriptors should usually return an absolute path based on__dirname.composes: optional list of other plugin names (or originaltransformspecifiers) whose source build should be redirected to this descriptor’ssource. Composition is one hop only:A.composes = ["B"]sends B to A’s binary, but ifB.composes = ["C"]then C is sent to B’s original binary, not A’s. Reciprocal entries (A.composes = ["B"]andB.composes = ["A"]) are rejected as a cycle.stage: plugin kind. Omit for"transform".contributors: optional list of additional Go source packages to statically link into this plugin’s binary at build time. Each entry’ssourceis copied into the scratch build tree as<scratch>/contrib/<name>/, and a synthesized blank import in the entry package triggers the contributor’sinit()beforemain. See Contributors below.
ttsc accepts Go source only. It builds the source with the pinned Go toolchain and TypeScript-Go shim overlay, then caches the resulting executable.
Stages
Public stages are deliberately small:
| Stage | Host behavior | Subcommands the host invokes |
|---|---|---|
omitted / "transform" | participates in the TypeScript-Go transform path | check, transform, build |
"check" | reports diagnostics before emit | check; opt-in fix, format |
There is no public output stage. Plugins do not receive generated JavaScript text or emitted file text for post-processing.
When the user runs ttsc fix, ttsc invokes check-stage plugins with
the fix subcommand and keeps JavaScript/declaration emit disabled.
fix is the run-everything entry point: edits from every enabled rule
flow through it, lint-class and format-class together. See
CLI Commands → fix for the subcommand contract.
When the user runs ttsc format, ttsc invokes the same check-stage
plugins with the format subcommand. ttsc format is the format-only
convenience that filters to format-class rule edits so lint rewrites
are skipped — pick this subcommand when you want to reshape source
without applying lint rewrites. See CLI Commands → format
for the subcommand contract.
Transform-stage plugins do not see fix or format. The host only spawns those subcommands against stage: "check" plugins; a transform-stage plugin’s default branch will never receive them.
Composition
Projects can enable multiple plugin entries. check entries run before emit and compose with transform entries.
Transform entries can share one compiler host in two ways:
- Several entries resolve to the same executable native binary, usually because one descriptor uses
composes. - One or more entries point at non-
mainGo packages.ttsclinks those packages into the selected executable host. If there is no executable transform host,ttscbuilds a generic host and links the packages there.
This is how linked transform packages compose:
{
"compilerOptions": {
"plugins": [
{ "transform": "@ttsc/banner", "text": "license" },
{ "transform": "@ttsc/strip", "calls": ["console.log"] },
],
},
}Distinct executable compiler hosts cannot be chained blindly, because each one would need to own Program creation and emit. If several transform modes must cooperate, expose them from one native binary, redirect with composes, or ship non-main linked packages that register against the driver host.
Combining plugins from different vendors
Before the host invokes any transform plugin, assertSharedHostCompatibility (packages/ttsc/src/compiler/internal/sharedHostHelpers.ts) checks the resolved plugin binaries after linked packages are removed from the compiler-owner set. If more than one distinct executable binary remains active, the host aborts with one of:
ttsc: multiple compiler native backends cannot share one emit pass;
compose transform libraries through one aggregate native host
ttsc: multiple transform native backends cannot share one source-to-source pass;
compose transform libraries through one aggregate native hostThe first fires during ttsc build / ttsc check; the second during ttsc transform. To combine transform libraries from different vendors (typia + nestia-style), pick one aggregate executable plugin and list the others in its composes: [...] array, or make the additional transforms linked packages that implement the driver plugin hooks. See Reference → Pitfalls for the full failure catalogue.
Composing across binaries
Executable plugins that want to share one compiler host can opt in through the composes field on their descriptor:
module.exports = {
name: "my-aggregate-plugin",
source: path.resolve(__dirname, "go-plugin"),
stage: "transform",
composes: ["my-feature-a", "my-feature-b"],
};When ttsc loads the descriptors of my-feature-a and my-feature-b from the project’s compilerOptions.plugins, it reroutes their build target to the aggregate’s source. All three names remain in the --plugins-json payload so the aggregate sidecar can dispatch by name. The aggregate must implement the dispatch logic itself; ttsc only redirects the binary.
Rules enforced at load time:
- One hop only. ttsc does not transitively follow
composesarrays of composed plugins.A.composes = ["B"]sends B to A’s binary; ifB.composes = ["C"]then C is sent to B’s original binary, not A’s. - Cycle rejected. Two plugins each listing the other in
composesis a hard error (plugin composes cycle detected between "<A>" and "<B>"). - Multi-aggregate rejected. A plugin claimed by two different aggregates is a hard error (
plugin "<name>" is composed by multiple aggregate plugins). - Empty-string target rejected. Each entry in
composesmust be a non-empty string matching the target’snameor its tsconfigtransformspecifier. - No void aggregates. The aggregate’s own descriptor still needs a real
sourcedirectory; ttsc never composes a plugin into nothing.
See Reference → Pitfalls for the exact error strings and recovery steps.
Contributors
composes is horizontal — it lets multiple top-level plugin entries dispatch to one binary by name. contributors is vertical — it lets one binary statically link additional Go sources that never appear as compilerOptions.plugins[] entries. The contributing npm packages are discovered through the host plugin’s own configuration (for @ttsc/lint, that is lint.config.ts’s plugins map).
A host plugin populates contributors from its factory:
import path from "node:path";
module.exports = (context) => ({
name: "@ttsc/lint",
source: path.resolve(__dirname, "plugin"),
stage: "check",
contributors: [
{ name: "demo", source: "/abs/path/to/lint-contributor-demo/rules" },
],
});ttsc’s plugin builder then:
- Copies the host plugin’s source to a scratch directory.
- Copies each contributor’s
sourceinto<scratch>/contrib/<contributor.name>/. - Synthesizes a
ttsc_contributions.gonext to the host’s entry package with one blank import per contributor:import _ "<host-module-path>/contrib/<name>". - Hashes every contributor source directory into the binary cache key (so swapping a contributor invalidates the cache).
- Runs
go build. The resulting binary has every contributor’sinit()already executed by the timemainstarts.
Constraints enforced at load time:
- Package, not module. Contributors ship Go source as a package. A contributor with its own
go.modis rejected at build time, not silently pruned — embed the contributor’s source as a sub-package, not a separate Go module. The host plugin’sgo.modsupplies every transitive Go dependency, which also closes the supply-chain hole where a contributor could otherwise pull in arbitrary Go modules at build time. - At least one non-test
.gofile. A contributor directory with only*_test.gofiles (or no.gofiles at all) is rejected at load time. Test fixtures alone are not a contributor. - Host plugin must have a resolvable Go module path. The aggregate plugin’s source directory must resolve to a
go.modmodule path (or be co-located with one through the workspace overlay); the builder errors out if it cannot derive one. - Name regex.
contributor.namemust match/^[a-z][a-z0-9_]*$/(it forms the final import-path suffix and must be a valid Go identifier). The lint factory derives this by mapping the user-facing namespace’s hyphens to underscores — namespacereact-hooksbecomes contributor namereact_hooks. The Go source’spackagedeclaration must match the post-transform name. - Absolute source path.
contributor.sourcemust be an absolute path to an existing directory. - Unique names per build. Contributor names must be unique within one plugin build.
- Reserved scratch paths. The host plugin’s source must not already ship a
contrib/directory or attsc_contributions.gofile at its entry root; both are scratch-space reserved for the build pipeline. - Not on composed plugins. A composed plugin (one redirected by another’s
composes) cannot declare its owncontributors— move them onto the aggregate, or drop thecomposesredirect.
See Reference → Pitfalls for the exact rejection error strings.
Contributor source hashes fold into the plugin cache key, so consumers with the same logical set of contributors share one cached binary regardless of declaration order. The full cache-key formula (Go binary identity, GO_BUILD_ENV_KEYS, overlay hashes) lives in Architecture → Cache key inputs.
Plugin Config Keys
ttsc owns transform and enabled. Every other key on a compilerOptions.plugins[] entry is plugin-owned config — passed through unchanged via --plugins-json (see below for the verbatim-passthrough rule). ts-patch words such as before, after, or phase carry no special meaning to ttsc. Descriptors choose only between the public "transform" and "check" stages.
Disabled Entries
enabled: false disables a plugin entry before loading:
{
"compilerOptions": {
"plugins": [
{ "transform": "my-plugin", "enabled": false },
{ "transform": "other-plugin" },
],
},
}Disabled entries are not resolved, built, or included in --plugins-json.
CLI Commands
The built Go binary receives subcommands. Unknown flags should be ignored so future ttsc minors can add optional flags. The host emits flags in equals form when spawning plugin binaries (--cwd=/abs/path, --tsconfig=/abs/path, --plugins-json=[...]); space-form (--cwd /abs/path) appears only in tsgo’s own arg list and never reaches a plugin. Go’s flag.NewFlagSet accepts both forms; hand-rolled parsers should accept equals-form at minimum.
ttscnever spawns theversion/-v/--versionsubcommand on a plugin process. First-party plugins implement it as a smoke verb forgo run ./plugin version; third-party plugins may but are not required to.
version
my-plugin version
my-plugin -v
my-plugin --versionPrint a human-readable version and exit 0. Smoke verb only — the host never invokes it.
check
my-plugin check \
--cwd=/project \
--tsconfig=/project/tsconfig.json \
--plugins-json='[...]'Run diagnostics only. Write diagnostics to stderr. Exit non-zero for errors.
fix
my-plugin fix \
--cwd=/project \
--tsconfig=/project/tsconfig.json \
--plugins-json='[...]'Optional for check-stage plugins. Invoked when the user runs ttsc fix.
Apply autofixes to source files in place, then render any
remaining diagnostics through the same renderer contract as check. Emit
stays disabled — fix plugins must not write JavaScript or declaration output.
Found nothing to apply? Exit 0 with empty stderr. Do not support fix? Exit non-zero with a human-readable stderr message — see Unsupported fix/format below.
format
my-plugin format \
--cwd=/project \
--tsconfig=/project/tsconfig.json \
--plugins-json='[...]'Optional for check-stage plugins. Invoked when the user runs ttsc format. Apply formatter-class edits (whitespace, punctuation,
ordering) to source files in place. Write-only by contract: format
subcommands must not print diagnostics and must keep JavaScript /
declaration emit disabled.
The split between fix and format is the apply-time filter, not a
plugin boundary. A check-stage plugin may host both lint and format
rules in one binary: fix applies every category’s edits; format
filters to format-class only. The two subcommands share the engine
and the protocol; only the post-engine filter differs.
Unsupported fix / format
A check-stage plugin that does not implement fix or format should exit non-zero with a human-readable stderr message. ttsc does not parse any specific phrase — the host prints stderr verbatim and aborts. The @ttsc/lint convention of <plugin-name>: fix not supported / format not supported and exit 2 is a project convention, not a host contract; any non-zero exit suffices.
transform
my-plugin transform \
--cwd=/project \
--tsconfig=/project/tsconfig.json \
--plugins-json='[...]'Project-wide source transform used by ttsc.transform() and in-memory callers. Write JSON to stdout:
{
"diagnostics": [],
"typescript": {
"src/main.ts": "export const value = 1;\n"
}
}Each value in typescript is written to disk verbatim — the host does not re-parse or re-render. If your plugin mutated the AST, you must run a printer over the file before placing its text in the map; the canonical path is shimprinter.NewPrinter(...) → shimprinter.EmitSourceFile(printer, file). Returning file.Text() after AST mutation will silently round-trip the original source. See packages/ttsc/utility/host.go::RunTransform for the reference implementation.
For leaf-text mutations (string-literal, identifier, numeric-literal .Text), the printer reads original source text via getTextOfNode when the node is non-synthesized and has a parent — see Recipes → Mutating leaf-text nodes for the synthesize-flag invariant the rewrite must follow.
build
my-plugin build \
--cwd=/project \
--tsconfig=/project/tsconfig.json \
--plugins-json='[...]' \
--emit \
--outDir=/project/distProject-wide transform build. Run diagnostics and write TypeScript-Go outputs.
--plugins-json
--plugins-json is a JSON array of loaded plugin descriptors for the current command:
[
{
"name": "my-plugin",
"stage": "transform",
"config": {
"transform": "my-plugin",
"mode": "strict"
}
}
]config is the consumer’s compilerOptions.plugins[i] entry passed through verbatim, modulo JSON normalization — every key the consumer wrote, including ttsc-owned transform and enabled, will appear. Plugins should ignore transform and enabled: ttsc owns them. (enabled: false plugins are filtered before serialization, so plugins will only ever see enabled: true or absent — but should not depend on it.) Read user options from any other key in config.
When multiple entries resolve to the same binary, ttsc sends them together. Select the entry you need by name, mode, or plugin-owned option fields.
Plugin order. The array is deterministically ordered: all check-stage plugins first, in their tsconfig order, then all transform-stage plugins, in their tsconfig order. A plugin that needs to know its position in the pipeline can rely on this rule (see packages/ttsc/src/plugin/internal/loadProjectPlugins.ts::orderNativePlugins).
Exit and Output
0: success.2: argument/config/diagnostic failure.- Any other non-zero: runtime failure.
stderris shown to users; format errors for humans.transformstdout must be the JSON shape above.buildwrites project outputs through TypeScript-Go emit.
Unrecovered Go panics in a plugin process surface as a normal exit-2 with the panic stack on stderr. The host prints stderr verbatim and propagates exit code 2 as ttsc’s own exit code; it does not catch, symbolize, or aggregate plugin panics. Wrap rule bodies in recover() if you need graceful per-rule reporting — see packages/lint/linthost/engine.go for the reference pattern.
Compatibility Rules
Within the current protocol:
ttscmay add optional flags.ttscmay add JSON fields.ttscwill not rename or remove current fields without a protocol bump.
So plugin binaries should ignore unknown flags and unknown JSON fields.
See also
- Concepts — two-halves model, stages/subcommands, vocabulary.
- AST & Checker — Program, Checker, printer, leaf-text mutation invariant.
- Reference → Driver API — the Go façade plugin authors should call.
- Reference → Architecture — cache, build environment, Go toolchain resolution.
- Reference → Pitfalls —
composes/contributors/ Go toolchain failure catalogue.