Plugin Protocol Reference
This page is the contract between ttsc and a plugin package.
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: non-empty display name.source: Go command package directory orgo.modfile. 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. First-party utility plugin names (@ttsc/banner,@ttsc/paths,@ttsc/strip) cannot appear here; they have their own auto-composition path through the shared compiler host.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 | Binary commands |
|---|---|---|
omitted / "transform" | participates in the TypeScript-Go transform path | check, transform, build |
"check" | reports diagnostics before emit | check; optional 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.
Composition
Projects can enable multiple plugin entries. check entries run before emit and compose with transform entries.
Transform entries can share one compiler host when they resolve to the same native binary. This is how the first-party utility plugins compose:
{
"compilerOptions": {
"plugins": [
{ "transform": "@ttsc/banner", "text": "license" },
{ "transform": "@ttsc/strip", "calls": ["console.log"] },
],
},
}Distinct third-party 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 and dispatch by explicit mode or option fields in the --plugins-json payload.
Composing across binaries
Third-party 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:
- Composition is one hop only. ttsc does not transitively follow
composesarrays of composed plugins. - Cycles (two plugins listing each other) are rejected with an explicit error.
- First-party utility names (
@ttsc/banner,@ttsc/paths,@ttsc/strip) cannot appear incomposes. They are composed automatically through the shared compiler host hosted bypackages/ttsc/utility/host.go. - The aggregate’s own descriptor still needs a real
sourcedirectory; ttsc never composes a plugin into nothing.
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:
- Contributors ship Go source as a package, not a Go module. A contributor with its own
go.modis rejected. 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. 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.contributor.sourcemust be an absolute path to an existing directory.- Contributor names must be unique within one plugin build.
- 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. - A composed plugin (one redirected by another’s
composes) cannot declare its owncontributors— move them onto the aggregate, or drop thecomposesredirect.
The cache key derivation for a plugin with N contributors is ttsc + tsgo + platform + entry + Σ(contributor source hashes) + plugin source hash + overlay source hashes, so consumers with the same logical set of contributors share one cached binary regardless of declaration order.
Plugin Config Keys
ttsc reads only transform and enabled from each user plugin entry. Every other key remains plugin-owned config and is passed through unchanged to the native sidecar.
ts-patch words such as before, after, or phase do not affect ttsc execution. If a plugin package chooses to use those names for its own config, they are ordinary plugin data. Package 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.
version
my-plugin version
my-plugin -v
my-plugin --versionPrint a human-readable version and exit 0.
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.
Plugins that do not support fixes should exit 2 with a stderr message of the
form <plugin-name>: fix not supported. The host surfaces that as a build
failure. Plugins that support fixes but find nothing to apply exit 0 with
empty stderr.
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.
Plugins that do not support format should exit 2 with a stderr message
of the form <plugin-name>: format not supported. Plugins that support
format but find nothing to apply exit 0 with empty stderr.
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"
}
}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 original tsconfig plugin entry. Read user options there.
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.
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.
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
- Plugin Development · TS-Go concepts — Program / Checker / VFS.
- Plugin Development · Authoring — the 5-step authoring walkthrough.
- Plugin Development · Reference — architecture and pitfalls.