sx is an automated refactoring tool for minimizing Go codebases.
LLMs are good at adding code: wrappers, fallback paths, adapters, one-off
helpers, defensive branches, and repeated special cases. The main goal of sx
is to shrink code generated by AI after implementation rounds. It measures Go
code by counting AST nodes, detects refactoring candidates, applies them one at
a time, and keeps only changes that still build, pass tests, and make the
measured program smaller.
Every change sx makes is a type-aware AST transformation performed by a
maintained Go tool: gopls for inlining and extraction, eg for
example-based rewrites, and deadcode for reachability. Before a change is
tried, sx predicts it and refuses the patterns the tool cannot transform
correctly. After a change, sx gofmts it, builds it, runs the tests of every
package that could be affected, and measures it. A change that fails any of
these steps is reverted, so every change sx keeps builds and passes the tests.
sx optimizes for small code, so review its diff the way you review any
refactoring, for naming and style. Correctness comes from the type-aware tools
and the gates, not from the review.
It is intended to be used inside a coding agent through a skill, after an implementation round, before push as a git hook, or inside CI. You can also run it manually, but the main loop is designed for repeated use after an LLM makes the code grow.
Agent and Automation Use
The primary workflow is agent-driven. A coding agent implements a change, then
runs sx to take out the bloat that round added, reviews the patch, and applies
it to your tree.
This repository ships the assistant-facing instructions for that workflow:
| File | Client | What it defines |
|---|---|---|
.codex/skills/sx/SKILL.md |
Codex | The $sx skill: when to use sx, min, bake, and adding eg rules by hand |
.claude/commands/sx.md |
Claude Code | The /sx <cmd> [param] dispatcher |
.claude/commands/sx/min.md |
Claude Code | /sx min [auto|all] |
.claude/commands/sx/bake.md |
Claude Code | /sx bake [path] |
To use them in your own repository, copy the files to the same paths there. Both
clients use the same command shape, with $ in Codex and / in slash-command
clients:
| Command | Default | What it does |
|---|---|---|
sx min auto |
yes | Minimize in a temporary worktree, review the diff, and apply only the changes that read well |
sx min all |
Minimize in a temporary worktree and apply every change that passed the gates | |
sx bake |
path sx/examples/eg |
Write new eg rewrite templates from patterns found in your code |
sx bake <path> |
The same, writing the templates to <path> |
/sx with no command, or with an unknown one, prints the summary above.
Before you start
The agent runs sx from your module, so set it up once:
go get -tool github.com/dhilst/sx/cmd/sx go install golang.org/x/tools/cmd/deadcode@latest go install golang.org/x/tools/gopls@latest go install golang.org/x/tools/cmd/eg@latest
If the helpers are missing, the agent installs them when your policy allows it.
Without go get -tool, it uses an installed sx binary, or asks you for one.
Tutorial: sx min auto
Use this after an implementation round, when you want the bloat removed and want the agent to decide what is worth keeping.
-
Commit your work.
minruns on a copy ofHEAD. If your tree has uncommitted or untracked files, the agent stops and asks you to either commit them first or abort. It never minimizesHEADwhile changes sit beside it, because the resulting diff would not match the code you have. -
Ask for it:
autois the default, so/sx min autois the same. In Codex, use$sx min. -
The agent works in a disposable worktree. It runs, roughly:
tmp=$(mktemp -d) git worktree add -d "$tmp/worktree" HEAD go tool sx refactor -apply -n 100 "$tmp/worktree"
Every change is gated there: gofmt, build, the tests of each affected package, and a re-count. Anything that fails a gate or does not shrink the tree is reverted. Your checkout is not touched.
-
The agent tests and reviews the result. It runs your project's test command in the worktree (or
go test ./...) and reads the diff. -
It applies what reads well. In
automode the agent keeps the changes it judges maintainable and briefly explains each one it drops. For example, it might keep an inlined one-use helper and drop an inline that left a bare{ ... }block behind. -
The worktree is removed, and the accepted changes are left uncommitted in your tree for you to look over:
An example of what the agent reports (the numbers are illustrative):
sx: 10099 -> 9871 nodes (-228) in 14 changes, 17 attempts.
Applied 12 changes:
- inlined parseFlags, loadConfig, newClient (each called once)
- removed unreachable legacyHandler and its "net/http/httputil" import
- extracted a repeated retry loop in fetch.go into one function
- strings.Index(s, "/") >= 0 -> strings.Contains(s, "/")
Dropped 2:
- inline of render(): left a bare block with a renamed variable
- extraction in handlers.go: the new function needs five parameters
Tutorial: sx min all
Use this when you want every gated reduction applied, for example on generated scaffolding or on a branch you will squash, and you will review the diff yourself.
The steps are the same as auto, except for step 5: every change that passed
the gates is applied, and none is dropped for style. It is a good fit for a
first pass on a large AI-written change. Follow it with a normal code review, or
run /sx min auto next time.
Tutorial: sx bake
eg templates teach sx expression rewrites specific to your codebase. bake
has the agent find them for you.
-
Ask for it:
-
The agent looks for repeated larger-than-necessary expressions, such as a comparison against a constant, a manual loop that a standard library call replaces, or a helper wrapped in a conversion it does not need.
-
It writes one template per rule to
sx/examples/eg://go:build ignore package template import "strings" func before(s, prefix string) bool { return strings.Index(s, prefix) == 0 } func after(s, prefix string) bool { return strings.HasPrefix(s, prefix) }
Each template's
beforeandafterhave the same type, and a template never drops, duplicates, or reorders an argument that could have side effects. -
It validates them without writing to your code:
go tool sx refactor -check -eg sx/examples/eg .Templates that do not parse, do not type-check under
eg, or do not shrink the tree when they match are discarded. -
Later
minruns use them automatically.sxsearchesexamples/egandsx/examples/egby default, so the next/sx minapplies the new rules along with everything else.
Commit the templates like any other code. They are ordinary Go files kept out of
your build by //go:build ignore.
Tutorial: sx bake <path>
Use a path when your team keeps rules somewhere else, or keeps several rule sets:
The steps are the same, except the templates are written to and validated in
./tools/eg. Because that is not a default search path, point sx at it
yourself:
go tool sx refactor -check -eg ./tools/eg . go tool sx refactor -apply -eg ./tools/eg -eg sx/examples/eg .
-eg can be repeated or take comma-separated paths. Passing it replaces the
default search paths, so list every directory you want.
More examples
Minimize right after a feature lands:
Implement the export-to-CSV command, commit it, then run /sx min.
Grow the rule set before minimizing:
Keep a shared rule set in the repository and use it in every run:
/sx bake ./examples/eg
/sx min all
Minimize a single package (ask the agent directly):
Run sx min on ./internal/storage only.
Git hook
Outside an agent, sx works as a pre-push hook. -check never writes files. It
exits non-zero when a shrinking candidate exists, so the push stops until the
bloat is handled:
#!/bin/sh # .git/hooks/pre-push exec go tool sx refactor -check -n 30 .
chmod +x .git/hooks/pre-push
When the hook fires, run /sx min (or go tool sx refactor -apply .), commit,
and push again.
CI
The same check works as a CI step; see the CI Example:
go tool sx refactor -check -n 30 .Structural Complexity
sx defines structural complexity as |AST|: the number of Go AST nodes needed
to express a program. This is deliberately naive. It ignores formatting,
comments, and taste so the tool has a simple objective function to optimize.
sx works by detecting refactoring candidates: dead code, deduplication,
inlining, eg rewrites, and other AST/type-safe transformations. In apply mode,
it applies candidates eagerly, then measures, builds, tests, and redetects after
each kept change. The loop continues until there are no more candidates or the
configured -n attempt limit is reached.
Who This Is For
Use sx when you want to:
- shrink AI-generated code after an LLM-assisted development round
- push back against wrapper-heavy, branch-heavy, duplicated LLM output
- find the largest functions in a Go package or module
- remove unreachable functions detected by
deadcode - inline one-use helpers through
gopls - factor repeated code when doing so reduces AST size
- apply small, example-based expression rewrites with
eg - fail CI when a shrinking candidate is available
How sx Keeps Changes Safe
Safety comes in layers, and each one can only reject a change:
| Layer | What it guarantees |
|---|---|
| Type-aware tools | Edits are made on the type-checked AST, never by text substitution. gopls preserves semantics when it inlines or extracts: it binds arguments that cannot be substituted and keeps conversions explicit. eg rewrites only expressions whose types match the template |
| Conservative scope | Only unexported functions are inlined, only unreachable functions are removed, and only identical code within one package is deduplicated. Generated files and files outside the current build are never edited. Inlining also skips functions that carry //go: directives, use unsafe, or are named by assembly or //go:linkname |
| Exact duplicates | Two runs are copies only when they agree token for token (operators, := or =, break or continue, a variadic ...) and when every identifier means the same thing, or is a local of the same type, in each copy |
| Predictor refusals | Patterns the tools get wrong are refused before anything is written, such as a defer or recover that would move into a helper, values copied after their address is taken, lost loop-carried writes, and control flow that leaves an extracted run. See Appendix A |
| Gates | Each change must parse, gofmt, build, and pass the tests of every package under the target path and every package that imports one of them. Tests that already failed before the first change are recorded and skipped, and only a new failure rejects a change |
| Revert | A change that fails any gate, or does not make the tree smaller, is undone before the next one is tried. An interrupted run (Ctrl-C, SIGTERM) stops its tests and reverts the change in progress |
| Read-only CI mode | -check never writes, and -check -apply is rejected |
The tests are the final word on behaviour, so the stronger your test suite, the stronger that guarantee.
Install
In the Go module you want to shrink, add sx as a tool dependency:
go get -tool github.com/dhilst/sx/cmd/sx
Then run it with:
Install the helper tools for refactoring candidates:
go install golang.org/x/tools/cmd/deadcode@latest go install golang.org/x/tools/gopls@latest go install golang.org/x/tools/cmd/eg@latest
You do not need every helper installed, but each missing helper disables one
class of candidate. If no usable helper is available, sx refactor exits with
an install message. eg counts as usable only when at least one template exists.
Inside this repository, go tool sx and go run ./cmd/sx build the same local
program.
Helper Tools
sx decides which changes are worth trying, but it delegates the actual Go-aware
work to maintained Go tools:
| Tool | Link | Used for |
|---|---|---|
deadcode |
golang.org/x/tools/cmd/deadcode | Finding unreachable functions that can be deleted |
gopls |
golang.org/x/tools/gopls | Inlining calls, extracting duplicated statement runs, and repairing imports |
eg |
golang.org/x/tools/cmd/eg | Applying example-based expression rewrites from template files |
The tools are optional in the sense that sx can run with only the helpers you
have installed. Missing helpers simply remove candidate classes:
- without
deadcode, unreachable functions are not proposed - without
gopls, inline and deduplication candidates are not proposed - without
eg, example rewrite templates are not applied
At least one candidate source must be available. For eg, that means both the
eg binary and at least one template under examples/eg, sx/examples/eg, or
a path passed with -eg.
Quick Start
Start with a clean git working tree so rejected or unwanted patches are easy to inspect and undo.
1. Measure the current module
This prints the total node count and the largest functions.
2. Preview one candidate without editing files
Without -apply, sx refactor only reports the best candidate it would try.
3. Apply a bounded pass
go tool sx refactor -apply -n 30 .For each attempted change, sx formats, rebuilds and repairs unused imports,
re-counts nodes, runs the relevant tests, and reverts the change unless it builds,
the tests pass, and the final count is smaller.
4. Review before committing
Every change in the diff has already been built and tested. What remains is a style review: keep the changes that read well, and drop any you would name or structure differently.
Typical Workflows
Local minimization pass
git status --short go tool sx refactor -apply -n 30 . go test ./... git diff
To review the changes in smaller batches, run fewer attempts at a time:
git restore . go tool sx refactor -apply -n 5 .
CI check
Use -check to fail when sx can see at least one likely shrinking candidate.
It never writes files.
go tool sx refactor -check -n 30 .A -check candidate is a prediction from the transformation models. -apply
then confirms it with the real build, test, and measurement gates.
Batched testing
On a project whose tests are slow, testing after every change dominates the
run. -batch commits each change that builds and shrinks the tree, runs the
tests once at the end, and only if they fail looks for the cause:
go tool sx refactor -apply -batch -n 60 .- Each kept change is a commit (
sx: <kind> <target> (a -> b nodes)). The tree must be clean when the run starts. - At the end the test scope runs once. If a test fails that passed at the
start,
git-style bisection finds the first commit that makes it fail. - That commit is checked again, bypassing Go's test cache. If it does not fail again, the test is flaky: it is skipped from then on and the run is tested again.
- Otherwise the run goes back to just before that commit, the change is marked as tried, and detection continues. The changes after it are found again on the current tree rather than replayed, so an independent change is never lost because its lines touched the bad one's.
- When the tests pass, the commits are squashed into one whose message gives
the totals (changes, nodes, lines, per kind). The separate commits stay
under
refs/sx/runs/<run>.
With n changes of which k break a test, the tests run about 1 + k(log₂ n + 2) times instead of n.
Everything a batched run learns goes into a SQLite database at
.git/sx/sx.db, out of the working tree:
| Table | Holds | Indexed by |
|---|---|---|
runs |
each run's totals: nodes and lines before and after, attempts, changes kept and dropped, per kind, time spent detecting, applying and testing | run |
tests |
every test result: commit, tree, package, test, the file it is declared in, outcome, time | test name, file, commit, tree |
changes |
every committed change: kind, target, predicted and measured nodes, lines added and removed, and why it was dropped | commit, run |
A test that has both passed and failed on the same tree is flaky, whatever changed; every later run, batched or not, skips it from the start. The database answers other questions too:
sqlite3 .git/sx/sx.db "SELECT file, test, COUNT(*) FROM tests WHERE outcome='fail' GROUP BY 1, 2 ORDER BY 3 DESC"sx status lists the runs:
RUN NODES AFTER ΔNODES ΔNODES% ΔLOC KEPT DEDUP DEAD INLN EG TIME
20260918-194252.132 60 45 -15 -25.00% -2 1/2 0 0 1 0 17s
Disable tests for a fast exploratory run
go tool sx refactor -apply -test=false -n 30 .This skips the per-change test gate, which makes the loop faster. Run
go test ./... once at the end to restore the same guarantee.
What sx Changes
sx currently looks for four kinds of reduction.
| Kind | Helper | What it tries |
|---|---|---|
| Dead code | deadcode |
Remove unreachable plain functions |
| Inlining | gopls |
Inline unexported functions called once |
| Deduplication | gopls |
Extract repeated statement runs when the extraction is smaller |
eg examples |
eg |
Rewrite expressions using example templates |
Dead Code
sx asks deadcode which plain functions are unreachable from the current
program. It then tries deleting one candidate at a time, together with any
import only that function used, and keeps the deletion only if the build, tests,
and measured AST count all pass.
Inlining
sx finds unexported plain functions that are called exactly once in their
package, then asks gopls to run the actual inline refactor and deletes the
declaration once nothing refers to it. gopls owns the type-aware edit; sx
owns the decision about whether the resulting patch is smaller and still valid.
The predictor works out which inlining strategy gopls will use and what it
will write: the substituted expression, a var binding for any parameter that
cannot be replaced by its argument, braces when names would clash, and explicit
conversions where a type would otherwise be lost. A call that gopls could
only inline by wrapping the body in a function literal is not attempted.
Deduplication
sx looks for repeated statement runs in a package. It prices extracting each
run the way gopls would do it, with parameters for the variables the run reads,
results for the ones the code after it needs, and a returned flag or error check
when the run contains return. When that makes the tree smaller, sx asks
gopls to extract the first copy and replaces every other copy with the same
call site gopls wrote.
This is deliberately conservative. It does not try to invent arbitrary
abstractions; it only attempts repeated code that can be represented as a normal
Go extraction and accepted by the same build, test, and measurement gates. Runs
that gopls would extract incorrectly are refused before anything is written:
- a variable whose address the run takes, which would be copied into or out of the helper
- a write the run makes that a loop or closure reads later
- a
break,continue, orgotothat leaves the run - a type parameter of the enclosing function in the new signature
- a type from a package the file does not import
- two parameters with the same name
eg Rewrites
sx loads eg templates from configured directories, asks eg whether each
template matches, and prices the rewrite over every match. A wildcard that
after uses fewer times than before, as in s[:len(s)] -> s, also saves the
expression it matched. The rewrite is applied only when it is selected as a
candidate.
Every attempted change follows this loop:
- choose the highest predicted saving not already tried
- apply the candidate
- format the touched files
- rebuild, repairing unused imports if that is the only problem
- count AST nodes again
- run the tests of the packages that could be affected
- keep the change only if it builds, the tests pass, and the count went down
The predicted saving decides what to try first and whether a candidate is offered at all. Each model rebuilds what the helper tool will write and counts it, so the prediction is meant to equal the measured change; see Appendix A. The final measured count is still what decides whether the change stays.
Code Reduction Examples
These are examples of the small expression rewrites included in examples/eg.
// before if strings.Index(name, "/") >= 0 { return true } // after if strings.Contains(name, "/") { return true }
// before return fmt.Sprintf("%s", value) // after return value
// before return time.Now().Sub(start) // after return time.Since(start)
// before return bytes.Compare(a, b) == 0 // after return bytes.Equal(a, b)
// before return enabled == true // after return enabled
Checked-in templates currently cover:
fmt.Errorf("%s", s) -> errors.New(s)
fmt.Sprintf("%s", s) -> s
time.Now().Sub(t) -> time.Since(t)
s[:len(s)] -> s
x == true -> x
x != false -> x
!!x -> x
bytes.Compare(a, b) == 0 -> bytes.Equal(a, b)
strings.Index(s, sub)>=0 -> strings.Contains(s, sub)
strings.Index(s, sub)==-1 -> !strings.Contains(s, sub)
len(s) == 0 -> s == "" (s a string)
len(s) > 0, len(s) != 0 -> s != "" (s a string)
fmt.Sprintf("%d", n) -> strconv.Itoa(n) (n an int)
strconv.FormatInt(int64(n), 10) -> strconv.Itoa(n) (n an int)
The last four were found by /sx bake in cc-connect, flowstate, and milvus,
where they matched 73 times.
Add Your Own eg Rewrites
eg is the Go example-based
refactoring tool from golang.org/x/tools. An eg template is a Go file with a
before function and an after function. Both functions must have the same
type.
Minimal template
//go:build ignore package template func before(s string) string { return s[:len(s)] } func after(s string) string { return s }
To copy this into your own module:
mkdir -p sx/examples/eg $EDITOR sx/examples/eg/full-string-slice.go go tool sx refactor -check -eg sx/examples/eg . go tool sx refactor -apply -eg sx/examples/eg . go test ./... git diff
You can also pass any template directory to sx:
go tool sx refactor -check -eg ./examples/eg . go tool sx refactor -apply -eg ./examples/eg .
The -eg flag can be repeated:
go tool sx refactor -apply -eg ./examples/eg -eg ./team/eg .It can also take comma-separated paths:
go tool sx refactor -apply -eg ./examples/eg,./team/eg .Disable eg rewrites by passing an empty -eg value:
go tool sx refactor -apply -eg "" .
By default, sx searches these directories if they exist:
examples/eg
sx/examples/eg
Template-writing checklist
- Keep
beforeandafterthe same type. - Prefer one returned expression in each function.
- Add imports normally when the expressions need them.
- Use
//go:build ignoreso templates are not compiled into your module. - Avoid rules that duplicate, remove, or reorder expressions with side effects.
- Start with narrow, obvious rewrites and let
sxprove the measured saving.
Command Reference
Measure Go files:
go tool sx [-json] [-n 20] [-tests] <paths...>
Refactor a module:
go tool sx refactor [-apply] [-check] [-batch] [-n 10] [-test=false] [-eg path] [-cpuprofile file] [path]Show the recorded runs of the repository:
Useful flags:
| Flag | Command | Meaning |
|---|---|---|
-json |
measure | Emit measurement output as JSON |
-n |
measure | Number of functions to list; 0 lists all |
-tests |
measure | Include _test.go files when measuring |
-apply |
refactor | Write accepted changes; without it, preview one candidate |
-check |
refactor | Exit non-zero when a shrinking candidate is found; never writes files; cannot be combined with -apply |
-n |
refactor | Number of candidates to attempt; default is 10 |
-test=false |
refactor | Skip tests after each accepted-looking change |
-eg |
refactor | File or directory of eg templates; repeatable. Default: examples/eg and sx/examples/eg under the target path, its module root, its repository root, and the current directory |
-cpuprofile |
refactor | Write a CPU profile of the run to a file |
-batch |
refactor | Commit each change and run the tests once at the end instead of after every change; see Batched testing |
CI Example
name: sx on: pull_request: push: branches: [main] jobs: minimize: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: go.mod - run: go install golang.org/x/tools/cmd/deadcode@v0.50.0 - run: go install golang.org/x/tools/gopls@v0.23.0 - run: go install golang.org/x/tools/cmd/eg@v0.50.0 - run: go tool sx refactor -check -n 30 .
The versions are the ones the models in
Appendix A were checked against. A newer
gopls may extract or inline differently, which costs wasted attempts, not
wrong results: the measured count still decides.
Troubleshooting
| Symptom | What to do |
|---|---|
sx refactor says helper tools are missing |
Install at least one of deadcode, gopls, or eg |
eg templates are ignored |
Confirm eg is installed and templates are in a searched path or passed with -eg |
A candidate appears in -check but is not kept by -apply |
A gate rejected it and sx reverted it; the tree is unchanged |
| The diff is larger than you want to review at once | Rerun with a smaller -n |
| A change is smaller but reads worse | Drop it from the diff; sx optimizes for size, and naming and style are yours |
Algorithm and Layers
The minimization loop is greedy and measurement-gated:
- Parse the current Go files and measure the tree as
C = |AST|. The measure covers non-test.gofiles that match the current build constraints, skippingvendor,testdata, and directories starting with.or_._test.gofiles are not counted. - Compute the test scope once: every package under the target path plus every
package in its module that depends on one of them, directly or transitively.
At the module root that is
./..., which is also the fallback whengo listcannot answer. With-apply, run the scope's tests once and record what already fails; those tests are skipped from then on. - For each attempt, run the available detectors against the current tree, in
this order:
deadcode, inline, deduplication (both needgopls), andegtemplates. A detector that errors contributes no candidates. Detection is incremental: export data is listed once per pass, and a package is parsed, type-checked, and searched again only when its files or the export data of what it imports have changed.deadcoderuns once per run and again only when a pass finds nothing, because none of the transformations can make an unreachable function reachable.egmatches are found by the model's own matcher, and theegbinary runs only to apply a chosen template. - Price each candidate with its transformation model
(Appendix A) and pick the one with the
highest predicted saving,
−ΔN > 0, whose key has not already been tried; ties go to the earlier detector. Candidates the model refuses, or predicts would not shrink the tree, are never offered. The chosen key is marked as tried whatever the outcome. - Without
-apply, report that candidate and stop without writing. With-check, report it and exit non-zero. If no candidate remains, both modes exit successfully.-checkand-applyare rejected together before anything is measured or written. - With
-apply, apply one candidate to the working tree. If the edit cannot be produced or is rejected by the apply step's own checks, report it as skipped and continue; a skipped candidate still counts as an attempt. - gofmt the files whose content the change altered (a formatting failure
reverts the change and aborts the run), then
go build ./.... If every build error is an unused import, rungopls importson those files and build again. - Parse again and measure
C'. If tests are enabled, rungo teston the precomputed scope, skipping the tests that failed at the start, only after the build and measurement succeed. - Keep the change iff it builds, no test fails that passed at the start, and
C' < C. Otherwise, restore the files the apply step recorded. A revert message names the first compiler error or the first new test failure. - If kept, set
C = C'. Either way, redetect candidates on the next attempt and repeat until no candidate remains or-nattempts have been made.
Candidate keys make retries stable across edits that move code: dead-code
candidates are keyed by position, inline candidates by package and function,
deduplication candidates by the content hash of the repeated run, and eg
candidates by template.
Each prediction comes from a model of the transformation, computed from the type-checked AST:
| Kind | Model |
|---|---|
| Dead code | |
| Inline | |
| Deduplication | |
eg |
The terms are defined in Appendix A.
The layers are intentionally separate:
| Layer | Responsibility |
|---|---|
| Parser | Reads Go files with the standard Go parser, respecting build constraints; generated files are measured and read for references but never edited |
| Measurer | Counts AST nodes and reports ` |
| Detector | Finds possible reductions: unreachable plain functions, unexported plain functions referenced exactly once in their package, identical statement runs of at least 12 nodes within one package, and matching eg templates |
| Predictor | Models each transformation to compute its exact ΔN, and refuses the ones the helper tool would get wrong; checked against test/examples, but the gate still decides |
| Refactor tool | Performs the edit. gopls inlines the call and extracts the first duplicate; eg rewrites every match of one template across the tree. sx itself deletes dead functions, deletes an inlined function once nothing refers to it, and replaces the remaining duplicate copies with the call gopls generated |
| Gate | Formats touched files, builds, repairs unused imports, remeasures, and by default tests the precomputed package scope |
| Reverter | Restores the recorded files whenever the gate fails or the measured tree is not smaller |
The apply step also refuses some edits before the gate runs: an inline whose
function is still referenced afterwards, is exported, or is named by assembly or
//go:linkname; and an eg rewrite that touches a generated file or a file
outside the current build. For a deduplication, the call site gopls writes for
the first copy (declarations, the call, and any return check) is copied to every
other copy.
The quality of sx depends mostly on detection quality and rewrite coverage.
Better detectors produce fewer doomed candidates and find more real reductions.
Better predictors waste fewer attempts. A richer, conservative eg example
library gives the tool more AST/type-safe expression rewrites to try. Improving
sx usually means adding one of those: a detector, a predictor filter, or an
eg template that captures a common larger-to-smaller Go idiom.
Test Examples
test/examples is a library of small programs, each isolating one behaviour:
<name>_before.go is the input and <name>_after.go is what sx refactor -apply
makes of it. The prefix names the transformation under test (dead_, inline_,
dedup_, eg_). Each file carries //go:build ignore, so the examples are not
part of this module's build.
Two tests use the library:
| Test | What it checks |
|---|---|
TestModelsMatchReality (internal/refactor) |
Every candidate each detector finds in each example, including the ones its model says would grow the tree, is applied for real. The measured ΔN must equal the predicted one exactly, and the result must build. |
TestExamples (cmd/sx) |
The whole loop runs on each _before.go and must produce _after.go byte for byte. |
To add an example, write <name>_before.go, then generate its expected result
and review the diff before committing:
go test ./cmd/sx -run Examples -update
git diff test/examplesBoth tests need deadcode, gopls, and eg. They are skipped when those are not
installed, and they run in CI with the pinned versions.
Appendix A: Transformation Models
Every candidate is priced by a model of what the helper tool will write. Each
model reconstructs the AST after the transformation and counts nodes; it does
not estimate from source length. A candidate is offered only when
A.0 Notation
-
$N(x)$ : the number ofast.Nodevalues in the subtree$x$ , the same count the measure uses. -
$\Delta N = N(\text{tree after}) - N(\text{tree before})$ , over the files the measure counts. -
$[P]$ : 1 when$P$ holds and 0 otherwise. -
$T_x$ : the syntax of$x$ 's type as the tool writes it.
Imports. Every transformation can make an import unused, which the repair
step removes, or need one the file lacks, which gopls and eg add.
An import spec costs 2 nodes (the path literal and the spec), or 3 when it is renamed.
A.1 Dead code
Deleting an unreachable declaration
"strings" gives
A.2 Inlining
Inlining the only call to
Substitution. Parameter
where "", 0.0, 1.0,
T{}, conversions, and selections that do not indirect a pointer. Otherwise
A conversion is needed at a reference that is not assigned to a value of the
parameter's type, is assigned to an interface, or feeds type inference, when the
argument's own type differs from
Binding. When var declaration holds one spec for
each parameter field
Strategies. gopls chooses one strategy, which fixes
-
Returned expression. The body is
return eand the call is inside an expression, with$K = \emptyset$ .$S$ is the call:N(R) = N(e) + \sigma + [\text{non-trivial}]\,\big(1 + N(T_r)\big) -
Returned call as a statement. The body is
return e,$e$ is itself a call, and the call is a statement, with$K = \emptyset$ .$S$ is the call: -
Statements. The call is a statement, and the body has no
return,defer, or labels.$S$ is the whole call statement:N(R) = \sum_i N(s_i) + \sigma + \beta + [\text{clash}] -
Empty body. The call is a statement.
$S$ is the statement, and only arguments with effects survive:N(R) = [K \neq \emptyset]\,\Big(1 + |K| + \sum_{p \in K} N(a_p)\Big) -
Anything else is refused.
"Non-trivial" means the returned expression's type, or its default type for a
constant, is not the declared result type gopls keeps the braces. The refused case is literalization,
func(...){...}(...). It saves only the name and the call's two nodes, and
leaves an immediately invoked closure where the call was.
A.3 Extraction (deduplication)
For a run of statements with gopls
extracts the first copy and the call it writes there replaces every copy:
-
$P$ : parameters. Variables declared before the run and read in it. -
$V$ : results. Variables the run declares or assigns whose value the code after the run reads before overwriting it. -
$Q$ : return plumbing. When the run containsreturn, the enclosing function's results, plus aboolflag unless every return isif err != nil { return ..., err }. -
$n_{ret}$ : thereturnstatements in the run. -
$\tau$ : the run ends in a top-levelreturn, so it always returns. -
$\epsilon$ : every return in the run is an error check. -
$Z(T)$ : the size of$T$ 's zero value. It is 1 for0,"",false, ornil;$1 + N(T)$ forT{}; and 4 for*new(T).
The declaration is func newFunction(p T, ...) (R, ...) { body }, with a
return appended when values come back, and every return in the body padded
with zero values:
The call site is the call, an assignment when values come back, and the check
that carries a return out. When the assignment cannot use := (some result was
declared before the run, in a scope it cannot be redeclared in), gopls first
declares with var the set
In the flag case, return newFunction(...).
The model refuses a run, and it is never offered, when gopls would extract it
into code that does not compile or that behaves differently:
| Refusal | Why |
|---|---|
A parameter or result has its address taken in the run, by &v, a pointer method, or a closure |
gopls passes and returns by value, so whatever holds the address keeps the helper's copy |
| A write in the run is not returned, but a loop or closure reads it later | The write lands on the helper's copy and is lost |
A break, continue, or goto leaves the run |
gopls threads it through a control value, which is not modelled |
| The signature needs a type parameter | gopls does not carry the enclosing function's type parameters over |
| The signature names a package the file does not import | gopls does not add the import |
| Two parameters would share a name | A type switch declares its variable once per clause |
The run contains a defer or a call to recover, outside a nested function literal |
Both belong to the enclosing function's frame. Moved into a helper, defer mu.Unlock() releases the lock when the helper returns, and the caller carries on unlocked |
| An identifier means something different in another copy, or a local has a different type there | The call gopls writes for the first copy is pasted over the others, so it must mean the same thing at each one |
| The code after another copy needs different results | A result one copy's caller reads and another's does not would be declared and never used at the second |
A.4 eg rewrites
A template rewrites before(w...) to after(w...). Each match before and in after:
Here eg also rewrites
tests, but the measure does not see them. eg adds the imports after needs,
and the repair step removes the ones before no longer uses.
A.5 Validation
The models follow gopls v0.23.0 and golang.org/x/tools v0.50.0 (eg,
deadcode). TestModelsMatchReality applies every candidate in test/examples
and requires the measured eg rewrites, and 3 dead-code removals.
License
Apache License 2.0. See LICENSE.