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 five things toon-go does not cover, which an agent-facing CLI has to get right before it prints or reads:
Sanitize: hostile input handling. toon-go rejects control bytes below 0x20 with an error but passes U+2028/U+2029, invalid UTF-8, lone C1 bytes and Unicode format characters (bidi controls, zero-width and tag characters) straight through. A payload carrying a raw ANSI escape reaches the terminal that renders it, and one carrying a bidi override can reorder what a reader sees.
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.
Tabular reading: toon-go decodes a whole document in one strict pass and returns a map, which drops the three things a tabular array declares about itself. Its parsedHeader type is unexported, so the field ORDER, the delimiter and the declared row count are all unreachable from the public API — and a paginated payload, which declares more rows than it carries on purpose, fails the whole document. DecodeTabular reads the header itself and returns those three. Decode is the ordinary whole- document read and forwards to toon-go, which keeps one decoder rather than two.
The constants-over-prose choice is deliberate. A documented convention drifts; a compiled constant cannot.
Index ¶
- Constants
- func CanEncode(v any) bool
- func Decode(r io.Reader) (any, error)
- func DecodeFile(path string) (any, error)
- func Encode(w io.Writer, v any) error
- func EncodeOrJSON(w io.Writer, v any) error
- func IsTabularHeader(line string) bool
- func MustSanitize(v any) any
- func Sanitize(v any) (any, error)
- func SanitizeString(s string) string
- func WriteHelp(w io.Writer, lines []string) error
- type CycleError
- type Document
- type ExitCode
- type Format
- type KeyCollisionError
- type TagVerdict
- type Tier
- type Verdict
Constants ¶
const Default = TOON
Default is the format used when the caller expresses no preference.
const MaxDocumentBytes = 64 * 1024 * 1024
MaxDocumentBytes bounds a whole document read by Decode.
toon-go's decoder takes a []byte, so the document has to be resident — but "resident" is not "unbounded". Decode accepts any io.Reader, including one whose size cannot be checked first, and an uncapped read there is a way to exhaust memory before a single byte is parsed. 64 MiB is far above any real findings payload (hundreds of rows of KB-scale text) and far below trouble.
Variables ¶
This section is empty.
Functions ¶
func CanEncode ¶
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 Decode ¶ added in v0.3.0
Decode reads a TOON document of any shape, preserving toon-go's types.
DecodeTabular is tabular-only by contract and rejects anything else, which is correct for the findings payloads it was written for and useless for the rest. `llm-filesystem-axi get-file-info` emits a flat object of scalars with no array anywhere, and `list-directory` emits an array with a `total` sibling — the first would not decode at all and the second silently dropped the total.
Types are preserved rather than projected to strings: this is a new entry point with no existing consumers, and a size that stays a number is more useful than one that becomes text.
func DecodeFile ¶ added in v0.3.0
DecodeFile reads a TOON document of any shape from a file.
func Encode ¶
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 ¶
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 IsTabularHeader ¶ added in v0.3.0
IsTabularHeader reports whether line has the SHAPE of a TOON tabular array header: a name, a bracketed count, and a trailing colon.
It is the dispatch point between DecodeTabular and Decode, and it deliberately does NOT validate. Asking parseHeader instead — "does this header parse" — makes a BROKEN tabular header indistinguishable from a line that was never a tabular header, and the caller then reroutes it to the document reader. Observed, through the binary rather than the package:
$ llm-support toon parse f.axi # f[1|]{a|b|a}: / 1|2|3
{"shape":"document","value":{"f":[{"a":3,"b":2}]}} exit 0
A duplicate column is an error at the package level and there is a test proving it, but the payload never reached that error. toon-go kept one of the two `a` columns, last value wins, the 1 was gone, and the command reported success. Dropping a column in silence is the precise failure this reader exists to remove, so a line that LOOKS tabular stays on the tabular path and gets to report its real problem.
The shape test cannot swallow a document: an inline array (`tags[2]: x,y`) does not end in a colon, and a scalar or nested key has no bracket at all.
NOT the same test as the unexported isTabularHeader in check.go, and the two must not be merged. That one asks "does this WHOLE document contain a header with a braced field list", over []byte, to set Verdict.Tier on output that go-axi itself just encoded. This one asks "does this ONE line open a tabular array", over a string, to route a line the caller is about to read. The braced field list is the difference that matters: `findings[0]:` is a legitimate empty array here and is deliberately not a tabular header there, because an empty array is not what the tier is describing.
func MustSanitize ¶
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 ¶
Sanitize returns v with every string cleaned of characters that would either break the TOON encoder or reach stdout as a raw control byte.
Memory ¶
The input is never MUTATED, and that is the guarantee callers rely on. What is NOT promised is that the result occupies different memory: nothing is allocated unless a string actually needed cleaning, so for a clean value the result IS v. This was already true before the copy became conditional — nil containers, scalars, funcs, channels and every unexported struct field were always passed through by reference.
Two rules follow, and a caller needs both:
Do not mutate your own value after calling Sanitize and expect the returned value to stay as it was. There is no compile-time or runtime signal that the two are the same value.
Do not mutate it CONCURRENTLY while this call runs, or while the result is being encoded. Encode, EncodeChecked and EncodeOrJSON pass the result straight to toon.Marshal, so a clean payload is marshalled from the caller's own map rather than from a private snapshot. A concurrent write during that window is a fatal "concurrent map read and map write" that recover() cannot catch. Rebuilding unconditionally used to hide that race behind a copy; it never made the caller's code safe, because encoding/json and every other reflective marshaller carry the identical hazard.
Why it exists ¶
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.
- Unicode format characters (category Cf) pass through too. Bidi controls and zero-width characters let text reorder or hide itself when shown in a terminal, editor or diff, and tag characters carry text a human cannot see.
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.
Concrete types are preserved, 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 ¶
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 ¶
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
Error names the type the cycle was found in, and says why the value is refused rather than encoded.
type Document ¶ added in v0.3.0
type Document struct {
Name string // the array name, e.g. "findings"
Fields []string // declared field names, in order, unquoted
Delimiter rune // read from the header, never assumed
Rows []map[string]string // one map per row, keyed by field name
// Declared is the row count the HEADER states. It is not always len(Rows):
// atcr's paginated payload caps the emitted rows while keeping the true
// total in the header, and says so with a `truncated` sibling.
Declared int
// Meta holds `key: value` sibling lines that follow the array at column 0 —
// `truncated: false` is the one atcr emits today.
Meta map[string]string
}
Document is one decoded tabular array.
func DecodeTabular ¶ added in v0.3.0
DecodeTabular reads one TOON tabular array from r.
func DecodeTabularFile ¶ added in v0.3.0
DecodeTabularFile reads one TOON tabular array from a file, naming it on error.
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 )
type Format ¶ added in v0.3.0
type Format string
Format is an output encoding chosen by the caller.
The choice is explicit rather than detected. Output shape is a property of the command, not of the data: a format that varied per call would force every consumer to handle both, and would make an instruction like "the third column is SEVERITY" unsafe to write down. EncodeOrJSON is the opposite policy and exists for callers that have no design-time rule.
func ParseFormat ¶ added in v0.3.0
ParseFormat maps a flag value to a Format.
An empty string selects Default, so an unset flag can be passed straight through without the caller special-casing it. Matching is case-insensitive and ignores surrounding spaces, because the value usually arrives from a shell.
func (Format) Encode ¶ added in v0.3.0
Encode writes v to w in the receiver's format, terminated by exactly one newline, in a single Write call.
TOON is sanitized first, which is load-bearing rather than cosmetic: toon-go errors on a raw ANSI escape instead of emitting it, so without sanitizing, a command handed coloured text would fail outright on data it should simply print. JSON is marshalled as-is; encoding/json escapes control bytes itself.
Field names come from struct tags — `toon` for TOON and `json` for JSON. toon-go does NOT fall back to the `json` tag, so a type carrying only `json` tags publishes its Go identifiers here. Use CheckTags to catch that in a test, or EncodeProjected to encode from the `json` tags instead.
Nothing is written when encoding fails, including on an unknown format. A partial payload is worse than none, because it parses.
func (Format) EncodeProjected ¶ added in v0.3.0
EncodeProjected writes v the way Encode does, but derives TOON field names from the `json` struct tags rather than requiring a second `toon` tag.
WHY THIS EXISTS. toon-go does not read the `json` tag. A codebase whose result types carry only `json` tags therefore has two options: add a duplicate `toon` tag to every field, or route the value through its JSON form first. The first is what cadence-axi does — 102 tags, maintained by hand, where a single omission silently publishes a Go identifier. The second is this function.
It is not free. Medians of six runs on a 500-row payload: 257us and 5,427 allocations direct, against 665us and 15,055 projected — about 2.6x the time and 2.8x the allocations, because the value is marshalled and unmarshalled once more. Encode remains the fast path and the default; this is the convenience. Reproduce with:
go test -run '^$' -bench Format_Encode -benchmem
Projection applies to TOON only. encoding/json already reads the `json` tag, so a JSON payload has nothing to correct and is passed to Encode unchanged — routing it through a generic map would also reorder its keys, since struct fields marshal in declaration order and maps in sorted order.
Going through JSON additionally normalises values to the shapes toon-go handles, so a type that marshals specially — time.Time, a json.Marshaler — reaches the encoder looking the way JSON output would have shown it.
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
Error quotes both original keys with %#v, so the invisible byte that made them collide is readable in a terminal that would not render it.
type TagVerdict ¶ added in v0.3.0
type TagVerdict struct {
// OK is true when no field would be published under its Go identifier.
OK bool
// Fields names each field that would be, as "Type.Field".
Fields []string
}
TagVerdict reports whether encoding a value as TOON would publish Go identifiers as its field names.
OK is true when every encodable field carries a `toon` tag. Fields names the ones that do not, as "Type.Field", in the order they were found.
func CheckTags ¶ added in v0.3.0
func CheckTags(v any) TagVerdict
CheckTags reports whether v's type is safe to hand to Format.Encode as TOON.
WHY THIS EXISTS. toon-go does not fall back to the `json` struct tag. Probed against the pinned version: a struct carrying only `json` tags encodes to its Go identifiers, so a field whose json tag reads "count" still emits "Count: 3" rather than "count: 3". Nothing errors and nothing is empty, so neither Check nor a smoke test catches it. The output is simply keyed on names no consumer was told to expect, and a skill instructed that "the third column is SEVERITY" reads the wrong column.
That is the same defect class Check was built for — a codec failing quietly rather than loudly — which is why the guard lives here rather than in each tool. Call it from a test over every type a command prints:
if v := goaxi.CheckTags(SearchResult{}); !v.OK {
t.Error(v.String())
}
A value that is already generic — a map, a slice, a scalar, or anything Format.EncodeProjected produced — carries no Go identifiers and is always OK. Unexported fields are never encoded, and a field marked `json:"-"` is never reached, so neither is flagged.
func (TagVerdict) String ¶ added in v0.3.0
func (v TagVerdict) String() string
String renders the verdict as a message suitable for a failing test or a build-time guard. It returns the empty string when the verdict is OK.
Deliberately not named Error. A method of that name would make TagVerdict satisfy the error interface, so `var err error = verdict` would compile and never be nil — including for a passing verdict.
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 )
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 ¶
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
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
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.