@ttsc/strip โ Statement Removal
@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 main.go dispatcher will look familiar โ stripโs is identical except for the package name. 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, override on the compilerOptions.plugins entry:
{
"compilerOptions": {
"plugins": [
{
"transform": "@ttsc/strip",
"calls": ["console.log", "console.debug", "myLogger.*"],
"statements": ["debugger"]
}
]
}
}Two configuration knobs:
callsโ match call statements by dotted name.console.logmatches exactlyconsole.log(...);myLogger.*matchesmyLogger.<anything>(...)for any single property access. 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.
Directory layout
packages/strip/
โโโ package.json
โโโ src/
โ โโโ index.cjs โ JS descriptor (CommonJS, no TypeScript types)
โโโ go.mod
โโโ driver/
โ โโโ strip.go โ Linked transform logic
โโโ plugin/
โโโ main.go โ 36-line dispatcher
โโโ strip.go โ Blank-imports the linked driver packageStrip has no TypeScript-side configuration types, so the descriptor is a plain CommonJS file โ no lib/, no compile step.
The JS descriptor (src/index.cjs)
// @ts-check
"use strict";
const path = require("node:path");
module.exports = function createTtscStrip() {
return {
name: "@ttsc/strip",
source: path.resolve(__dirname, "..", "driver"),
stage: "transform",
};
};This is the same descriptor shape as banner, written in CommonJS. The // @ts-check pragma asks the TypeScript checker to validate this file even though it has no .ts source. Useful when the manifest is hand-edited.
module.exports is CommonJSโs โdefault exportโ โ the host calls this function to get the descriptor object.
The sidecar (plugin/main.go)
Identical structure to banner, with @ttsc/strip in the package name and error messages:
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 โ turn
--plugins-jsonโsconfigobject 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 whether the user wrote 1 or 1.0 in tsconfig. 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 driver returns the parsed Program. ApplyProgram walks every source file:
func applySourceTransforms(prog *driver.Program, state transformState) error {
for _, file := range prog.SourceFiles() {
if state.paths != nil {
state.paths.apply(file)
}
if state.strip != nil {
state.strip.apply(file)
}
}
return nil
}prog.SourceFiles() already drops declaration files, so file is always user-authored TypeScript. The order is important โ paths first, then strip โ but for strip in isolation that does not matter.
Then (*stripRewriter).apply:
func (s *stripRewriter) apply(file *shimast.SourceFile) {
if s == nil || file == nil || (len(s.calls) == 0 && !s.stripDebugger) {
return
}
filterStatements(file.Statements, s)
}s == nil check. Defensive. Strip is constructed by newStripRewriter, 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 := make([]*shimast.Node, 0, len(list.Nodes))
for _, stmt := range list.Nodes {
if shouldStripStatement(stmt, strip) {
continue
}
filterChildStatements(stmt, strip)
out = append(out, stmt)
}
list.Nodes = out
}make([]T, 0, capacity)โ allocates the backing array withcapacityslots but exposes length 0;appendfills slots without reallocation until the cap is exhausted. Useful when you know an upper bound on the final length (here,len(list.Nodes)). Length and capacity are independent; newcomers conflate them.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.list.Nodes = outโ replace the underlying slice. The originallist.Nodesis garbage-collected once nothing references it.
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()is true forBlock,SourceFile,ModuleDeclaration,CaseClause, etc. โ every node kind whose body is aNodeListof statements. When true, recurse into the bodyโs statement list with the samefilterStatements. (CanHaveStatementsandStatementListare methods on*shimast.Nodeitself; the full surface is in AST & Checker.)node.ForEachChild(fn)iterates child nodes regardless of kind. We recurse into each one to find nested function bodies, lambdas, arrow expressions, etc.
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 pathsRewriter.
Sidebar: node.AsX() returns nil โ branch on Kind first
node.AsCallExpression(), node.AsIfStatement(), etc. return nil when node.Kind does not match instead of panicking. The case shimast.KindIfStatement: stmt := node.AsIfStatement() pattern in filterEmbeddedStatements is safe only because the case already proved the kind. Outside a kind-checked switch โ for example after a ForEachChild that yields heterogeneous children โ check if stmt == nil { return } before dereferencing any field. node.AsCallExpression().Expression on a non-call panics with a nil-deref, which the host surfaces as exit 2 with a Go stack on stderr. The same rule applies to node.Symbol(), which is nil for non-declaration nodes; 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)
}
filterChildStatements(stmt, strip)
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 dotted-name match is the cleanest call-shape recognizer in the repo:
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) {
if expr == nil {
return "", false
}
switch expr.Kind {
case shimast.KindIdentifier:
return expr.Text(), true
case shimast.KindPropertyAccessExpression:
prop := expr.AsPropertyAccessExpression()
left, ok := dottedName(prop.Expression)
if !ok || prop.Name() == nil {
return "", false
}
return left + "." + prop.Name().Text(), true
default:
return "", false
}
}dottedName recursively converts console.log into the string "console.log":
console.logis aPropertyAccessExpressionwith expressionconsole(anIdentifier) and namelog(anotherIdentifier).- The recursion:
dottedName(console.log)โdottedName(console)returns"console", then"console" + "." + "log"โ"console.log".
For more complex shapes โ obj["log"](), (this as Logger).log(), optional chaining โ dottedName returns ("", false) because those node kinds are not in the switch. Strip deliberately handles only the simple syntactic shape; if a user wants to strip a complex callee, they can use a regular console.log (or change the call site).
expr.Text() is safe inside the case shimast.KindIdentifier: branch because the kind check filters out kinds where Text() would panic โ see Pitfalls โ AST and Checker.
The matcher itself:
func (s *stripRewriter) matchesCall(name string) bool {
for _, pattern := range s.calls {
if pattern.matches(name) {
return true
}
}
return false
}
func (p callPattern) matches(name string) bool {
parts := strings.Split(name, ".")
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.
Thatโs the whole matcher โ 15 lines of Go for pkg.fn and pkg.* semantics.
What to copy, what to ignore
Copy:
- The
filterStatementsshape โmakea fresh slice, conditionallyappend, then assign back. This is the canonical Go pattern for in-place list filtering. - 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
dottedNamerecursion. It is the same shape@ttsc/lintโs console rule uses; copy it whenever you need to recognizepkg.fnorobj.methodcallees.
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.