Imported from peczenyj/structalign (
AGENTS.md). Install upstream withnpx skills add peczenyj/structalign. Copyright stays with the author.
AGENTS.md
This is the single source of truth for both human contributors and coding agents (Claude Code and others) working in this repository: the architectural overview, development workflows, and coding conventions.
What this is
structalign is a single-binary Go CLI that shows how a struct's fields could be
reordered to use less memory, as human-readable output: it prints the
reordered struct plus a diff for review, rather than editing files or emitting a
machine-applicable patch the way fieldalignment -fix / -fix -diff do. It also
has an -inspect mode that prints a struct's memory layout (offset/size/align/
padding per field).
The program is split into small, decoupled packages:
main.go(module root) — a thin entrypoint:os.Exit(app.New(os.Stdout, os.Stderr).Run(os.Args[1:])).pkg/common— the public contracts: data types (Target,Finding,Layout,LayoutField,DiffStyle,Colorize) and interfaces (Loader,Aligner,Inspector,Sizes). Kept out ofinternal/so mockery can generate mocks from a non-internal source.internal/— the implementations:loader(go/packages adapter),align(runs the analyzer → findings),layout(computes struct layouts),sizes(go/typessizing adapter),textdiff(go-udiff line diff),match(glob filtering),structfilter(generated-file andcpu.CacheLinePadpredicates),config(.structalignrc and env var mapping),ui(thePrinter— all rendering + color/width helpers),app(flag parsing- wiring). Plus
testutil(in-processTargetbuilder for tests) andmocks(mockery-generated, test-only).
- wiring). Plus
_example/types.go is sample input used for manual testing; the leading
underscore makes the Go tool skip the directory, so it never enters ./....
The module path is github.com/peczenyj/structalign, and main is at the module
root, so the install target is github.com/peczenyj/structalign@latest → binary
structalign.
Commands
The repo uses Task; the Makefile is a thin delegator
(make X runs task X). Run task --list to see everything.
task build # -> ./structalign
task lint # golangci-lint v2 (lint + formatters: gofumpt/goimports/gci)
task test # gotestsum over all packages
task test -- -update # regenerate golden fixtures (internal/ui/testdata/*.golden)
task smoke # run both modes against ./_example
task generate # regenerate code (go generate) + mocks (mockery); subtasks generate:code / generate:mocks
task ci # full pre-push gate: tidy:check, lint, go-consistent, build, test, smoke
go run . [flags] [packages] # packages: ./..., import paths, dirs, files
enumer and mockery are code generators; DiffStyle and Colorize
(pkg/common) are enumer-generated uint8 enums (go generate ./pkg/common
after changing their constants), and mocks come from mockery. Generated files (*_enumer.go,
internal/mocks/*) are committed — regenerate, never hand-edit.
Exit code is meaningful: diff modes exit 1 when any reordering is found
(CI-friendly), 0 when none; -inspect always exits 0.
Core architecture
The key design decision (see the README "How it works"): this tool does not
reimplement the field-alignment algorithm. internal/align runs the unmodified
upstream fieldalignment.Analyzer and intercepts the SuggestedFix it already
produces. The pipeline, orchestrated by app.Run:
loader.Loadresolves CLI args viagolang.org/x/tools/go/packages(./..., import paths, dirs, and — vianormalizeArgs— single.gofiles) into[]common.Target. ATargetis a loader-agnostic view of one typed package (syntax, types, type info, sizes) — it hidesgo/packages.Package.align.Findingsruns the analyzer over aTarget(wiring ananalysis.Passand satisfying theinspectpass withinspector.New) and returns[]common.Finding— plain data (original + proposed struct text + message), not rendered output.layout.Layoutsis the parallel inspect path: it readsSizes.Offsetsof/Sizeof/Alignofto produce[]common.Layout.app.Runcollects all findings (or layouts) across the scanned targets into one slice, then post-processes that slice inapp: filter (-threshold, by absolute bytes saved), sort (-sort, largest-first — findings by savings, layouts byLayout.Total), and hand the result toui.Printer, which renders (unified / side-by-side / proposed-only diff viatextdiff, or annotated layout) to anio.Writer. With-summary(diff only) it then prints a one-lineSummary: N structs affected, M bytes saved total. The savings metric is the sharedapp.savings(common.Finding) int64helper (used by sort, threshold, and summary). Because the logic packages return data anduiconsumes it, rendering is testable by injecting findings — no analyzer, no toolchain.
Two injectable wrappers are the crux of the decoupling and testability:
common.Sizesabstractsgo/typessizing. Its method set matchesgo/types.Sizes, so acommon.Sizesis assignable directly toanalysis.Pass.TypesSizes. Production uses the loaded package's sizes (hostGOOS/GOARCH); tests injectsizes.ForArch("amd64"), making golden output deterministic on any host (no archt.Skip).common.Targethidesgo/packages.Package.testutil.Target(tb, src)builds one from a source string in-process (go/parser+go/types, nogo listshell-out) — fast and hermetic. It runs the test from a temp dir (tb.Chdir) and writes a relativesrc.go, so the analyzer's recorded filename is a stable"src.go"(deterministic golden output) whilealign.readSourcecan still read the bytes off disk.
Testing: each package has black-box _test tests using
github.com/stretchr/testify (require/assert). The golden tests live in
internal/ui (build findings/layouts via align/layout against
testutil.Target, compare to testdata/*.golden; regenerate with
task test -- -update). mockery generates Loader/Aligner/Inspector mocks
into internal/mocks (test-only, excluded from lint/coverage via the Taskfile's
PKG_LIST); Sizes is intentionally not mocked — it has a real
deterministic implementation.
Package load/type errors are surfaced on each Target.Errors and printed to
stderr but are non-fatal — a partially-resolved package can still produce findings.
Layered Configuration: defaults are loaded from four layers before parsing CLI arguments (highest precedence wins):
- CLI flags (e.g.
-sort) - Environment variables (
STRUCTALIGN_<FLAG>, e.g.STRUCTALIGN_SORT=true) - CWD RC file (
./.structalignrc, key=value format) - Home RC file (
~/.structalignrc) - Built-in defaults
The -no-rc flag (detected early) disables loading both .structalignrc files.
RC files use key = value lines; # comments and blank lines are ignored.
Keys map directly to flag names. theme is not an RC key (use
STRUCTALIGN_THEME).
| Feature | CLI Flag | Environment Variable | RC Key | Default |
|---|---|---|---|---|
| Diff style | -diff |
STRUCTALIGN_DIFF |
diff |
unified |
| Output format | -format |
STRUCTALIGN_FORMAT |
format |
text |
| Column width | -width |
STRUCTALIGN_WIDTH |
width |
0 (auto) |
| Color mode | -color |
STRUCTALIGN_COLOR |
color |
auto |
| Theme palette | — | STRUCTALIGN_THEME |
— | default |
| Inspect mode | -inspect |
STRUCTALIGN_INSPECT |
inspect |
false |
| Verbose inspect | -verbose |
STRUCTALIGN_VERBOSE |
verbose |
false |
| Keep tags | -tags |
STRUCTALIGN_TAGS |
tags |
false |
| Show summary | -summary |
STRUCTALIGN_SUMMARY |
summary |
false |
| Largest-first sort | -sort |
STRUCTALIGN_SORT |
sort |
false |
| Min bytes saved | -threshold |
STRUCTALIGN_THRESHOLD |
threshold |
0 |
| Type filter | -type |
STRUCTALIGN_TYPE |
type |
(empty) |
| Package exclude | -exclude |
STRUCTALIGN_EXCLUDE |
exclude |
^unsafe$|^builtin$ |
| Include generated | -generated |
STRUCTALIGN_GENERATED |
generated |
false |
| Include tests | -tests |
STRUCTALIGN_TESTS |
tests |
false |
| Skip cache padded | -skip-cache-padded |
STRUCTALIGN_SKIP_CACHE_PADDED |
skip-cache-padded |
false |
| Show //nolint | -show-nolint |
STRUCTALIGN_SHOW_NOLINT |
show-nolint |
false |
| Nolint linters | -nolint-linters |
STRUCTALIGN_NOLINT_LINTERS |
nolint-linters |
fieldalignment |
Why go-udiff and not x/tools' own diff: Go's internal-package rule forbids
importing golang.org/x/tools/internal/diff from a module not rooted under
golang.org/x/tools/. fieldalignment's own internal imports are fine because
the importer is inside x/tools and this tool only touches its public API. go-udiff
is a public port of the same gopls diff code, so results are equivalent. Don't try
to swap it back for the internal package — it won't compile from this module.
Things to keep consistent when editing
- Type sizes flow through
common.Sizes. Production wraps the toolchain's real target sizes (pkg.TypesSizes); tests injectsizes.ForArch("amd64"). Don't reach for a hardcoded arch or a mock — use the interface. alignandlayoutreturn data,uirenders it. Keep that split: no printing in the logic packages, no analysis inui. New output formatting goes inui; new analysis/derived fields go on thecommontypes.-format=json(ui.RenderJSON) is the machine renderer, parallel toRenderFindings/RenderLayouts. Two deliberate divergences from the text path: the diff document always carries thesummaryblock (so consumers always get totals —-summarygoverns only the text trailing line), and the text-only presentation flags (-diff,-summary,-verbose,-color,-width) are ignored in JSON.-tagsstill gates the inspect field'stag. Any encode error is reported on the printer'sErrstream (p.err(), set toApp.Stderr), not the realos.Stderr.- Scan options travel in
common.Options(Patterns,KeepTags,IncludeGenerated,SkipCachePadded,RespectNolint,NolintLinters), passed toAligner.Findings/Inspector.Layouts.align/layoutapply the filters viainternal/structfilter(InGeneratedFileusesgo/ast.IsGenerated;HasCacheLinePadchecks for agolang.org/x/sys/cpu.CacheLinePadfield, skipped via-skip-cache-padded). Generated files are skipped by default (-generatedopts in);_test.gois loaded only with-tests(loader.New(tests));-excludedrops packages by import-path regexp inapp. Add a new scan knob toOptions, not as another positional arg.- Config discovery lives in
internal/config. It handles.structalignrcparsing and env-name derivation (-skip-cache-padded→STRUCTALIGN_SKIP_CACHE_PADDED).app.Runwires these as defaults viafs.Setbefore callingfs.Parse. - //nolint is respected by default (diff only).
align.nolintIndexmapsStructType.Pos()to the directive parsed from the type's doc comment (TypeSpec.Doc/ groupedGenDecl.Doc) and any comment on the type's opening line (a trailingtype T struct { //nolint, matched by line since the AST doesn't attach it toTypeSpec.Comment).buildFindingdrops a finding whenOptions.RespectNolintand the directive is bare//nolintor names a token inOptions.NolintLinters(default["fieldalignment"]).appwires-show-nolint(→RespectNolint = !showNolint) and-nolint-linters. Inspect ignores//nolint(layoutdoesn't read these fields).
- Config discovery lives in
- Diff presentation extras live on
common.Finding:OldSize/NewSize(parsed from the analyzer message) drive the(NN.NN% smaller)suffix, andTypeParams(e.g."[T]") letsuirendertype Name[T] struct {for generics. Generic diffs use the type params' assumed sizes; inspect instantiates a generic with a representative type per parameter (layout.representativeType: constraint core type, elseinterface{}) for sizing, but renders fields from the origin struct so they stay source-faithful (Value T, notValue any). Each field carriesLayoutField.Assume(e.g."T=any", or"K=any, V=any"), computed by walking the field's origin type for referenced type params (layout.fieldAssume/collectTypeParams, which follows pointers/slices/maps/nested generics);uirenders it as an aligned-- assume …marker, andLayout.Notecarries the top-line disclaimer. - Struct name labeling depends on
structNameIndex(inalign) mappingStructType.Pos()to the declared type name, because the analyzer reports at that position. Anonymous structs have no name and are filtered out by any non-empty-typeglob (match.MatchAny). - Tag stripping (
stripStructTagsinalign, on by default;-tagspreserves them) removes diff noise from gofmt re-aligning tags when columns shift; best-effort (falls back to original on parse error). Tags never affect layout numbers. DiffStyleandColorizeare enumer-generateduint8enums that implementflag.Value(the-diffand-colorflags bind viaflag.Var; theirType()method feeds the usage strings). Change the constants inpkg/common/diffstyle.go/pkg/common/colorize.go, thengo generate ./pkg/common.- Color, width, and padding verbosity live in
ui:ui.WantColor(colorize, out)takes acommon.Colorize(auto = stdout is a TTY andNO_COLORis unset;-color=alwaysoverridesNO_COLOR, per no-color.org),ui.ResolveWidth(out)(side-by-side column width from the terminal size), and the-verboseflag (whether padding gets its own_line in inspect mode). - Themes route color through
ui.Theme(semantic rolesHeader/Added/Removed/ Meta/Padding/Label);Printer.Themezero value resolves toui.DefaultTheme(), which is byte-for-byte the historical palette (golden fixtures must stay unchanged — never-updatethem for a theme change). Built-ins (default/cga/green/amber) live ininternal/ui/themes.go(ui.ThemeByName).appselects one — hidden easter-egg flags-cga/-green/-amber(caught in the pre-parse scan beside-fixand stripped from args, so invisible in-help) win over the documentedSTRUCTALIGN_THEMEenv var, else default; an unknown name warns to stderr. Theme is orthogonal to-color(palette only applies when color is on). This is not a full theming system; per-role/custom themes are a planned later feature.