@ttsc/strip: Statement Removal
This walkthrough is for plugin authors implementing statement removal through the linked Go driver.
@ttsc/strip deletes debugger; statements and call expressions whose callee matches a pattern like console.log or assert.* from TypeScript source before emit. The package introduces the patterns every “remove a syntactic construct” plugin uses: matching call shapes, filtering NodeList.Nodes, recursing into block-bodied statements, and the embedded-statement substitution trick (replacing if (x) console.log(y) with if (x); rather than if (x), which would be invalid).
If you completed @ttsc/banner, the standalone main.go dispatcher will look familiar. Strip’s dispatcher is identical except for the package name. The published descriptor points at linked driver/ source, so the new material on this page is the linked rewriter in driver/strip.go.
What it does for the consumer
With no plugin options, @ttsc/strip removes the default patterns: console.log, console.debug, assert.*, and debugger;.
// tsconfig.json: opt in with defaults
{
"compilerOptions": {
"outDir": "dist",
"plugins": [{ "transform": "@ttsc/strip" }],
},
}// src/main.ts (before)
console.log("hello");
debugger;
export const value = 1;// dist/main.js (after)
export const value = 1;Both statements vanish. The export survives.
For a custom call list, drop a strip.config.ts next to your tsconfig.json:
// strip.config.ts
import type { ITtscStripConfig } from "@ttsc/strip";
export default {
calls: ["console.log", "console.debug", "myLogger.*"],
statements: ["debugger"],
} satisfies ITtscStripConfig;Two configuration knobs:
calls: match call statements by dotted name.console.logmatches exactlyconsole.log(...);myLogger.*matchesmyLogger.<anything>(...)for one or more property accesses after the prefix. Wildcards are only supported at the end of a pattern.statements: match statement-level syntax. Today onlydebuggeris supported; the slot exists so future statement kinds can be added without a breaking change.
@ttsc/strip discovers its config 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), looking for strip.config.{ts,cts,mts,js,cjs,mjs,json}. To point at a specific file, set configFile on the tsconfig entry: { "transform": "@ttsc/strip", "configFile": "./config/strip.config.ts" }.
Directory layout
packages/strip/src/index.ts defines the descriptor factory. src/structures/ITtscStripConfig.ts defines the consumer config type, re-exported by the entry. Run pnpm --filter @ttsc/strip build to generate lib/index.js and lib/index.d.ts, the runtime and type entries selected by the package manifest.
The linked transform lives in driver/strip.go, with config loading in driver/config.go. plugin/main.go is the standalone dispatcher and plugin/strip.go imports the linked driver. The Go source remains in the published package because the host compiles it for the selected native toolchain.
The TypeScript descriptor (src/index.ts)
The default-exported factory validates plugin-entry keys and returns the native registration. Its core registration is:
const configInputs = stripConfigInputs(context);
return {
hostInputHashes: configInputs.hashes,
hostInputRealpaths: configInputs.realpaths,
hostInputs: configInputs.inputs,
name: "@ttsc/strip",
source: path.resolve(context.dirname, "..", "driver"),
stage: "transform",
};The host supplies the loaded module’s absolute directory in context.dirname, so the sibling Go source resolves independently of the caller’s working directory. The build uses the workspace TypeScript compiler and emits both JavaScript and declarations from maintained TypeScript source.
Discovery starts from context.pluginConfigDir when supplied, otherwise from the tsconfig directory. An explicit nonblank string configFile records only that path. Implicit discovery records all seven candidate names in each ancestor directory until it finds a file or reaches the filesystem root. Directory candidates do not stop the walk.
hostInputs makes the host watch both present and absent candidates. hostInputHashes records file bytes, directory markers or failed reads; hostInputRealpaths records physical targets. A nearer config appearing or a symlink changing must invalidate reuse even when the previously selected config is unchanged. The native loader validates the selected config and rejects multiple files in the same directory; this factory observes discovery inputs without evaluating configuration.
Unknown entry keys throw rather than silently turning stale inline options into default stripping. The consumer’s ITtscStripConfig belongs in a dedicated config file. Declaring either calls or statements replaces both built-in lists; omitting both selects the defaults. Removing a matched call statement also removes evaluation of its arguments, which is the intentional product effect.
The standalone sidecar (plugin/main.go)
Identical structure to banner’s standalone sidecar, with @ttsc/strip in the package name and error messages. Consumer builds use the linked driver/ source through the selected native host; this command is still useful as a package test target and executable-sidecar example:
package main
import (
"fmt"
"os"
"github.com/samchon/ttsc/packages/ttsc/utility"
)
const version = "0.0.1"
func main() {
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "@ttsc/strip: command required (expected build|transform|check|version)")
return 2
}
switch args[0] {
case "-v", "--version", "version":
fmt.Fprintf(os.Stdout, "@ttsc/strip %s\n", version)
return 0
case "build": return utility.RunBuild(args[1:])
case "transform": return utility.RunTransform(args[1:])
case "check": return utility.RunCheck(args[1:])
default:
fmt.Fprintf(os.Stderr, "@ttsc/strip: unknown command %q\n", args[0])
return 2
}
}Same run shape, same exit codes, same subcommand surface as banner. The interesting code lives in the shared host.
The real logic: stripRewriter in driver/strip.go
The strip-specific code in packages/strip/driver/strip.go is roughly five components. We walk each one in the order they execute at runtime:
- Parse the config: load
strip.config.*(or use defaults) into a*stripRewritervalue. - Apply to every source file: for each
SourceFilein the Program, filter top-level statements and recurse. - Recurse into block-bodied statements: function bodies, blocks, etc. Carry their own statement lists.
- Substitute embedded statements:
if (x) <strip-me>;becomesif (x);(anEmptyStatement) so the parent syntax stays valid. - Match call patterns: turn
call.Expressioninto a dotted name and test against the pattern set.
1. Parsing the config
type stripRewriter struct {
calls []callPattern
stripDebugger bool
}
type callPattern struct {
parts []string
wildcard bool
}
func parseStrip(config map[string]any) (*stripRewriter, error) {
_, hasCalls := config["calls"]
_, hasStatements := config["statements"]
if !hasCalls && !hasStatements {
config = map[string]any{
"calls": []any{"console.log", "console.debug", "assert.*"},
"statements": []any{"debugger"},
}
}
calls, err := stringArrayConfig(config, "calls")
if err != nil {
return nil, fmt.Errorf("@ttsc/strip: %w", err)
}
statements, err := stringArrayConfig(config, "statements")
if err != nil {
return nil, fmt.Errorf("@ttsc/strip: %w", err)
}
out := &stripRewriter{}
for _, call := range calls {
pattern, err := parseCallPattern(call)
if err != nil {
return nil, fmt.Errorf("@ttsc/strip: %w", err)
}
out.calls = append(out.calls, pattern)
}
for _, statement := range statements {
switch statement {
case "debugger":
out.stripDebugger = true
default:
return nil, fmt.Errorf("@ttsc/strip: unsupported statement pattern %q", statement)
}
}
return out, nil
}Three details:
Default-when-empty. If both calls and statements are absent, strip injects the defaults. The check uses Go’s two-value map lookup:
_, hasCalls := config["calls"]The second return value is a boolean, true if the key was present, even when the stored value is the zero value (e.g. nil for an any map). This is how Go distinguishes “key absent” from “key present with zero value.”
Error wrapping with %w. fmt.Errorf("@ttsc/strip: %w", err) wraps the inner error so errors.Is and errors.Unwrap can still recover the original. Use %w when you want callers to inspect the cause; %v when you just want a formatted string.
[]any is Go’s unknown[]. config arrives as a map[string]any (decoded from JSON). Every value pulled out needs a type assertion before use: text, ok := raw.(string), arr, ok := raw.([]any). Numbers come out as float64 regardless of the source. The , ok form is mandatory. A bare raw.(string) panics on the wrong type, surfacing as exit 2 with a stack trace the user cannot fix. The helper stringArrayConfig type-asserts each element to string and returns a typed []string; if any element is the wrong type, the helper returns an error. The kind that pops out of parseStrip as @ttsc/strip: "calls"[1] must be a non-empty string. The cleaner alternative for richer configs is json.Marshal → json.Unmarshal into a typed struct; see Recipes → Typed Config.
Sidebar: pointer receivers and constructors
parseStrip returns *stripRewriter. A pointer to a freshly-allocated struct. The convention is that mutating methods take a pointer receiver:
func (s *stripRewriter) apply(file *shimast.SourceFile) { ... }The s *stripRewriter is the receiver; calling r.apply(file) is sugar for the method expression (*stripRewriter).apply(r, file), Go passes the receiver r as the first argument behind the scenes. By taking *stripRewriter instead of stripRewriter, the method can mutate the value the caller holds without copying it.
Go has no class keyword and no constructors. You call a function like parseStrip that returns the value. The convention is newX or parseX for these “constructor-like” functions.
2. Apply to every source file
The linked driver registers itself with the host in init() (driver.RegisterPlugin(plugin{})). At build time the host hands the parsed Program to ApplyProgram, the plugin’s single entry point:
func (plugin) ApplyProgram(prog *driver.Program, ctx driver.PluginContext) error {
config, err := loadStripConfigMapWithReporters(ctx.Entry.Config, ctx.Cwd, ctx.Tsconfig, ctx.ReportHostInput, ctx.ReportHostInputHash, ctx.ReportHostInputRealpath, ctx.ReportObservationIncomplete)
if err != nil {
return err
}
rewriter, err := parseStrip(config)
if err != nil {
return err
}
ctx.ReportDependenciesComplete()
for _, file := range prog.SourceFiles() {
rewriter.apply(file)
}
return nil
}loadStripConfigMapWithReporters (in driver/config.go) rejects unsupported inline keys on the tsconfig entry, then resolves the config file: an explicit configFile wins, otherwise strip.config.* is discovered by upward walk. It reports observed configuration inputs and any incomplete observation to the host. The resulting map feeds parseStrip from step 1.
The rewrite reads each file’s AST and the reported configuration, so it can declare its source dependencies complete. Configuration observation has a separate completeness signal; this declaration does not override it.
prog.SourceFiles() drops declaration files, but it returns every non-declaration implementation source resident in the Program, including imported raw TypeScript or JavaScript dependencies. This plugin deliberately applies the strip rules to that complete implementation-source set; filter paths explicitly if a plugin should mutate project-owned files only.
Then (*stripRewriter).apply:
func (s *stripRewriter) apply(file *shimast.SourceFile) {
if s == nil || file == nil || (len(s.calls) == 0 && !s.stripDebugger) {
return
}
filterChildStatements(file.AsNode(), s)
}s == nil check. Defensive. Strip is constructed by parseStrip, so s is normally non-nil; the check is there because Go dispatches a method even when the receiver is nil. The panic only happens at the first field deref. The body does deref s.calls on the next line, but the || short-circuit fires the early return before that ever runs. Catching nil-receivers explicitly is the convention in this codebase; it makes the body’s invariants easy to read.
file.Statements is a *shimast.NodeList. NodeList is a small wrapper: { Pos, End, Nodes []*shimast.Node }. Mutating the slice underneath is how strip removes statements.
3. Filter top-level statements
func filterStatements(list *shimast.NodeList, strip *stripRewriter) {
if list == nil || len(list.Nodes) == 0 {
return
}
out := list.Nodes[:0]
for _, stmt := range list.Nodes {
if shouldStripStatement(stmt, strip) {
continue
}
out = append(out, stmt)
}
clear(list.Nodes[len(out):])
list.Nodes = out
}list.Nodes[:0]: starts an empty output slice over the existing backing array. The output position never passes the current read position, so appending retained statements cannot overwrite unread entries.for _, stmt := range list.Nodes: iterate over a slice. The first value is the index (we discard it with_); the second is a copy of the element. For pointer-element slices like[]*Node, the copy is the pointer, not the underlying struct. Since Go 1.22 the loop variablestmtis scoped per iteration, so closures capture distinct values each pass. Pre-1.22 code reused one variable across iterations, a classic foot-gun when savingstmtinto a goroutine or callback.continue: skip to the next iteration. Here it means “drop this statement; do not append toout.”append(out, stmt): Go’s variadic list-extend.appendreturns a (possibly new) slice; you must reassign the result.clear(list.Nodes[len(out):]): clears removed tail references in the retained backing array, allowing discarded subtrees to be collected when no other owner holds them.list.Nodes = out: publishes the shortened slice without allocating a replacement backing array.
The function is in-place: it mutates list.Nodes to a smaller slice and is therefore visible to anyone holding a pointer to the same *NodeList. This is exactly what the printer reads when it renders the file back to TypeScript text.
Sidebar: why filtering, not deleting?
There is no Node.Remove() API. The shimast package treats AST nodes as plain data; structural mutation is just slice manipulation. This keeps the model simple, the printer iterates Statements.Nodes directly without any deletion bookkeeping, at the cost of forcing the plugin author to think in terms of list rewrites.
For leaf-text mutations (changing an identifier or string literal in place), the picture is different and you need the synthesize-flag invariant. See the @ttsc/paths walkthrough for the canonical pattern.
4. Recurse into block-bodied statements
func filterChildStatements(node *shimast.Node, strip *stripRewriter) {
if node == nil {
return
}
filterEmbeddedStatements(node, strip)
if node.CanHaveStatements() {
filterStatements(node.StatementList(), strip)
}
node.ForEachChild(func(child *shimast.Node) bool {
filterChildStatements(child, strip)
return false
})
}Three things happen per node:
filterEmbeddedStatementshandles statements that live in single-statement slots (the body ofif (x) <stmt>,while (x) <stmt>, etc.). See step 5 below.node.CanHaveStatements()identifies nodes exposing a statement list.filterStatementscompacts that list without recursing. (CanHaveStatementsandStatementListare methods on*shimast.Nodeitself; the full surface is in AST & Checker.)node.ForEachChild(fn)owns recursion into retained children, including nested function bodies, lambdas and arrow expressions. List filtering and embedded-statement replacement leave traversal to this one path, avoiding repeated visits to the same subtree.
The visitor passes func(child) bool returning false. The contract: returning true short-circuits the iteration; returning false keeps going. Strip never short-circuits, so it always returns false.
Sidebar: closures and the func(...) bool shape
node.ForEachChild takes a function literal, Go’s closure syntax:
node.ForEachChild(func(child *shimast.Node) bool {
filterChildStatements(child, strip)
return false
})Closures in Go capture variables by reference, not by value. The inner function sees later mutations to the captured variable. Here strip is captured so the recursive call still has the rewriter state; nothing mutates strip after the closure is created, so the capture-by-reference detail is invisible. It matters when you capture a loop variable you intend to reassign or pass into a goroutine. The pre-1.22 loop-variable trap mentioned above is exactly this. This closure shape is the standard Go pattern for tree walking; you will see it again in the @ttsc/paths rewriter.
Sidebar: check Kind before node.AsX()
node.AsCallExpression(), node.AsIfStatement() and other typed accessors assert the node’s internal data type. A wrong kind can panic during the accessor call; checking its result for nil is too late. Check the node is non-nil and prove its Kind before calling the matching accessor. The case shimast.KindIfStatement: stmt := node.AsIfStatement() pattern in filterEmbeddedStatements supplies that proof.
ForEachChild yields heterogeneous nodes, so its callback must establish the kind before using a typed accessor. node.Symbol() has a separate contract and can return nil; see Pitfalls → AST and Checker.
5. Substitute embedded statements
This is the subtle case:
if (x) console.log("oops");If we removed the console.log outright, we would be left with if (x) followed by no statement. A syntax error. Strip’s solution is to swap the embedded statement for an EmptyStatement (;):
if (x);The code:
func filterEmbeddedStatements(node *shimast.Node, strip *stripRewriter) {
switch node.Kind {
case shimast.KindIfStatement:
stmt := node.AsIfStatement()
stmt.ThenStatement = filterEmbeddedStatement(stmt.ThenStatement, strip)
stmt.ElseStatement = filterEmbeddedStatement(stmt.ElseStatement, strip)
case shimast.KindDoStatement:
stmt := node.AsDoStatement()
stmt.Statement = filterEmbeddedStatement(stmt.Statement, strip)
case shimast.KindWhileStatement:
stmt := node.AsWhileStatement()
stmt.Statement = filterEmbeddedStatement(stmt.Statement, strip)
case shimast.KindForStatement:
// ... same pattern
case shimast.KindForInStatement, shimast.KindForOfStatement:
stmt := node.AsForInOrOfStatement()
stmt.Statement = filterEmbeddedStatement(stmt.Statement, strip)
case shimast.KindWithStatement:
// ... same pattern
case shimast.KindLabeledStatement:
// ... same pattern
}
}
func filterEmbeddedStatement(stmt *shimast.Statement, strip *stripRewriter) *shimast.Statement {
if stmt == nil {
return nil
}
if shouldStripStatement(stmt, strip) {
return emptyStatement(stmt)
}
return stmt
}
func emptyStatement(original *shimast.Node) *shimast.Statement {
empty := shimast.NewNodeFactory(shimast.NodeFactoryHooks{}).NewEmptyStatement()
empty.Flags |= shimast.NodeFlagsSynthesized
if original != nil {
empty.Loc = original.Loc
}
return empty
}Three details in emptyStatement:
shimast.NewNodeFactory(...)is the canonical way to construct synthesized nodes. Direct struct literals work for some kinds but not all; the factory ensures internal fields are properly initialized.empty.Flags |= shimast.NodeFlagsSynthesizedmarks the node as printer-friendly. Without this flag, the printer tries to read the original source text for this position and fails because the position is reused from a different node.empty.Loc = original.Locpreserves the source location so diagnostics and source maps still point at the originalconsole.log. The synthesize flag covers the printer; this preserves debuggability.
Sidebar: when to set the synthesize flag
Three rules of thumb:
- Structural mutations (filtering a
NodeList, swapping a child pointer): no flag needed. The printer iterates lists directly without source spans. - New nodes constructed via
NewNodeFactory.NewX(): setNodeFlagsSynthesized. The printer needs to know the node has no original source-text mapping. - Leaf-text mutations on an existing node (changing an identifier’s
Textfield). SetNodeFlagsSynthesizedand resetLoctoshimcore.UndefinedTextRange(). Without this, the printer reads the old text from the file’s source span and silently ignores your change. The@ttsc/pathswalkthrough has the worked example.
6. Match call patterns
The call matcher recognizes identifier chains without evaluating the callee:
func shouldStripStatement(node *shimast.Node, strip *stripRewriter) bool {
if node == nil {
return false
}
switch node.Kind {
case shimast.KindDebuggerStatement:
return strip.stripDebugger
case shimast.KindExpressionStatement:
expr := node.AsExpressionStatement().Expression
name, ok := callExpressionName(expr)
return ok && strip.matchesCall(name)
default:
return false
}
}
func callExpressionName(expr *shimast.Node) (string, bool) {
if expr == nil || expr.Kind != shimast.KindCallExpression {
return "", false
}
call := expr.AsCallExpression()
return dottedName(call.Expression)
}
func dottedName(expr *shimast.Node) (string, bool) {
var suffix []string
for expr != nil && expr.Kind == shimast.KindPropertyAccessExpression {
prop := expr.AsPropertyAccessExpression()
if prop.Name() == nil {
return "", false
}
suffix = append(suffix, prop.Name().Text())
expr = prop.Expression
}
if expr == nil || expr.Kind != shimast.KindIdentifier {
return "", false
}
if len(suffix) == 0 {
return expr.Text(), true
}
var name strings.Builder
name.WriteString(expr.Text())
for i := len(suffix) - 1; i >= 0; i-- {
name.WriteByte('.')
name.WriteString(suffix[i])
}
return name.String(), true
}dottedName converts console.log into the string "console.log":
console.logis aPropertyAccessExpressionwith expressionconsole(anIdentifier) and namelog(anotherIdentifier).- The loop collects property names from right to left, reaches the identifier
console, then writes the identifier and reversed suffix through onestrings.Builder. Long chains avoid repeatedly copying a growing prefix.
For shapes such as obj["log"]() and (this as Logger).log(), dottedName returns ("", false) because the callee is not an identifier/property-access chain. Optional access or call tokens do not change those node kinds, so console?.log() and console.log?.() still produce "console.log" and match the configured pattern. Strip recognizes the syntax without evaluating the callee; stripping the statement also removes its argument effects.
expr.Text() is safe after the expr == nil || expr.Kind != shimast.KindIdentifier guard because it excludes nil and every non-identifier kind before the call. See Pitfalls → AST and Checker.
The matcher itself:
func (s *stripRewriter) matchesCall(name string) bool {
if len(s.calls) == 0 {
return false
}
parts := strings.Split(name, ".")
for _, pattern := range s.calls {
if pattern.matchesParts(parts) {
return true
}
}
return false
}
func (p callPattern) matchesParts(parts []string) bool {
if p.wildcard {
if len(parts) <= len(p.parts) {
return false
}
return equalStringSlices(parts[:len(p.parts)], p.parts)
}
return equalStringSlices(parts, p.parts)
}For console.log, parts = ["console", "log"]. A pattern console.log with wildcard: false requires exact length and element-equality. A pattern console.* (parsed into parts: ["console"], wildcard: true) requires strictly more name segments than pattern segments. Note the early-return if len(parts) <= len(p.parts) { return false }, so console.* matches console.log but not the bare identifier console. After that guard, the prefix segments must match element-wise.
The call name is segmented once and reused across candidate patterns.
What to copy, what to ignore
Copy:
- The
filterStatementsshape: compact retained statements in the existing backing array, clear removed tail references and publish the shorter slice. Let one visitor own recursion into retained children. - The
filterEmbeddedStatementsenumeration of statement kinds. If you ever need to walk single-statement-bearing nodes, this list is exhaustive for ES2022. - The
emptyStatementconstructor pattern: factory → setSynthesizedflag → preserveLoc. This is the right shape whenever your rewrite must substitute a placeholder for a removed node. - The
dottedNameloop and builder when recognizingpkg.fnorobj.methodcallees. Check node kinds before calling typed accessors.
Do not copy:
- The package-specific default-config injection in
parseStripunless your plugin intentionally has default removal patterns. - The standalone
plugin/strip.gowrapper when your descriptor points directly at an executable command package.
Test coverage
The stripRewriter is exercised two ways:
- Package-local Go tests in
packages/strip/test: run the binary throughgo run ./pluginagainst fixture projects, one assertion per file in the AGENTS.md §2.2 shape. - End-to-end TypeScript fixtures under
tests/test-ttsc/src/features/: spawn the realttsclauncher and assert on emitteddist/files.
Pick the strip tests as your template when you write your own. They cover the structural-mutation shape your plugin probably also has.
Where to read next
@ttsc/paths: leaf-text mutation, the synthesize-flag invariant, and Program-backed alias resolution.- AST & Checker → Recognizing Calls: the same
dottedNamepattern, in the protocol docs. - Authoring → End-to-end transform: write your own
debugger;remover from scratch, following the third-party plugin shape.