Help us shape the future of Go! Take the 2026 Go Developer Survey

goaxi

package module
v0.1.4 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 12, 2026 License: MIT Imports: 11 Imported by: 0

README

go-axi

Safe TOON output for Go command-line tools. A hardening and guard layer over the official codec, toon-go.

Go Reference Test License

TOON is the token-efficient output format behind AXI principle 1, and toon-go is its official Go implementation. toon-go encodes and decodes. go-axi covers what an agent-facing CLI still has to get right before it prints:

  • Sanitize untrusted text. Strip ANSI escapes, U+2028/U+2029, lone C1 bytes and invalid UTF-8 out of anything the tool did not author, without dropping the surrounding visible text.
  • Catch silent encoding failures. Some Go types encode to empty output instead of an error, so a command prints nothing and exits 0. Check turns that into a condition a test can assert on.
  • Know when TOON is not the cheaper choice. Check measures the TOON payload against the JSON one and reports which is smaller.
  • Ship one exit-code set and one help[] writer as compiled constants instead of a convention in a style guide.

Install

go get github.com/samestrin/go-axi

Go 1.23 or newer. One dependency: toon-go.

Quick start

package main

import (
	"fmt"
	"os"

	goaxi "github.com/samestrin/go-axi"
)

type run struct {
	ID     string `toon:"id"`
	Status string `toon:"status"`
}

func main() {
	payload := map[string]any{
		"total": 2,
		"runs": []run{
			{ID: "4812", Status: "passed"},
			{ID: "4813", Status: "failed"},
		},
	}

	if err := goaxi.Encode(os.Stdout, payload); err != nil {
		fmt.Fprintln(os.Stderr, "ci:", err)
		os.Exit(int(goaxi.ExitError))
	}

	if err := goaxi.WriteHelp(os.Stdout, []string{"ci logs 4813", "ci rerun 4813"}); err != nil {
		fmt.Fprintln(os.Stderr, "ci:", err)
		os.Exit(int(goaxi.ExitError))
	}

	os.Exit(int(goaxi.ExitOK))
}
runs[2]{id,status}:
  "4812",passed
  "4813",failed
total: 2
help[2]: ci logs 4813,ci rerun 4813

Encode sanitized the payload on the way out, and WriteHelp appended a contextual-disclosure block that decodes as part of the same document.

API

import goaxi "github.com/samestrin/go-axi"

Full reference: pkg.go.dev/github.com/samestrin/go-axi.

Writing output
Function Behavior
Encode(w io.Writer, v any) error Sanitize, encode as TOON, terminate with exactly one newline
EncodeChecked(w io.Writer, v any) (Verdict, error) Sanitize, verify TOON carries it losslessly, write it — one pass for both jobs
EncodeOrJSON(w io.Writer, v any) error Same, but fall back to a JSON envelope when TOON would lose data
WriteHelp(w io.Writer, lines []string) error Write help[N]: first,second; an empty list writes nothing

Encode sanitizes internally so a caller cannot forget, and writes nothing at all when encoding fails — a partial payload is worse than none, because it parses.

EncodeChecked is what you want when the guard matters and you are about to print. Calling Check and then Encode sanitizes and marshals the same value twice to serve one guard; EncodeChecked derives its verdict from the bytes it writes. Measured medians, with a bare Encode as the floor:

rows Encode EncodeChecked Check + Encode
100 134 µs 156 µs (+16%) 338 µs (+152%)
2000 2.91 ms 3.32 ms (+14%) 7.18 ms (+147%)

It writes nothing unless the verdict is OK, and its Tier describes the bytes actually emitted.

EncodeOrJSON falls back on data loss only, never on size, to a self-describing envelope:

{"axi_format":"json","axi_notice":"<why>","data":{...}}

