Quickstart
ago is a semantic edit protocol for Go: agents query the typechecked
workspace and submit compiler-checked mutations instead of editing text.
Go as a language for agents. The protocol puts the Go toolchain between the model and the files: an edit that does not typecheck cannot reach disk. LSP-backed tools have the same compiler diagnostics available and can still write broken code, because checking is advisory there. Here invalid edits are unrepresentable, not merely checked.
The thesis under test: weak local models become effective repo-scale Go editors when raw file editing is replaced by semantic queries and validated mutations with typed rejections. The bench in this repo measures exactly that, on tasks mined from real commits in traefik, vault, and boundary.
Install
brew install guygrigsby/tap/ago
curl -fsSL https://ago.aeryx.ai/install.sh | sh
go install github.com/guygrigsby/agent-go/cmd/ago@latest
Windows: grab the zip from the releases page. Every channel ships the
agent skill in the binary; ago skill install drops it into
~/.claude/skills for shell-capable coding agents. In an existing repo,
ago init wires the agent files without touching the module: AGENTS.md
(read natively by Codex, Copilot, Cursor, Windsurf, Zed, Claude Code,
and the rest of the AGENTS.md ecosystem), MCP wiring, and a GEMINI.md
pointer for the one holdout.
Or from a clone:
go build -o ago ./cmd/ago
Requires Go 1.26+. The daemon auto-spawns on first use, one per workspace, and exits after five minutes idle; there is nothing to start or configure.
How it works
One binary, three fronts over a per-workspace daemon (auto-spawned on first use, unix socket, idle exit):
$ ago --help
semantic edit protocol for Go: query the typechecked workspace, submit compiler-checked mutations
Usage:
ago [command]
reads and setup:
help the versioned op catalog: args, examples, ceilings
init scaffold an agent-first module: MCP wiring, AGENTS.md
inspect kind, signature, position, doc for one symbol
query semantic questions by --kind (callers, implementations, ...)
refs every reference, tests included
search name fragment to exact symbol addresses
status load or refresh the snapshot: packages, files, errors
view declaration as annotated text with node handles
mutations (validated before anything touches disk):
add-param add a parameter, call sites updated with --default
patch ordered multi-op edit, atomic and generation-checked
rename rename a symbol, every reference proven to resolve
set-body replace a function body, typechecked first
test go test, scoped, structured pass/fail
upsert add or replace a whole declaration
lifecycle:
daemon run the workspace daemon in the foreground
mcp serve the MCP tools over stdio
skill install or print the embedded agent skill
Additional Commands:
completion Generate the autocompletion script for the specified shell
help Help about any command
Flags:
-C, --dir string workspace directory (default ".")
-h, --help help for ago
Use "ago [command] --help" for more information about a command.
A few invocations:
ago search -s MaxEntries
ago refs -p <pkg> -s <sym>
ago query -k implementations -p <pkg> -s <iface>
ago rename -p <pkg> -s DefaultLimiterMaxEntries --to DefaultLimiterMaxQuotas
echo 'return v << 1' | ago set-body -p <pkg> -s Double --body-file -
ago patch --body-file patch.json
Agents connect over MCP (ago mcp); ago init writes the wiring. For
agents with a shell (Claude Code and kin), ago skill install teaches
the CLI workflow in any Go repo with no per-repo setup; the skill ships
embedded in the binary. patch
is the full language: 14 statement ops, composable decl ops, table-driven
test ops; rename/set-body/add-param/upsert are one-op sugar over
it. Full catalog via ago help.
Every mutation validates before anything touches disk: the daemon re-typechecks only the affected packages against its in-memory graph (~200ms for a 29-reference rename on a 533-package repo) and rejects with the compiler’s own diagnostics:
{
"status": "rejected",
"reason": "edit does not typecheck",
"diagnostics": [
{"pos": "config.go:114:29", "msg": "undefined: slices"},
{"pos": "config.go:9:2", "msg": "\"reflect\" imported and not used"}
]
}
Rejections are data, not errors: the agent loop is query, mutate, and on
rejection adjust and retry. Most rejections carry possible_repairs,
complete paste-ready calls: the corrected mutation, the re-view that
refreshes handles, the search that locates an undefined identifier.
Resending a rejected call unchanged gets an escalated rejection instead
of the same one forever. Rename also proves every rewritten reference
still resolves to the renamed symbol, so shadowing capture is rejected
even when the compiler is satisfied.
Bench
bench/ holds the raw-vs-semantic comparison: same local model, same
harness (opencode), same mined tasks (rename and add-param kinds, from
traefik, vault, boundary, and cobra); one mode gets shell and file
editing, the other gets only the protocol.
The oracle is the bench’s third arm: a scripted replay of each task’s ground-truth commit through the protocol itself, no model in the loop. Every mined task carries the real change its source commit made; the oracle submits that change as protocol calls and must reach green. A task enters the roster only after the oracle certifies it, so “the model failed” is never confused with “the task was impossible”. Oracle rejections are findings (an engine bug, a bad extraction, or a named protocol ceiling), its wall time is the task’s time-to-green floor, and its accepted transcripts seed the fine-tuning corpus.
Scoring is goal predicate + typecheck + scoped tests under a time cap;
the tests gate counts only where a pristine worktree passes the same
tests. Every episode records its transcript, config, diff, score, token
usage, failure kind, and the daemon’s per-request log under
bench/results/. Serving setups are pinned as named profiles in
bench/profiles.json and embedded in each run’s run.json.
# smoke: one certified task per kind, smallest repo, for first contact
AGO_BENCH_SUITE=smoke AGO_BENCH_PROFILE=<name> AGO_BENCH_SCRATCH=<clones dir> \
go test ./bench -bench Rename -benchtime 1x -timeout 0
# model round, k=3
AGO_BENCH_PROFILE=glm-flash AGO_BENCH_SCRATCH=<clones dir> \
go test ./bench -bench Rename -benchtime 3x -timeout 0
# oracle sweep: no model, episodes run in parallel
AGO_BENCH_MODES=oracle AGO_BENCH_SCRATCH=<clones dir> \
go test ./bench -run OracleSweep -parallel 20 -timeout 0
# mine candidate tasks from any clone; report across runs
go run ./cmd/bench mine -scratch <clones dir> <repo>
go run ./cmd/bench report bench/results/<run> ...
Development
go test ./... # full suite; snapshot tests take ~40s
gofmt -l . # must be empty
Rules of the road:
- TDD: write the failing test first, watch it fail, then implement. Every op and repair in the tree landed that way.
- Docs are drift-guarded by tests: the op catalog must match
docs/specs/surface.md, help examples must be accepted against the test fixture, and README invocations must match the real CLI dispatch. Change behavior and the guard tells you which doc to touch. - The demo fixture (
internal/snapshot/testdata/demo) is copied per test; never mutate it in place.demo/libis frozen behind a recorded hash (TestDemoLibFixtureFrozen) because the view-handle tests depend on its exact layout.demo/sigis the place for new fixture shapes. - Race-sensitive changes (the parallel retypecheck) should run under
go test -race ./internal/snapshot.
Docs
docs/specs/language.md: the full op catalog, decl through test opsdocs/specs/surface.md: shipped and foreseen calls, drift-guarded by testdocs/specs/protocol.md: protocol semantics and guaranteesdocs/specs/bench.md: bench designdocs/specs/plan.md: status and build orderdocs/optimizations/: per-model serving research and the cross-model build listdocs/tenets.md: the engineering principles this repo lives bydocs/model-strategies.md: brainstorm on coding with local modelsdocs/adr/: architecture decisionsidea.md: the original thesis
Work is tracked in beads: bd ready
for the queue, bd list for everything; issues sync through
.beads/issues.jsonl.
Experimental. Interfaces change without notice.
Inspired by zero, a programming language for agents.
Why we chose Go
The thesis: weak local models become effective repo-scale editors when raw file editing is replaced by semantic queries and validated mutations with typed rejections. Picking the target language came down to two questions. Which language is easiest for a small model to reason about, and which toolchain exposes enough semantic machinery to build the protocol without writing a compiler?
Go won the first question. C# won the second. Go got the nod because the agent’s job has to be easy; ours only has to be possible.
Why Go
Go removes most of the ambiguity that trips up local models:
- small grammar
- very little syntactic variation
- explicit imports
- explicit error flow
- no inheritance hierarchy
- limited metaprogramming
- structural interfaces
- standardized formatting
- fast compilation and tests
- predictable repository layout
That matters more than people give it credit for. A 7B or 14B model has less language surface to understand and fewer plausible-but-wrong ways to express a change.
More importantly, Go exposes most of the analysis stack as ordinary libraries:
go/parser syntax
go/ast AST
go/types type checking and symbol identity
go/packages whole-program/package loading
go/ssa SSA and call-oriented analysis
go/analysis modular analyzers
gopls references, rename, fixes and refactorings
go/packages loads and type-checks complete programs. go/ssa provides an
analysis-oriented intermediate representation. gopls already performs
structured transformations: rename, extraction, inlining, code repair,
formatting. Nobody had to invent the semantic substrate; the work was
putting a better agent protocol over it.
The shape we wanted
Not an MCP server with twenty vague tools. More like a small database language:
inspect symbol "github.com/acme/store.(*Store).Put"
find callers of symbol_id="..."
find implementations of interface_id="..."
find writes to field_id="..."
find paths from handler_id="..." to effect="network"
And tightly scoped mutations:
rename_symbol
add_parameter
replace_call
implement_interface
extract_function
move_declaration
add_struct_field
wrap_error
add_test_case
Every mutation returns structured data:
{
"status": "rejected",
"reason": "interface implementation would become incomplete",
"affected_symbols": ["..."],
"diagnostics": ["..."],
"possible_repairs": ["add_method", "change_interface"]
}
That is where a smaller model wins. It chooses from bounded operations instead of synthesizing arbitrary patches.
What we passed on
Rust
rust-analyzer is an excellent semantic engine: query-driven incremental analysis, separate syntax and semantic representations. Philosophically it is close to what we wanted.
The language itself asks a lot of a weak model:
- traits and associated types
- lifetime relationships
- coercions and autoderef
- macros
- feature-gated compilation
- conditional compilation
- complex generics
- borrow-checker-driven repairs
The compiler can tell an agent it is wrong, but choosing the right repair often takes more reasoning than the equivalent Go. Rust could be made very agent-friendly, and rust-analyzer is probably the right foundation for whoever takes that on. For a proof of concept, too much of our time would have gone to interfacing with compiler internals that were never designed as a public surface, and to interpreting diagnostics written for a human reader.
C#
Roslyn is almost comically well suited to this kind of project. Its workspace model already exposes:
- complete solutions and projects
- source text
- immutable syntax trees
- semantic models
- compilations
- symbol identities
- analyzers
- code fixes
- refactorings
The API is designed for programmatic inspection and transformation across a whole solution, and a convincing demo would probably have landed here fastest:
var symbol = semanticModel.GetDeclaredSymbol(node);
var references = await SymbolFinder.FindReferencesAsync(symbol, solution);
The language is bigger though, with more history for a model to carry:
- overload resolution
- inheritance
- attributes
- LINQ
- delegates and events
- nullable-state analysis
- reflection
- source generators
- multiple equivalent syntactic styles
Roslyn optimizes for the builder. Go optimizes for the agent. We built for the agent.
C++
Clang may have the richest low-level code-query and transformation tooling anywhere: LibTooling, AST matchers, refactoring APIs, compiler-grade semantic information.
The language asks more of a small model than anything else we considered:
- macros obscure source identity
- templates carry enormous semantic complexity
- overload resolution is intricate
- undefined behavior is invisible to structural checks
- ownership is largely conventional
- build configurations alter the visible program
- small changes can surface distant failures
Building here would have proven the infrastructure impressive without showing that the approach makes local agents useful.
Zig
Appealing because the language is explicit, but the compiler APIs are not ready to carry a project like this. Choosing it would mean living the same lesson zero is working through: pick an interesting language, then discover the semantic tooling is itself a research project.
What we built
Ultimately, we chose Go for
the target language and Go for the control plane, sitting directly
on go/packages, go/types, go/ssa, and selected gopls machinery.
agent → semantic protocol → Go workspace model
↓
transaction/refactoring engine
↓
gofmt → go vet → go test → commit
The first operations:
inspect_symbolfind_referencesrename_symbolchange_signatureimplement_interface
Then the same local model runs in two modes, raw shell and source editing against semantic operations only, on deliberately repository-wide tasks. The bet: semantic mode shows a large improvement in completion rate, particularly around missed callers, wrong symbols, imports, and compile-repair loops. The bench in this repo measures exactly that.
How it works
Thesis (from idea.md): weak local models become effective repo-scale Go editors when restricted to semantic queries and validated mutations instead of raw text editing. The protocol is the product; the bench proves or kills the thesis.
Language
- Workspace: one Go module tree (go.work planned). Unit of daemon ownership and snapshot state.
- Snapshot: the typechecked in-memory view of a workspace: packages, syntax, types, reference info. Owned by the daemon, never by clients.
- Symbol address:
pkg(import path) +sym(Name, orType.Memberfor methods and fields). Locals and import aliases are not addressable yet; that needs scope- or position-qualified addressing. - Query: read-only question answered from the snapshot:
status,search,inspect,refs,callers,callees,implementations,doc. Milliseconds; never touches disk. List results page at 50 with a totalcount,truncated, andnext_offset/offset. - Mutation: a checked edit: the full op catalog in language.md
(decl, statement, and test ops, incl.
set_signatureandmove_decl), plus the four sugar tools. Validated against the snapshot before any file is written. All-or-nothing: failure writes nothing and rolls back everything. An accepted mutation that reshaped exactly one declaration embeds that declaration’s fresh view in its response. - Rejection: the structured refusal of a mutation. Reason, detail,
compiler diagnostics,
did_you_meancandidates, andpossible_repairs, complete paste-ready next calls. A rejection is data for the agent, not an error; the agent loop is query → mutate → (rejection → repair → retry). Exact resends of a rejected call escalate instead of repeating. - Splice: package-level revalidation: re-typecheck only the dirty set (edited-file packages, plus transitive reverse importers when the edit can change API or method sets) and swap results into the live snapshot. Packages check in parallel, one goroutine each, scheduled over the post-edit import graph (~56ms for a 117-package dirty set). Identity across splices is objectpath-based (ADR 0002).
Guarantees
The canonical guarantees list lives in language.md (its list is the superset: atomicity, generations, tolerance for pre-existing issues, embedded views). In one line: a mutation introduces no new diagnostic or it changes nothing.
Surface
One binary, three fronts over one daemon (unix socket per workspace,
auto-spawned, idle-exit; gopls -remote=auto precedent, ADR 0001):
- CLI:
ago init|status|inspect|refs|rename|set-body|stop, JSON out, exit 2 on rejection. - MCP:
ago mcpover stdio for agent harnesses; toolsago_*mirror the CLI ops; rejections returned as payloads, not tool errors. ago initscaffolds an agent-first project: compilable module, MCP wiring, AGENTS.md protocol instructions.
Op catalog
Implemented surface: the ten-tool MCP/CLI surface (status, help, query,
view, patch, test, rename, set_body, add_param, upsert_decl) plus the
full patch op catalog underneath patch. See docs/specs/language.md
for the language spec and ago help for the versioned, per-op catalog
(args, one example, notes) that always matches what’s built.
Non-goals for now: multi-module go.work, non-Go files, formatting choices (gofmt is the only style), IDE features (hover docs, completion).
Engineering tenets
The principles this repo lives by. Each names its enforcement; a tenet without a mechanism is a wish. The rules are strict on purpose: this work is meant to be checked, used, and built on by other people, and the rigor is for them. We care about the work and the humans it lands on.
1. Automate everything so drift is impossible
Every fact that lives in two places gets a test comparing them, code side as the source of truth. Review only has to catch prose semantics; existence, status, counts, shapes, and examples are machine-checked. Enforced by the guard lattice (op registry ↔ help catalog ↔ surface.md ↔ language.md ↔ issue status ↔ README ↔ MCP wire ↔ init scaffold ↔ bench counters), catalog-version hashing, fixture-executed examples, and CI running the whole lattice on every push. Adding a fact without a guard is the bug.
2. Check the answer key first
Every benchmark task comes from a real commit, so the correct answer is
already known. Before any model is asked to attempt a task, we submit
that known answer through the protocol ourselves. If the right answer
can’t get through, the model never had a chance: the problem is ours (a
spec bug, an engine bug, or a protocol limit), and the task doesn’t
count until it’s fixed. Enforced by TestOracleSweep and the certified
flag: bench certify flips it from committed oracle evidence only, and
model rounds refuse uncertified tasks.
3. Rejections are data
Every rejection answers “what is the correct next call”: diagnostics say
what broke and possible_repairs carries the corrected call whole.
Sending the same rejected call again escalates instead of looping. A
repair that would itself reject is worse than none, so every repair runs
verbatim in tests before it is ever offered. Enforced by the repairs
suite; each new repair kind lands with its paste-back execution test.
4. Parallel by default, deterministic by test
Concurrency is the default for independent work (bench episodes, packages, subagents); serial needs a named reason. Identical query, identical bytes: caches, benchmarks, and result comparability all hang on it, and map iteration order is not an ordering. Enforced by the race detector in CI, TestParallelRetypecheckMatchesSerial, and TestQueryDeterministicBytes.
5. Name the ceiling, ship the floor
Deliberate simplifications are documented where they bind, with the trigger for lifting them. “v1 ceiling: X, rejected with the blocker named” is a feature; a silent wrong answer is the bug. Enforced by ceiling notes in the catalog and specs, rejects that name what blocked them, and TestLanguageSpecOpRowsMatchRegistry: an UNSHIPPED row that ships, or a shipped row that does not exist, fails the build.
6. Tolerate the world, reject the change
Real codebases carry history, and the people working in them shouldn’t be blocked by problems they inherited. A mutation is judged only on the diagnostics it introduces; what was already failing never stops unrelated work. Same rule shapes scoring: a test gate no ground truth could pass is vacuous, not failed. Enforced by baseline capture and filter in the engine and the pristine-worktree baseline in bench scoring.
7. Meet the codebase where it is
Production code bends to deadlines, migrations, and business reality. That is not a defect to punish; it is the medium. The protocol serves the engineer shipping under those constraints, not an imagined ideal repo, and nothing here demands a clean world before it helps. Enforced by the bench itself: every task is mined from a real commit in a production repo (traefik, vault, boundary), never hand-written, so the tool is measured on the code people actually live in. TestTaskManifestsCiteRealCommits fails any manifest entry that does not cite a full commit from a known repo.
8. The data is the contribution
Every run is captured as if a reader will check the work: transcripts, configs, diffs, scores, tokens, failure kinds, and request logs in git, serving setups pinned per run, prompts token-counted. A number without its evidence trail is an anecdote, and nobody has measured Go-with-local-models in this flavor; the data matters as much as the code. Enforced by the bench record path, the results-in-git convention, the MLflow export, and TestCommittedEpisodesCarryIdentity: committed evidence missing its task, mode, or profile fails the build.
9. Tooling speaks the project’s language
No sidecar scripts. A committed script is a missing subcommand; mining, reporting, extraction, and validation are Go code with tests, living next to what they serve. Enforced by TestNoSidecarScripts, which fails on any committed script file.
The ago language
The complete semantic edit language for Go agents. Supersedes the op catalog in protocol.md (which documents the currently implemented surface); engine semantics (daemon, snapshot, splice) are unchanged and specified there and in the ADRs.
Design stance, fixed by the bench evidence and Guy’s calls (2026-07-15): statement-granular ops so the model composes operations, not syntax; expression arguments are text atoms now with a structured form designed in; node handles from views address everything inside a declaration; a patch is an ordered, atomic, generation-checked transaction. Be as structured as possible; start looser to get going.
Surface
Six tools, four sugar ops. Everything else is patch payload.
| tool | purpose |
|---|---|
status | load/refresh; packages, files, errors |
help | versioned op catalog with per-op schemas and examples |
query | semantic questions: search, inspect, refs, callers, callees, implementations, doc |
view | render a declaration with node handles and its generation |
patch | ordered op list, validated and applied atomically; dry_run for preview |
rename, set_body, add_param, upsert_decl | standalone sugar, each exactly a one-op patch |
Addressing
- Packages: import path. Symbols:
NameorType.Member(methods, fields). Test-file symbols resolve through test variants. - Inside a declaration: node handles (
n1,n7, …) issued byview. A handle is meaningful only against the generation the view reported. - Every declaration has a generation, bumped by any accepted mutation that
touches it. A patch names the generation it was built against; a
mismatch rejects with
stale generation: re-view, never a guess.
Locals and import aliases are addressable only through handles (their declaring node), never by name; that closes the addressing gap without a second naming scheme.
Queries
All from the typechecked snapshot (go/types tier); milliseconds.
search {q}: case-insensitive name fragment to exact addresses.inspect {pkg, sym}: kind, signature, decl position, doc.refs {pkg, sym}: every reference, tests included, defs marked.callers {pkg, sym}/callees {pkg, sym}: static call-graph edges from types info. A call through an interface reports the interface method (query the interface method for its callers;implementationsbridges to concrete types). v1 ceiling: no method-set candidate expansion for dynamic dispatch.implementations {pkg, sym}: interface -> implementing types, or type -> interfaces satisfied.doc {pkg, sym}: doc comment.
View
view {pkg, sym} returns the declaration as annotated text, one handle
per statement and per addressable expression slot, plus generation:
gen 14
func (s *Store) Put(v int) error {
n1: if s.frozen {
n2: return ErrFrozen
}
n3: s.n = v
n4: return nil
}
Views are projections; agents read them, never edit them.
Patch
{"pkg": "demo/lib", "sym": "UseHelper", "generation": 14, "dry_run": false,
"ops": [{"op": "add_if", "at": "n1", "where": "after", "cond": "v > 0"},
{"op": "add_return", "at": "$1", "where": "first", "exprs": ["v"]}]}
Ordered. Validated as one unit: all ops apply to an in-memory copy, the
dirty set re-typechecks once, resolution proofs run once, then everything
writes and splices, or nothing does. Ops later in the list may address
handles created by earlier ops (constructors return handles in the
response; in one patch they are referenced as $1, $2, … by op index).
dry_run runs the whole pipeline and reports the outcome without writing.
Cross-declaration patches (e.g. interface + impls rename) name pkg/sym per op instead of at the top level.
Decl ops
| op | args | notes |
|---|---|---|
upsert_decl | pkg, text, imports? | add or replace a whole declaration; goimports in the loop, imports names what it cannot infer (aliases, ambiguous names); replaces into _test.go files and single members of grouped blocks in place (iota and inherited-value members reject, named), new decls append to an existing package file (test funcs to a _test.go) and may reference other in-flight ops; creates packages under the module on demand |
delete_decl | pkg, sym or syms | rejected while references remain (listed); the syms batch tolerates intra-set references, spans earlier ops rewrote do not count, grouped members excise in place (iota and inherited values reject), and methods deleting with their receiver type defer to the end-of-list typecheck |
move_decl | pkg, sym, to_pkg, create_pkg? | rewrites references and imports, requalifying call sites; the declaration’s own imports travel with it, aliases included; test decls land in a _test.go, created on demand. create_pkg creates a missing module-local target (opt in; a bare miss rejects and offers the flag as a repair). a type moves with its whole method set; one spec of a grouped block extracts standalone. v1 ceilings: the declaration must be self-contained (no uses of its old package’s other top-level symbols); grouped specs may not lean on iota or inherited values; each rejects naming the blocker |
rename | pkg, sym, to | proves post-splice resolution; rejects capture and collision |
set_body | pkg, sym, body | body as checked text; the coarse escape hatch |
set_signature | pkg, sym, signature, defaults? | full param/result rewrite as Go text. Parameters match the old signature by name: carried ones keep each call site’s argument (reordering reorders them), dropped ones drop it, new ones splice their defaults entry positionally, so spread sites f(args...) survive insertions before the variadic. Underscore params pair positionally when their type matches, so widening func(ctx context.Context, _ DecryptFn) carries the _ argument. Interface methods work; changing an interface and its implementors is one atomic multi-op patch. Value uses and the body are NOT rewritten: repair them with sibling ops in the same patch or the end-of-list typecheck rejects with the positions |
add_param | pkg, sym, name, type, default | callers updated with default, inserted before a variadic tail (spread sites included); a top-level body local name := <default> is promoted into the parameter, any other same-named local rejects with its position; value uses rejected with theirs |
remove_param | pkg, sym, name | UNSHIPPED (use set_signature, which drops params today); planned as sugar over it |
add_field | pkg, sym (Type), name, type, tag? | |
remove_field | pkg, sym (Type.Field) | rejected while references remain |
set_doc | pkg, sym, text | doc comment only |
implement_interface | pkg, type, iface | UNSHIPPED; generates missing method stubs |
Statement ops
All take {at: handle, where: before|after|first|last} for placement
(first/last against a block handle). Expression-valued arguments are
text atoms, parsed and typechecked in scope at the target position.
| op | args | notes |
|---|---|---|
add_assign | lhs, rhs, define? | := when define |
add_return | exprs[] | arity/type checked against signature |
add_call | expr | expression statement |
add_if | cond, else? | creates empty block(s); returns the then-block handle (else block addressed via a fresh view) |
add_for | cond? or range? | empty body; returns handle. v1 ceiling: no init/post clauses; use upsert_decl/set_body for classic three-clause loops |
add_switch | tag? | empty; extend with add_case |
add_case | at (switch handle), exprs[] or default | returns body handle |
add_defer | expr | |
add_go | expr | |
set_cond | at, expr | if/for/case condition replacement |
replace_expr | at, expr | v1 ceiling: the node’s condition or a whole expression statement only; per-slot sub-expression handles are future |
delete_node | at | statement or case; deleting a block requires it be empty |
wrap_stmts | from, to, with (if/for/block), cond? | from/to must be siblings in order in the same block; returns the new node’s handle |
wrap_error | at (assign or call handle), message | the Go idiom: assign err, add if err != nil return with fmt.Errorf("...: %w", err). v1 ceiling: a bare expression-statement call resolves its return arity only for a same-package function identifier |
The statement vocabulary deliberately omits constructs an agent should
express with upsert_decl/set_body wholesale (select, labeled
statements, complex composite literals); help says so per gap. If bench
evidence shows a missing op mattering, it gets added; the catalog is
versioned.
Test ops
Tests are declarations underneath, but they get dedicated ops for three
reasons: placement is constrained (a _test.go file, correct test
package), the naming is constrained (TestXxx(t *testing.T)), and the
idiomatic form humans expect, table-driven, is structured enough that an
agent should compose it from data, not synthesize its shape.
| op | args | notes |
|---|---|---|
add_test | pkg, target (sym under test), name? | scaffolds a table-driven test: case struct derived from the target’s signature (inputs from params, want from results), rows slice, range + t.Run loop, one starter failure message. v1: address the test by name in follow-up ops. Name defaults to Test<Target> |
add_test_case | test (name), name, args[], want[] | appends one row; values are expression atoms typechecked against the case struct. v1 addresses tests by name; table handles are future |
set_test_case / remove_test_case | case addressed by test + row name | |
add_bench | pkg, target, name? | UNSHIPPED; BenchmarkXxx(b *testing.B) skeleton |
Placement and form rules, enforced at validation:
- New tests land in
<declfile>_test.gonext to the target, created on demand. Internal vs external test package follows the package’s existing tests; a package with no tests gets internal. - Assertion style follows the package’s dominant existing convention
(stdlib
t.Errorfvs testifyrequire/assert), detected from the test files already present; stdlib when there is no precedent (v1 detection: any existing_test.goimporting testify/require flips torequire.Equal;assertunsupported). - The canonical skeleton is fixed by this spec (name/args/want struct,
got:= call, comparison,t.Errorf("Target(%v) = %v, want %v", ...)) so generated tests read the same everywhere; gofmt applies as always. - Generated helpers call
t.Helper().
Arbitrary non-table tests remain expressible with upsert_decl into a
_test.go path; the ops cover the idiomatic 90%.
The test tool
Semantic mode has no shell, so running tests is part of the language: a
test {pkg?, run?} tool executes go test scoped to a package (and
optionally -run filter) and returns structured results: pass/fail per
test, failure messages with positions, elapsed time. Per Guy’s workflow
rule, validation of mutations stays compiler-only; test is how the agent
closes the behavior loop per set of changes, at its own judgment. The
bench’s scoring runs tests independently either way.
Project ops
| op | args | notes |
|---|---|---|
add_dependency | module, version? | go get + tidy, validated build |
remove_dependency | module | rejected while imported |
move_file | from, to | package clauses and imports updated |
delete_file | path | rejected while it declares referenced symbols |
mod_tidy |
Expressions: atoms now, structure designed in
Every expression-typed argument accepts either a string (text atom) or a structured node. The structured grammar is fixed now so it can arrive per-argument without a protocol break:
{"kind": "binary", "op": "!=", "left": {"kind": "ident", "name": "err"},
"right": {"kind": "ident", "name": "nil"}}
Kinds: ident, select, index, call, lit, unary, binary, paren, func
(closure bodies are op lists). v1 implements text atoms; the structured
form is the target for constrained decoding (one JSON grammar covers a
whole patch, so a llama.cpp GBNF grammar can force validity at the
decoder).
Rejections
{"status": "rejected", "reason": "...", "detail": "...",
"diagnostics": [{"pos": "...", "msg": "..."}],
"did_you_mean": ["..."],
"possible_repairs": [{"why": "demo/lib.Double resolves",
"call": {"tool": "view", "args": {"pkg": "demo/lib", "sym": "Double"}}}]}
A rejection is the agent’s error channel and must always answer “what is
the correct next call”: diagnostics say what broke, did_you_mean lists
bare candidates, possible_repairs carries the corrected call whole,
complete and paste-ready. Addressing misses resend the corrected call
(view, query, patch ops, and the sugar mutations), filtered so a repair
never repeats the rejection that produced it; stale generations and
unknown handles repair with the re-view call; a missing required op
argument falls back to the help call; an undefined identifier in a
typecheck reject gets the search call that locates it. Nothing is
guessed: where no mechanical repair exists, diagnostics stand alone.
Patch rejections say which op index failed; earlier ops in the patch
have no effect. Op arguments decode strictly: a field from another op’s
vocabulary rejects at the shape layer naming the field, with the help
call as its repair. An exact resend of a just-rejected call gets an
escalated rejection (resent, escalation) instead of the same answer
forever. List-returning queries page at 50 entries: count is always
the total, truncated responses carry truncated and next_offset, and
offset requests the next page.
Guarantees
- An accepted edit introduces no new compiler diagnostic. Problems the
code already had never block it; they are measured up front and
reported separately as
pre_existing. - A rejected patch changes nothing: disk, snapshot, or generation.
- Rename and move prove every rewritten reference still points at the intended object. A reference captured by shadowing is rejected even when the compiler would accept it.
- Patches apply whole or not at all; a patch built on a stale generation is rejected.
- Queries see accepted mutations immediately. A patch that reshapes one
declaration gets that declaration’s fresh view back in the response,
so the next edit needs no extra call; multi-declaration patches say
why the view was omitted (
views_omitted). - Edits made outside the protocol are detected on the next request and trigger a full reload.
Future, named
- SSA query tier:
writes_to,paths_to_effect, purity/escape facts. - Multi-module / go.work workspaces (vault sdk/ class of repo).
- Structured-expressions-only mode + constrained decoding integration.
- Batch cross-repo transactions.
- Statement-op coverage growth driven by bench evidence, via the versioned catalog.
Testing
Per-op unit tests against the fixture module; the oracle harness executes ground-truth-derived patches for every bench task (a task enters the bench only if the protocol can express it); the raw-vs-semantic bench is the integration gate and the measure of whether added structure pays.
Op catalog
Generated from ago help (catalog version v9) at build time. 33 patch ops.
Tools
status
Load or refresh the workspace snapshot. Returns package and file counts and any type errors. No arguments.
help
Return this versioned op catalog: every patch op’s argument schema, one worked example, and its v1 ceilings, plus short descriptions of the six tools. No arguments.
query
Semantic questions against the typechecked snapshot, dispatched by kind: search (case-insensitive name fragment -> exact addresses), inspect (kind, signature, decl position, doc), refs (every reference, tests included, defs marked), callers/callees (static call-graph edges; a call through an interface reports the interface method), implementations (interface -> implementing types, or type -> satisfied interfaces), doc (doc comment text). Args: kind (required), pkg, sym, q (name fragment, for kind=search, falls back to sym), offset (page offset for list results). Lists are position-sorted and paged 50 at a time: count is the total found; a truncated response carries truncated=true and next_offset to pass back as offset.
view
Render a declaration as annotated text. Functions and methods get a per-statement nK: handle prefix plus a generation counter for staleness checks; other declarations (const, var, type) render as plain source. Handles are meaningful only against the generation the same response reports. Args: pkg, sym.
patch
Apply an ordered list of ops as one atomic, generation-checked transaction: every op applies to an in-memory copy, the dirty set re-typechecks once, then everything writes and splices together — or nothing does. Ops compose: an op later in the list can address a handle an earlier op returned, referenced as $1, $2, … by 1-based op index. dry_run runs the identical pipeline and reports accept/reject without writing. Op families (full schemas and examples via help): decl ops (rename, set_body, add_param, upsert_decl, delete_decl, set_doc, add_field, remove_field), statement ops (add_assign, add_call, add_return, add_if, add_for, add_switch, add_case, add_defer, add_go, set_cond, replace_expr, delete_node, wrap_stmts, wrap_error), test ops (add_test, add_test_case, set_test_case, remove_test_case), project ops (delete_file, move_file, add_dependency, remove_dependency, mod_tidy). A decl, test, or project op (rename, set_body, add_param, upsert_decl, delete_decl, set_doc, add_field, remove_field, add_test, add_test_case, set_test_case, remove_test_case) and a statement op cannot edit the same file in one patch; run them as separate patches. An accepted patch that touched exactly one declaration embeds that declaration’s fresh view (same {text, nodes, generation} payload the view tool returns) under “view”, so back-to-back edits need no view call in between; when several declarations were touched the response carries views_omitted instead. Args: pkg/sym (defaults for ops that omit them), generation, dry_run, ops (required, the array of op objects).
test
Run go test, scoped to a package (default the whole workspace) and optionally filtered by name, and return structured per-test results: pass/fail, elapsed time, and captured output for failures. Validation of mutations stays compiler-only; this is how you close the behavior loop after a set of changes. Args: pkg, run (a -run filter).
Patch ops
rename
proves post-splice resolution: every rewritten reference must resolve to the renamed object; reference capture rejects even when the compiler is satisfied
| arg | type | required | description |
|---|---|---|---|
pkg | string | package import path; defaults to the envelope’s pkg | |
sym | string | symbol: Name or Type.Member; defaults to the envelope’s sym | |
to | string | yes | new name |
Example:
[
{
"op": "rename",
"sym": "Double",
"to": "Twice"
}
]
set_body
the coarse escape hatch: replaces the whole block between braces, validated by typecheck like every other op
| arg | type | required | description |
|---|---|---|---|
pkg | string | package import path; defaults to the envelope’s pkg | |
sym | string | symbol: Name or Type.Member; defaults to the envelope’s sym | |
body | string | yes | new body as statements, no surrounding braces |
Example:
[
{
"op": "set_body",
"sym": "Double",
"body": "return v + v"
}
]
add_param
callers updated with default; a top-level local name := <default> in the body is superseded and deleted (parameters share the body scope), any other same-named body declaration is rejected with its position; references to the function as a value (assigned, passed, satisfying an interface) cannot be repaired and are rejected with their positions
| arg | type | required | description |
|---|---|---|---|
pkg | string | package import path; defaults to the envelope’s pkg | |
sym | string | symbol: Name or Type.Member; defaults to the envelope’s sym | |
name | string | yes | new parameter name |
type | string | yes | new parameter type, e.g. context.Context |
default | string | argument expression for existing call sites; required whenever the function already has callers |
Example:
[
{
"op": "add_param",
"pkg": "demo/sig",
"sym": "Scale",
"name": "offset",
"type": "int",
"default": "0"
}
]
upsert_decl
add or replace a whole top-level declaration; goimports runs in the loop. Replacement finds declarations in _test.go files too, and replaces single members inside grouped const/var/type blocks in place (send the standalone form; iota groups and members a following bare spec inherits from reject with the blocker named). New declarations append to an existing package file (test funcs to a _test.go file, so they actually run) and may reference symbols other ops in the same patch introduce; a package with no such file gets agent.go / agent_test.go created on demand — including a brand-new package mid-patch, so one atomic patch can create a package and move declarations into it
| arg | type | required | description |
|---|---|---|---|
pkg | string | package import path; defaults to the envelope’s pkg | |
text | string | yes | complete declaration source, including doc comment if any; the symbol name is parsed from it |
imports | []{path,name} | imports the declaration needs that goimports cannot infer: an aliased import or an ambiguous package name; name is the alias, empty for the default |
Example:
[
{
"op": "upsert_decl",
"pkg": "demo/lib",
"text": "// Double doubles v.\nfunc Double(v int) int {\n\treturn v + v\n}"
}
]
delete_decl
rejected while any non-declaring reference remains outside the declaration itself (a recursive self-call does not count), outside the batch, and outside spans earlier ops in the same patch rewrote; the diagnostics list where. A member of a grouped const/var/type block excises in place (iota and inherited-value members reject, position defines them); a method whose receiver type deletes in the same batch skips the reference guard, the end-of-list typecheck arbitrates
| arg | type | required | description |
|---|---|---|---|
pkg | string | package import path; defaults to the envelope’s pkg | |
sym | string | symbol: Name or Type.Member; defaults to the envelope’s sym | |
syms | []string | batch form: several symbols delete together, so intra-set references (a helper and the test that used it) do not block |
Example:
[
{
"op": "delete_decl",
"sym": "Unused"
}
]
set_doc
doc comment only; replaces an existing one rather than appending, and does not affect the typecheck surface
| arg | type | required | description |
|---|---|---|---|
pkg | string | package import path; defaults to the envelope’s pkg | |
sym | string | symbol: Name or Type.Member; defaults to the envelope’s sym | |
text | string | yes | doc comment body; each line is rendered with a “// “ prefix |
Example:
[
{
"op": "set_doc",
"sym": "Double",
"text": "Double doubles v."
}
]
add_field
appended to the struct’s field list; rejected if the name already exists
| arg | type | required | description |
|---|---|---|---|
pkg | string | package import path; defaults to the envelope’s pkg | |
sym | string | the struct type’s name; defaults to the envelope’s sym | |
name | string | yes | new field name |
type | string | yes | new field type |
tag | string | struct tag, without backticks |
Example:
[
{
"op": "add_field",
"sym": "Store",
"name": "Tag",
"type": "string"
}
]
remove_field
rejected while referenced. v1 ceiling: a field sharing a multi-name declaration (“a, b int”) or an embedded field is not supported
| arg | type | required | description |
|---|---|---|---|
pkg | string | package import path; defaults to the envelope’s pkg | |
sym | string | field: Type.Field; defaults to the envelope’s sym |
Example:
[
{
"op": "remove_field",
"sym": "Config.Legacy"
}
]
move_decl
relocates the whole declaration (doc comment included) and requalifies every reference, adding imports where needed; a type moves together with its whole method set, and one spec of a grouped const/var/type block extracts standalone. v1 ceilings: the declaration must be self-contained (no uses of its old package’s other top-level symbols) and grouped specs may not lean on iota or an inherited value; each rejects with the blocking names
| arg | type | required | description |
|---|---|---|---|
pkg | string | source package import path; defaults to the envelope’s pkg | |
sym | string | top-level declaration name; defaults to the envelope’s sym | |
syms | array | several declarations moving as one set (type + constructor + tests); intra-set references are legal; mutually exclusive with sym | |
to_pkg | string | yes | target package import path (must exist by the time this op runs; an earlier upsert_decl in the same patch can create it) |
create_pkg | boolean | create a missing module-local target package as part of this patch; without it a missing target rejects (typo safety) and offers this flag as a repair |
Example:
[
{
"op": "move_decl",
"pkg": "demo/sig",
"sym": "Fetch",
"to_pkg": "demo/lib"
}
]
set_signature
full parameter/result rewrite: parameters are matched to the old signature by name — carried ones keep each call site’s argument (reordering reorders them), dropped ones drop it, new ones take their default; underscore params pair positionally when their type matches, so widening func(ctx context.Context, _ DecryptFn) keeps the _ argument; a spread call site f(args…) survives insertions before the variadic. Value uses of the function and the body itself are not rewritten: repair them with sibling ops in the same patch or the end-of-list typecheck rejects with the site positions
| arg | type | required | description |
|---|---|---|---|
pkg | string | package import path; defaults to the envelope’s pkg | |
sym | string | symbol: Name or Type.Member; defaults to the envelope’s sym | |
signature | string | yes | the complete new signature as Go text: “(params) results” |
defaults | object | argument expression per NEW parameter name, spliced into every existing call site; required for any new parameter when call sites exist |
Example:
[
{
"op": "set_signature",
"pkg": "demo/sig",
"sym": "Fetch",
"signature": "(ctx context.Context, a int, b string, rest ...int) int",
"defaults": {
"ctx": "context.Background()"
}
}
]
delete_file
removes one file; rejected while any package-level symbol it declares is referenced from outside it (the rejection lists the reference positions). Deleting a package’s last file removes the package
| arg | type | required | description |
|---|---|---|---|
path | string | yes | file path relative to the module root (or absolute) |
Example:
[
{
"op": "upsert_decl",
"pkg": "demo/scratch",
"text": "func Tmp() int {\n\treturn 0\n}"
},
{
"op": "delete_file",
"path": "scratch/agent.go"
}
]
move_file
same-directory moves are pure renames; a cross-package move rewrites the package clause to the target package and drops a now-self import, and is rejected while the file declares symbols referenced from outside it (their qualifiers would all be wrong — use move_decl per declaration instead)
| arg | type | required | description |
|---|---|---|---|
from | string | yes | current file path relative to the module root (or absolute) |
to | string | yes | new file path; a different directory must hold an already-loaded package |
Example:
[
{
"op": "move_file",
"from": "lib/lib.go",
"to": "lib/core.go"
}
]
add_dependency
runs go get module@version against the workspace module; go.mod and go.sum restore byte-for-byte on any later rejection in the same patch. Needs the module in the local cache or network access
| arg | type | required | description |
|---|---|---|---|
module | string | yes | module path, e.g. golang.org/x/sync |
version | string | module version; defaults to latest |
Example:
[
{
"op": "add_dependency",
"module": "golang.org/x/sync",
"version": "v0.10.0"
}
]
remove_dependency
runs go get module@none; rejected while any workspace file still imports the module (the rejection lists the import positions)
| arg | type | required | description |
|---|---|---|---|
module | string | yes | module path to drop |
Example:
[
{
"op": "remove_dependency",
"module": "golang.org/x/sync"
}
]
mod_tidy
runs go mod tidy with the same go.mod/go.sum restore-and-validate wrapper as the other module ops
| arg | type | required | description |
|---|
Example:
[
{
"op": "mod_tidy"
}
]
add_assign
| arg | type | required | description |
|---|---|---|---|
at | string | yes | handle (or $N) to place the statement relative to |
where | string | yes | before | after | first | last (first/last need a block-owning handle) |
lhs | string | yes | assignment target identifier |
rhs | string | yes | right-hand-side expression, parsed and typechecked in scope |
define | bool | use := instead of = |
Example:
[
{
"op": "add_assign",
"at": "n2",
"where": "after",
"lhs": "_",
"rhs": "h(3)",
"define": false
}
]
add_call
| arg | type | required | description |
|---|---|---|---|
at | string | yes | handle (or $N) to place the statement relative to |
where | string | yes | before | after | first | last |
expr | string | yes | a call expression or channel receive (<-ch); assignments belong to add_assign |
Example:
[
{
"op": "add_call",
"at": "n2",
"where": "after",
"expr": "fmt.Println(h(1))"
}
]
add_return
arity and result types are checked against the enclosing signature at end-of-list typecheck, not here
| arg | type | required | description |
|---|---|---|---|
at | string | yes | handle (or $N) to place the statement relative to |
where | string | yes | before | after | first | last |
exprs | []string | result expressions, in order; omit for a bare “return” |
Example:
[
{
"op": "add_return",
"at": "n2",
"where": "after",
"exprs": [
"h(2)"
]
}
]
add_defer
| arg | type | required | description |
|---|---|---|---|
at | string | yes | handle (or $N) to place the statement relative to |
where | string | yes | before | after | first | last |
expr | string | yes | a call expression |
Example:
[
{
"op": "add_defer",
"at": "n1",
"where": "after",
"expr": "fmt.Println(\"done\")"
}
]
add_go
| arg | type | required | description |
|---|---|---|---|
at | string | yes | handle (or $N) to place the statement relative to |
where | string | yes | before | after | first | last |
expr | string | yes | a call expression |
Example:
[
{
"op": "add_go",
"at": "n2",
"where": "after",
"expr": "h(1)"
}
]
delete_node
a block-owning statement with children, or an if with an else, is rejected rather than silently discarding content — delete children first
| arg | type | required | description |
|---|---|---|---|
at | string | yes | handle of the statement or case clause to remove |
Example:
[
{
"op": "add_return",
"at": "n3",
"where": "after",
"exprs": [
"h(9)"
]
},
{
"op": "delete_node",
"at": "n3"
}
]
add_if
returns the new then-block’s own handle via $N; there is no v1 handle for a requested else block (view again to reach it)
| arg | type | required | description |
|---|---|---|---|
at | string | yes | handle (or $N) to place the statement relative to |
where | string | yes | before | after | first | last |
cond | string | yes | condition expression |
else | bool | also create an empty else block |
Example:
[
{
"op": "add_if",
"at": "n2",
"where": "after",
"cond": "h != nil",
"else": false
}
]
add_for
empty body, returns its handle via $N. v1 ceiling: no init/post clauses — use upsert_decl/set_body for a classic three-clause loop
| arg | type | required | description |
|---|---|---|---|
at | string | yes | handle (or $N) to place the statement relative to |
where | string | yes | before | after | first | last |
cond | string | condition expression; mutually exclusive with range | |
range | string | a full range clause, e.g. “k, v := range coll”; mutually exclusive with cond |
Example:
[
{
"op": "add_for",
"at": "n2",
"where": "after",
"cond": "h(0) \u003e 0"
}
]
add_switch
empty body; extend with add_case
| arg | type | required | description |
|---|---|---|---|
at | string | yes | handle (or $N) to place the statement relative to |
where | string | yes | before | after | first | last |
tag | string | switch tag expression; omit for a tagless switch |
Example:
[
{
"op": "add_switch",
"at": "n2",
"where": "after",
"tag": "h(1)"
}
]
add_case
always appends as the last clause (v1 has no argument for placing a case among existing ones); returns the new case’s body handle via $N
| arg | type | required | description |
|---|---|---|---|
at | string | yes | handle (or $N) of the switch statement to extend |
exprs | []string | case expressions; mutually exclusive with default | |
default | bool | make this the default clause; mutually exclusive with exprs |
Example:
[
{
"op": "add_switch",
"at": "n2",
"where": "after",
"tag": "h(1)"
},
{
"op": "add_case",
"at": "$1",
"exprs": [
"1",
"2"
]
}
]
set_cond
a case clause’s whole expression list is replaced as one; v1 has no per-element case-expr addressing
| arg | type | required | description |
|---|---|---|---|
at | string | yes | handle (or $N) of the if/for/case to retarget |
expr | string | yes | replacement condition |
Example:
[
{
"op": "add_if",
"at": "n2",
"where": "after",
"cond": "h == nil"
},
{
"op": "set_cond",
"at": "$1",
"expr": "h != nil"
}
]
replace_expr
v1 ceiling: an if/for/case condition or a whole expression statement only; per-argument sub-expression handles are future work
| arg | type | required | description |
|---|---|---|---|
at | string | yes | handle (or $N) of the target node |
expr | string | yes | replacement expression |
Example:
[
{
"op": "add_call",
"at": "n2",
"where": "after",
"expr": "h(1)"
},
{
"op": "replace_expr",
"at": "$1",
"expr": "h(2)"
}
]
wrap_stmts
from/to must be direct siblings, in order, of the same statement list; returns the new node’s handle via $N
| arg | type | required | description |
|---|---|---|---|
from | string | yes | handle (or $N) of the first statement to enclose |
to | string | yes | handle (or $N) of the last statement to enclose |
with | string | yes | if | for | block |
cond | string | condition; required for with=if/for, forbidden for with=block |
Example:
[
{
"op": "wrap_stmts",
"from": "n1",
"to": "n2",
"with": "if",
"cond": "helper(1) \u003e 0"
}
]
wrap_error
the Go idiom automated end to end: binds err, inserts “if err != nil { return …, fmt.Errorf(…) }”. v1 ceiling: a bare expression-statement call resolves its return arity only for a same-package function identifier
| arg | type | required | description |
|---|---|---|---|
at | string | yes | handle (or $N) of the assignment or expression-statement call to wrap |
message | string | yes | context prefix for fmt.Errorf(“…: %w”, err) |
Example:
[
{
"op": "wrap_error",
"at": "n1",
"message": "fetch"
}
]
add_test
scaffolds a table-driven test: case struct derived from the target’s signature, rows slice, range+t.Run loop. v1 targets a plain function, not a method; address the generated test by name in follow-up ops
| arg | type | required | description |
|---|---|---|---|
pkg | string | package import path; defaults to the envelope’s pkg | |
target | string | yes | the function under test; defaults to the envelope’s sym when omitted |
name | string | test function name; defaults to Test<Target> |
Example:
[
{
"op": "add_test",
"target": "Double"
}
]
add_test_case
values are expression atoms, typechecked against the case struct at end-of-list typecheck
| arg | type | required | description |
|---|---|---|---|
pkg | string | package import path; defaults to the envelope’s pkg | |
test | string | test function name (from add_test); defaults to the envelope’s sym | |
name | string | yes | case row name |
args | []string | argument expressions, in target parameter order | |
want | []string | expected-result expressions, in result order (wantErr last, if the target returns error) |
Example:
[
{
"op": "add_test",
"target": "Double"
},
{
"op": "add_test_case",
"test": "TestDouble",
"name": "positive",
"args": [
"2"
],
"want": [
"4"
]
}
]
set_test_case
| arg | type | required | description |
|---|---|---|---|
pkg | string | package import path; defaults to the envelope’s pkg | |
test | string | test function name; defaults to the envelope’s sym | |
case | string | yes | existing row’s current name |
name | string | new row name; defaults to case (no rename) | |
args | []string | replacement argument expressions | |
want | []string | replacement expected-result expressions |
Example:
[
{
"op": "set_test_case",
"test": "TestScale",
"case": "one",
"args": [
"3",
"3"
],
"want": [
"9"
]
}
]
remove_test_case
| arg | type | required | description |
|---|---|---|---|
pkg | string | package import path; defaults to the envelope’s pkg | |
test | string | test function name; defaults to the envelope’s sym | |
case | string | yes | row name to remove |
Example:
[
{
"op": "remove_test_case",
"test": "TestScale",
"case": "one"
}
]