Route on the {"axi_format prefix rather than on a leading { or [. Four TOON shapes begin with [: [0]:, [2]: a,b, [2]: 1,2 and [1]{id}:.

If your project fixes the output format per command at design time, EncodeOrJSON is runtime format detection and you should keep your build-time guard instead.

Checking a value before you print it
type Verdict struct {
	OK           bool   // encoding preserves the data
	Tier         Tier   // TierTabular | TierNested | TierLossy
	Efficient    bool   // the TOON payload is no larger than the JSON one
	SizeCompared bool   // whether Efficient was actually measured
	Reason       string // explains a false OK or Efficient, in actionable terms
}

func Check(v any) Verdict                     // sanitizes internally
func CheckSanitized(v any, clean any) Verdict // for a value already sanitized
func CanEncode(v any) bool                    // Check(v).OK
Tier Meaning
TierTabular Uniform rows encoded as a table with a declared field list — where the token savings come from
TierNested Lossless but not columnar: ragged rows, list-valued fields, nested objects
TierLossy Encoding would discard data. Do not emit this as TOON

Tiers describe the shape actually emitted, measured rather than assumed.

SizeCompared exists because the comparison costs a second marshal, so EncodeChecked skips it. Without the flag, a skipped comparison would be indistinguishable from a measured "TOON is larger". Check always measures.

OK and Efficient are separate on purpose. A payload can be perfectly lossless and still cost more as TOON, which is a reason to choose JSON for that command but never a reason to call the value broken.

A Check assertion in a table test is the cheapest place to catch the empty-output trap:

func TestListOutputSurvivesTOON(t *testing.T) {
	v := goaxi.Check(listRunsPayload())
	if !v.OK {
		t.Fatalf("payload is lossy: %s", v.Reason)
	}
	if v.Tier != goaxi.TierTabular {
		t.Errorf("expected a tabular payload, got %s", v.Tier)
	}
}

Use CheckSanitized when you already hold the cleaned copy, so the value is not sanitized twice.

Sanitizing
Function Returns Use when
Sanitize(v any) (any, error) Cleaned copy, same concrete types Default
MustSanitize(v any) any Cleaned copy; panics on error Keys are fixed identifiers and shapes are acyclic
SanitizeString(s string) string Cleaned string Building output one field at a time

Concrete types are preserved, so toon: struct tags survive. That matters: TOON field names are part of the contract a consuming tool parses, and flattening to map[string]any would rename every column to its Go identifier.

Sanitize returns two error types, and neither can be resolved without losing or inventing data, so it refuses rather than guessing. Match them with errors.As:

clean, err := goaxi.Sanitize(v)
if err != nil {
	var cyc *goaxi.CycleError
	var dup *goaxi.KeyCollisionError
	switch {
	case errors.As(err, &cyc): // a self-referential value
	case errors.As(err, &dup): // two keys that clean to the same string
	}
}

*KeyCollisionError fires when, for example, "na\x1bme" and "name" both clean to "name". Dropping one is silent data loss; renaming one invents data the caller never wrote.

*CycleError refuses a self-referential value before the encoder sees it. That includes the cases a pointer-only check misses: a cycle closing through a map key, a slice that contains itself, and a loop that closes at any depth. Encoding one exhausts the stack, and stack exhaustion is a fatal Go runtime error: recover() cannot catch it, so the process dies with a goroutine dump where encoding/json would return a clean error.

Exit codes
ExitOK         = 0
ExitError      = 1 // the tool itself failed
ExitUsage      = 2 // unknown subcommand or flag — AXI requires failing loud
ExitNotFound   = 3
ExitValidation = 4 // ran correctly; the thing it checked did not pass

ExitCode is a defined type with a String() method, so os.Exit(int(goaxi.ExitValidation)).

ExitValidation is deliberately distinct from ExitError. A checker reporting "this does not conform" is a successful run with a real result, not a broken tool — and an agent cannot decide whether a retry is worthwhile if the two collapse into one code.

What this adds to toon-go

toon-go is a good codec. Use it directly for encoding and decoding. These are the gaps a production CLI hits:

Gap in toon-go What happens without a guard go-axi
Passes U+2028/U+2029, invalid UTF-8 and lone C1 bytes (0x9B, 0x9D) through A payload carrying a raw escape sequence reaches whatever terminal renders it Sanitize
Supports neither defined string types nor encoding.TextMarshaler, and does not error on them Empty output. The command prints nothing and exits 0 Check
Reference cycles reach the encoder Stack exhaustion kills the process; recover() cannot catch it Sanitize, Check
Size relative to JSON is not reported A command pays more tokens as TOON than it would as JSON Check.Efficient
Exit codes and help[] formatting are out of scope Every tool invents its own, and they drift Exported constants, WriteHelp

The help[] form is the inline TOON array, because it is the only one in circulation that survives its own codec: the AXI specification's indented example fails to decode with "list length mismatch", and the help[] line form fails with "missing colon after key".

AXI principle coverage

go-axi is a library, not an AXI tool itself. It implements the output-layer principles so each CLI does not reimplement them:

AXI principle Provided by
1 — Token-efficient output Encode, plus Check.Efficient and Check.Tier to prove it
5 — Definitive empty states Check distinguishes a legitimately empty result from encoder failure
6 — Structured errors and exit codes ExitCode constants; typed Sanitize errors
9 — Contextual disclosure WriteHelp

Principles 2, 3, 4, 7, 8 and 10 are per-command design decisions and stay with the tool.

Guarantees

  • Round-trip is a build failure. Decode(Encode(Sanitize(v))) must equal Sanitize(v). Codec pairs tested only against frozen fixtures go stale in silence.
  • One write per payload. A body and its terminating newline land in a single Write call, so a closed pipe cannot deliver a payload missing its last byte.
  • Nothing on failure. Encode writes zero bytes when encoding fails, and EncodeChecked writes zero bytes unless the verdict is OK.
  • One pass per payload. EncodeChecked and EncodeOrJSON sanitize once and marshal once, deriving their verdict from the bytes they write rather than from a second encode.
  • Input is never mutated. Sanitize returns a copy.
  • No output means no output. An empty body and an empty help list write nothing, not a blank line an agent pays tokens to read.

Development

go test ./... -race -cover
go vet ./... && gofmt -s -l .

License

MIT

Documentation

Overview

Package goaxi is a hardening and guard layer over the official TOON codec, github.com/toon-format/toon-go.

It is deliberately NOT a codec. toon-go encodes and decodes TOON; this package adds the four things toon-go does not cover, which an agent-facing CLI has to get right before it prints:

  • Sanitize: hostile input handling. toon-go rejects control bytes below 0x20 with an error but passes U+2028/U+2029, invalid UTF-8, and lone C1 bytes straight through. A payload carrying a raw ANSI escape reaches the terminal that renders it.

  • Check: toon-go supports neither defined string types nor encoding.TextMarshaler, and a violating type does not error — it emits EMPTY output. A command silently prints nothing. Check turns that into a detectable condition, and additionally reports when TOON is larger than JSON for a given shape. EncodeChecked applies that guard on the way out in a single pass, for callers that are about to print.

  • Exit codes: one canonical set, as exported constants rather than a rule written in a style guide.

  • Help blocks: one canonical writer for AXI contextual-disclosure lines.

The constants-over-prose choice is deliberate. A documented convention drifts; a compiled constant cannot.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func CanEncode

func CanEncode(v any) bool

CanEncode reports whether v survives TOON encoding without losing data. It is the boolean form of Check for callers that do not need the detail.

func Encode

func Encode(w io.Writer, v any) error

Encode sanitizes v and writes it as TOON, terminated by exactly one newline.

Sanitizing here rather than leaving it to the caller is deliberate. The one time a caller forgets is the time a control byte reaches a terminal, and a control byte is exactly what arrives when a tool echoes back text it did not author.

Nothing is written when encoding fails. A partial payload is worse than no payload, because it parses.

func EncodeOrJSON

func EncodeOrJSON(w io.Writer, v any) error

EncodeOrJSON writes v as TOON when that is lossless, and otherwise falls back to a self-describing JSON envelope naming the reason.

The fallback is triggered only by data loss, never by size. Routing on cost would make a command's output format vary with payload length, which no consumer could predict and every consumer would have to handle both ways.

This is runtime format detection. A project that fixes the output format per command at design time should keep its build-time guard and not call this. It exists for callers that have no such rule.

func MustSanitize

func MustSanitize(v any) any

MustSanitize is Sanitize for callers whose map keys are fixed identifiers and whose shapes are acyclic, where both failures are impossible by construction. It panics if one occurs, because continuing would mean emitting a payload with a field missing.

A panic is the lesser evil for a cycle specifically: it unwinds and can be recovered, whereas the stack overflow it replaces is fatal and cannot.

func Sanitize

func Sanitize(v any) (any, error)

Sanitize returns a copy of v with every string cleaned of characters that would either break the TOON encoder or reach stdout as a raw control byte.

It exists because toon-go mishandles hostile input in two opposite directions, both confirmed against the pinned version rather than assumed:

  • A raw ANSI escape (\x1b) makes toon.Marshal return an error, so a command handed reviewer text containing colour codes emits nothing at all.
  • U+2028, U+2029, lone C1 bytes (0x9b, 0x9d) and invalid UTF-8 pass through untouched, reaching whatever terminal renders the output.

Sanitizing before encoding fixes both: the encoder never sees a byte it would reject, and never sees one it would forward.

Characters are STRIPPED rather than replaced, so text on either side of a removed byte joins contiguously. Substituting a space would silently alter content; a reader cannot tell an inserted space from a real one.

Tab, carriage return and newline are deliberately preserved. toon-go already escapes those correctly, and removing them would corrupt multi-line content.

The copy preserves concrete types, so struct tags survive. That matters more than it looks: TOON field names are part of the contract consuming tools parse, and flattening a struct to map[string]any would rename every column to its Go identifier. Strings inside unexported fields cannot be reached by reflection and are carried through as-is.

Two errors are returned: *CycleError for a self-referential value, and *KeyCollisionError for two map keys that clean to the same string. A caller whose keys are fixed identifiers and whose shapes are acyclic can rule both out and use MustSanitize.

func SanitizeString

func SanitizeString(s string) string

SanitizeString cleans a single string using the same rules as Sanitize. It is exported for callers that build output a field at a time rather than handing over a whole value. It cannot collide, so it returns no error.

func WriteHelp

func WriteHelp(w io.Writer, lines []string) error

WriteHelp emits an AXI contextual-disclosure block: next-step command templates with fixed flags carried forward and runtime values left as placeholders like <id>.

The form is the inline TOON array, `help[2]: first,second`, chosen because it is the only one of the three in circulation that survives its own codec. The AXI specification's own indented example fails to decode with "list length mismatch", and the `help[] line` form fails with "missing colon after key". Appending this after a body is safe: the combined payload decodes as one value carrying both.

An empty list writes nothing. A command with no meaningful next step should not emit a stray "help[0]:" that an agent pays tokens to read and learns nothing from.

Types

type CycleError added in v0.1.1

type CycleError struct {
	Type string // the type the cycle was detected in
}

CycleError reports a value that refers to itself, directly or through other nodes.

TOON has no way to express a back-reference, so there is no correct encoding to fall back to. The walk is refused before it is entered rather than bounded part way in: sanitizeValue recurses through pointers with no seen-set, and the resulting stack overflow is a fatal runtime error, not a panic. No recover() catches it, so the process dies with a goroutine dump instead of returning to the caller — the exact class of failure this module exists to convert into an error.

Check already refuses a cycle for this reason. Sanitize now agrees, so the guarantee does not depend on which entry point a caller happened to reach for.

func (*CycleError) Error added in v0.1.1

func (e *CycleError) Error() string

type ExitCode

type ExitCode int

ExitCode is the process exit status an agent-facing CLI returns.

These ship as constants rather than as a rule in a style guide because a documented convention drifts and a compiled constant cannot. The set extends the conventional 0/1/2 with two codes agent-facing tools need to distinguish.

const (
	// ExitOK is success.
	ExitOK ExitCode = 0

	// ExitError is a runtime failure inside the tool itself.
	ExitError ExitCode = 1

	// ExitUsage is a malformed invocation: an unknown subcommand or flag.
	//
	// AXI requires an unknown flag to fail loudly rather than be ignored. An
	// agent that invents a flag has to learn it did nothing, or it will trust
	// output that was never scoped the way it asked.
	ExitUsage ExitCode = 2

	// ExitNotFound is a path or item that does not exist.
	ExitNotFound ExitCode = 3

	// ExitValidation means the command ran correctly and the thing it checked
	// did not pass.
	//
	// This is deliberately distinct from ExitError, and it is the load-bearing
	// distinction in the set. A checker reporting "this does not conform" is a
	// successful run with a real result, not a broken tool. Collapsing the two
	// makes "the tool crashed" indistinguishable from "your input is wrong",
	// and an agent cannot tell whether retrying is worth anything.
	ExitValidation ExitCode = 4
)

func (ExitCode) String

func (c ExitCode) String() string

String renders the code for error messages and logs. A bare integer in a message tells an operator nothing.

type KeyCollisionError

type KeyCollisionError struct {
	Cleaned any // the key both inputs produced
	First   any // the key already present
	Second  any // the key that would have overwritten it
}

KeyCollisionError reports two map keys that became identical after cleaning.

This is the one case where sanitizing cannot proceed without losing data. "na\x1bme" and "name" are different keys on input and the same key after the control byte is removed, so one would overwrite the other. Go randomizes map iteration order, which makes the survivor vary between runs — the worst kind of loss, because a test can pass today and fail tomorrow with no code change.

Sanitize refuses rather than picking. Renaming one key would invent data the caller never wrote, and dropping one is the silent loss this module exists to prevent.

func (*KeyCollisionError) Error

func (e *KeyCollisionError) Error() string

type Tier

type Tier int

Tier describes the payload shape TOON encoding actually produces.

The tiers are defined by measured behavior, not by what a format "should" handle. toon-go encodes ragged rows, list-valued fields and nested objects losslessly using a list form, so "can it be encoded" separates nothing useful. What differs is the shape emitted, and whether anything is lost.

const (
	// TierTabular is TOON's best case: uniform rows that encode as a tabular
	// array with a declared field list. This is where the token savings come
	// from.
	TierTabular Tier = iota

	// TierNested is lossless but not tabular. Ragged rows, list-valued fields
	// and nested objects land here. The payload is correct; it just does not
	// get the columnar win.
	TierNested

	// TierLossy means encoding silently discards data. Nothing in this tier
	// should be emitted as TOON.
	TierLossy
)

func (Tier) String

func (t Tier) String() string

type Verdict

type Verdict struct {
	// OK reports that encoding this value as TOON preserves its data.
	OK bool

	// Tier is the shape TOON encoding produces.
	Tier Tier

	// Efficient reports that the TOON payload is no larger than the JSON one.
	// Only meaningful when SizeCompared is true.
	Efficient bool

	// SizeCompared reports whether Efficient was actually measured. The
	// comparison needs a second marshal, so a path that writes the payload and
	// never reads the cost signal skips it. Without this flag a skipped
	// comparison is indistinguishable from a measured "TOON is larger".
	SizeCompared bool

	// Reason explains a false OK or Efficient in terms an operator can act on.
	Reason string
}

Verdict is the result of inspecting a value before encoding it.

OK and Efficient are deliberately separate. Correctness and cost are orthogonal: a payload can be perfectly lossless and still cost more as TOON than as JSON, which is a reason to choose JSON for that command but never a reason to call the value broken.

func Check

func Check(v any) Verdict

Check reports whether v can be encoded as TOON without losing data, what shape the payload takes, and whether TOON actually costs less than JSON.

It exists because toon-go fails in ways neither an error check nor a length check catches, all confirmed against the pinned version:

  • A defined string type (type Kind string) returns a real error. Loud.
  • A TextMarshaler at top level returns a nil error with EMPTY output, so the command prints nothing and exits zero.
  • A TextMarshaler nested in a struct or map encodes to "m:" — key present, value gone, nil error, non-zero length. A guard testing only err and len(b) passes this while the data is lost.

Loss is found by reflection rather than by round-tripping and comparing against the JSON projection. That comparison looks correct and is not: toon-go does not fall back to the json tag, so a field tagged only `json:"x"` encodes under its Go field name and the two projections disagree for reasons that have nothing to do with data loss.

func CheckSanitized added in v0.1.2

func CheckSanitized(v any, clean any) Verdict

CheckSanitized is Check for a value that has already been sanitized, so a caller holding the cleaned copy does not pay to sanitize it twice.

v supplies the declared types for the loss walks; clean is what gets measured. Passing both is deliberate — sanitizing preserves concrete types, so the walks give the same answer either way, but the measurements must be taken on what will actually be emitted.

func EncodeChecked added in v0.1.4

func EncodeChecked(w io.Writer, v any) (Verdict, error)

EncodeChecked sanitizes v, verifies TOON can carry it losslessly, and writes it — one sanitize walk and one TOON marshal serving both jobs.

Use this instead of Check followed by Encode. That pair sanitizes and marshals the same value twice to serve a single guard. Measured medians on the same payload, against a bare Encode as the floor:

rows   Encode    EncodeChecked   Check+Encode
100    134us     156us (+16%)    338us (+152%)
2000   2.91ms    3.32ms (+14%)   7.18ms (+147%)

Allocations at 2000 rows: 98,073 / 110,099 / 222,204. The guard costs about 14%; the rest was duplicated work.

Nothing is written unless the verdict is OK, because a partial payload is worse than none — it parses. The returned Verdict describes the bytes actually written, so Tier is measured rather than predicted.

Efficient is deliberately NOT measured; SizeCompared reports that. The comparison needs a second marshal that a caller writing the payload does not read. Call Check when you want the cost signal.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL