From e6f91cca9840137a82a25284c89e4bdfcc3c9066 Mon Sep 17 00:00:00 2001 From: samzong Date: Thu, 2 Jul 2026 03:43:25 -0400 Subject: [PATCH 1/3] docs: add machine contract index Index the versioned machine-readable contracts that generated CLIs, agents, and sibling tooling integrate through: generated code schema, command catalog, verify report, structured errors and exit codes, and the pinned durable inputs. New tools and capabilities should name the contract they consume or produce before touching Lathe internals. Co-Authored-By: Claude Fable 5 Signed-off-by: samzong --- docs/contracts.md | 80 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 docs/contracts.md diff --git a/docs/contracts.md b/docs/contracts.md new file mode 100644 index 0000000..4258b8c --- /dev/null +++ b/docs/contracts.md @@ -0,0 +1,80 @@ +# Machine Contracts + +Lathe's durable value is a small set of versioned, machine-readable contracts. +Generated CLIs, agents, and sibling tooling (installers, verifiers, registries) +integrate through these contracts, not through Lathe internals. Any new tool or +capability should answer one question first: which contract does it consume or +produce, and how is that contract versioned? + +Code is the source of truth. Each section below points at the defining source +file; when this page and the code disagree, trust the code and fix this page. + +## Generated code schema + +- Version constant: `runtime.SchemaVersion` in `pkg/runtime/spec.go`. +- Couples generated `[]runtime.CommandSpec` literals + (`internal/generated//_gen.go` in downstream repos) to the + runtime that executes them. +- Enforced by `runtime.AssertSchema` at mount time: a mismatch fails fast at + startup with an instruction to re-run codegen. +- Bump the constant whenever `CommandSpec` semantics or the generated mount + contract change in a way that requires regeneration. + +## Command catalog + +- Version field: `catalog_schema_version`, constant + `runtime.CatalogSchemaVersion` in `pkg/runtime/catalog.go`. +- Served by ` commands --json`, `commands show --json`, + `search "" --json`, and `commands schema --json`. +- This is the agent-facing discovery contract and the source of truth for + generated operation details: HTTP method and path template, auth + requirements, flags, body schema, output and pagination hints. +- Only generated API operation commands carry catalog entries. Framework + commands (`auth`, `commands`, `search`, `update`, `__lathe`) are discovered + through `--help`, not the catalog. +- Consumers: agents following the documented loop (search, then + `commands show`, then `auth status`, then execute), generated Skill files + (guidance and indexes only, never execution authority), and external + conformance tooling. + +## Verify report + +- Emitted by ` __lathe verify --json`; implemented in + `pkg/lathe/verify.go`. +- Shape: `{"ok": bool, "checks": [{"name": string, "ok": bool, "error": + string}]}`. The process exits non-zero when any check fails. +- This is the generated CLI's self-evidence: root help contract, catalog + schema and JSON round-trip, per-command flag consistency, and an isolated + unauthenticated `auth status` probe. +- The report is not independently versioned today; treat any change to its + shape or check names as a contract change and document it here. + +## Structured errors and exit codes + +- Defined in `pkg/runtime/errors.go`. +- Error codes: `general`, `usage`, `api_error`, `not_authenticated`. +- Exit codes: `0` OK, `1` general, `2` usage, `3` API error, + `4` not authenticated. +- Consumers: agents and scripts that branch on failure classes instead of + parsing prose. + +## Durable inputs + +- `cli.yaml`, parsed by `pkg/config.Load`, plus codegen-only keys + (`skill.root`, `skill.include`) parsed in `internal/lathecmd`. +- `specs/sources.yaml`, parsed by `internal/sourceconfig`, pinning upstream + spec refs per module. +- Optional overlay files, parsed by `internal/overlay`, merged at codegen time + only. +- Together these are the reproducibility contract: generated output must be + reproducible from these pinned inputs. Overlay concepts never reach the + runtime. + +## Rules + +- The runtime catalog is authoritative for operation details; generated Skill + files are guidance. +- Search results are discovery only; agents must inspect a command via + `commands show` before executing it. +- A change to any surface above is product behavior: prove it with focused + tests and treat the version fields as the compatibility gate. From d8a1851af68a4ab1496771ef552b5af62d08357e Mon Sep 17 00:00:00 2001 From: samzong Date: Thu, 2 Jul 2026 03:45:29 -0400 Subject: [PATCH 2/3] refactor(codegen): collect generated outputs into an app model before writing Introduce internal/codegen/app.App as the model of one codegen run: runCodegen now parses, normalizes, and validates every configured source into the app first, then writes all outputs in one pass. A failing source no longer leaves earlier modules partially written. The model is internal to codegen and not a stable extension API. Generated output is byte-identical: regenerating examples/petstore with the previous binary and this one produces no diff. Co-Authored-By: Claude Fable 5 Signed-off-by: samzong --- internal/codegen/app/app.go | 51 +++++++++++++++++++++++++++++++++++ internal/lathecmd/lathecmd.go | 47 +++++++++++++++++--------------- 2 files changed, 76 insertions(+), 22 deletions(-) create mode 100644 internal/codegen/app/app.go diff --git a/internal/codegen/app/app.go b/internal/codegen/app/app.go new file mode 100644 index 0000000..003fcbd --- /dev/null +++ b/internal/codegen/app/app.go @@ -0,0 +1,51 @@ +// Package app models the generated CLI application that one codegen run is +// about to emit. Outputs are collected into an App first and written together, +// so a failing input never leaves partially generated output behind. The model +// is internal to codegen; it is not a stable extension API. +package app + +import ( + "github.com/lathe-cli/lathe/internal/codegen/render" + "github.com/lathe-cli/lathe/pkg/config" + "github.com/lathe-cli/lathe/pkg/runtime" +) + +// App is the complete set of generated outputs for one codegen run. +type App struct { + Manifest *config.Manifest + Modules []Module + Skill *Skill +} + +// Module is one generated command module and how it mounts on the root command. +type Module struct { + Source string + CLIName string + Flat bool + Specs []runtime.CommandSpec +} + +// Skill is the optional generated Skill directory output. +type Skill struct { + Dir string + Include render.SkillInclude + Modules []render.SkillModule +} + +// Write renders every collected output. +func (a *App) Write() error { + mounts := make([]render.ModuleMount, 0, len(a.Modules)) + for _, m := range a.Modules { + if err := render.RenderModule(m.Source, m.CLIName, m.Specs, nil); err != nil { + return err + } + mounts = append(mounts, render.ModuleMount{Name: m.Source, Flat: m.Flat}) + } + if err := render.RenderModulesGen(mounts); err != nil { + return err + } + if a.Skill == nil { + return nil + } + return render.RenderSkillDirectoryWithInclude(a.Skill.Dir, a.Manifest, a.Skill.Modules, a.Skill.Include) +} diff --git a/internal/lathecmd/lathecmd.go b/internal/lathecmd/lathecmd.go index 362a4d3..1db9b98 100644 --- a/internal/lathecmd/lathecmd.go +++ b/internal/lathecmd/lathecmd.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" + "github.com/lathe-cli/lathe/internal/codegen/app" "github.com/lathe-cli/lathe/internal/codegen/backends/graphql" "github.com/lathe-cli/lathe/internal/codegen/backends/openapi3" "github.com/lathe-cli/lathe/internal/codegen/backends/proto" @@ -163,6 +164,21 @@ func runCodegen(sourcesPath string, manifestPath string, cacheRoot string, overl return err } + generated, err := buildGeneratedApp(cfg, overlays, syncRoot, manifest, skillDir, skillInclude) + if err != nil { + return err + } + return generated.Write() +} + +// buildGeneratedApp parses and normalizes every configured source into the +// generated app model without writing any output. +func buildGeneratedApp(cfg *sourceconfig.Config, overlays map[string]overlay.Module, syncRoot string, manifest *config.Manifest, skillDir string, skillInclude render.SkillInclude) (*app.App, error) { + generated := &app.App{Manifest: manifest} + if skillDir != "" { + generated.Skill = &app.Skill{Dir: skillDir, Include: skillInclude} + } + ordered := cfg.Ordered() moduleNames := make([]string, 0, len(ordered)) for _, src := range ordered { @@ -172,22 +188,20 @@ func runCodegen(sourcesPath string, manifestPath string, cacheRoot string, overl } moduleNames = append(moduleNames, name) } - var mounts []render.ModuleMount - var skillModules []render.SkillModule var shortcutRootNames []string for i, src := range ordered { syncDir := filepath.Join(syncRoot, src.Name) if err := specsync.VerifyState(syncDir, src); err != nil { - return err + return nil, err } state, err := specsync.LoadState(syncDir) if err != nil { - return err + return nil, err } mod, err := parseSource(src, syncDir) if err != nil { - return err + return nil, err } specs := normalize.Normalize(mod) @@ -200,11 +214,11 @@ func runCodegen(sourcesPath string, manifestPath string, cacheRoot string, overl cliName := moduleNames[i] flat, err := render.ResolveFlatCommandPath(manifest.CLI.CommandPath, len(ordered), specs) if err != nil { - return err + return nil, err } validateRootNames := append(append([]string(nil), moduleNames...), shortcutRootNames...) if err := render.ValidateShortcuts(validateRootNames, specs, flat); err != nil { - return err + return nil, err } for _, spec := range specs { for _, shortcut := range spec.Shortcuts { @@ -212,23 +226,12 @@ func runCodegen(sourcesPath string, manifestPath string, cacheRoot string, overl } } specs = render.RewriteCommandExamples(manifest.CLI.Name, cliName, specs, flat) - if err := render.RenderModule(src.Name, cliName, specs, nil); err != nil { - return err - } - mounts = append(mounts, render.ModuleMount{Name: src.Name, Flat: flat}) - if skillDir != "" { - skillModules = append(skillModules, render.SkillModule{Source: src, State: state, Specs: specs}) - } - } - if err := render.RenderModulesGen(mounts); err != nil { - return err - } - if skillDir != "" { - if err := render.RenderSkillDirectoryWithInclude(skillDir, manifest, skillModules, skillInclude); err != nil { - return err + generated.Modules = append(generated.Modules, app.Module{Source: src.Name, CLIName: cliName, Flat: flat, Specs: specs}) + if generated.Skill != nil { + generated.Skill.Modules = append(generated.Skill.Modules, render.SkillModule{Source: src, State: state, Specs: specs}) } } - return nil + return generated, nil } func resolveSkillOutput(manifestPath string, flags skillFlagOptions) (*config.Manifest, string, render.SkillInclude, error) { From e002cf0cef678fe5cad4ab0b64f6e62d75940ef5 Mon Sep 17 00:00:00 2001 From: samzong Date: Thu, 2 Jul 2026 03:50:37 -0400 Subject: [PATCH 3/3] fix(codegen): reject module names that shadow reserved root commands Namespaced module mounts previously reached the root command without any conflict check: a source named auth, search, or login silently mounted a duplicate root command over the runtime's own. Codegen now validates namespaced module names against reserved root commands and duplicate mounts before writing any output, and runtime.Build refuses to mount a module whose name collides with an existing root command, matching the check BuildFlat already had. runtime.Build now returns an error instead of panicking, and the generated Mount function propagates it. SchemaVersion is bumped to 9 so stale generated code fails fast with a re-run codegen instruction instead of silently discarding mount errors. The reserved list also gains login, which NewHiddenLoginCommand mounts at the root. Verified with make check, then regenerated examples/petstore, built it, and ran __lathe verify --json (ok true, catalog schema intact). Co-Authored-By: Claude Fable 5 Signed-off-by: samzong --- internal/codegen/app/app.go | 14 ++++++++ internal/codegen/render/render.go | 20 +++++++++-- internal/codegen/render/render_test.go | 17 ++++++++++ internal/lathecmd/lathecmd.go | 3 ++ internal/lathecmd/lathecmd_test.go | 39 ++++++++++++++++++++++ pkg/lathe/catalog_test.go | 15 ++++++--- pkg/lathe/verify_test.go | 6 ++-- pkg/runtime/build.go | 11 +++--- pkg/runtime/build_test.go | 46 ++++++++++++++++++-------- pkg/runtime/catalog_test.go | 16 ++++----- pkg/runtime/spec.go | 2 +- 11 files changed, 154 insertions(+), 35 deletions(-) diff --git a/internal/codegen/app/app.go b/internal/codegen/app/app.go index 003fcbd..827d5d5 100644 --- a/internal/codegen/app/app.go +++ b/internal/codegen/app/app.go @@ -32,6 +32,20 @@ type Skill struct { Modules []render.SkillModule } +// Validate rejects app compositions that would produce a conflicting root +// command tree. Flat modules are skipped: their module name is never mounted, +// and flat root conflicts are rejected by ResolveFlatCommandPath. +func (a *App) Validate() error { + names := make([]string, 0, len(a.Modules)) + for _, m := range a.Modules { + if m.Flat { + continue + } + names = append(names, m.CLIName) + } + return render.ValidateModuleNames(names) +} + // Write renders every collected output. func (a *App) Write() error { mounts := make([]render.ModuleMount, 0, len(a.Modules)) diff --git a/internal/codegen/render/render.go b/internal/codegen/render/render.go index c4a9e0d..ea666c7 100644 --- a/internal/codegen/render/render.go +++ b/internal/codegen/render/render.go @@ -188,10 +188,27 @@ var reservedRootCommands = map[string]bool{ "auth": true, "commands": true, "help": true, + "login": true, "search": true, "update": true, } +// ValidateModuleNames rejects namespaced module mount names that would shadow +// a reserved root command or another module on the generated root command. +func ValidateModuleNames(names []string) error { + seen := map[string]bool{} + for _, name := range names { + if reservedRootCommands[name] { + return fmt.Errorf("module name %q conflicts with a reserved root command", name) + } + if seen[name] { + return fmt.Errorf("module name %q is mounted more than once", name) + } + seen[name] = true + } + return nil +} + func MergeOverlay(specs []runtime.CommandSpec, overrides map[string]overlay.Override) []runtime.CommandSpec { return MergeOverlayModule(specs, overlay.Module{Commands: overrides}) } @@ -537,8 +554,7 @@ func Mount(root *cobra.Command) error { if err := runtime.AssertSchema(generatedSchemaVersion); err != nil { return err } - runtime.Build(root, {{printf "%q" .CLIName}}, Specs) - return nil + return runtime.Build(root, {{printf "%q" .CLIName}}, Specs) } func MountFlat(root *cobra.Command) error { diff --git a/internal/codegen/render/render_test.go b/internal/codegen/render/render_test.go index 0967259..38809e4 100644 --- a/internal/codegen/render/render_test.go +++ b/internal/codegen/render/render_test.go @@ -102,6 +102,7 @@ func TestRenderModule_AppliesOverlay(t *testing.T) { `func Mount(root *cobra.Command) error`, `if err := runtime.AssertSchema(generatedSchemaVersion); err != nil`, `return err`, + `return runtime.Build(root, "demo", Specs)`, `Schema:`, `&runtime.SchemaSpec{`, `Properties: map[string]*runtime.SchemaSpec`, @@ -595,3 +596,19 @@ func TestRewriteCommandExamples_NormalizesMultiWordGroupPaths(t *testing.T) { t.Fatalf("namespaced structured example = %q", got[0].Examples[0].Command) } } + +func TestValidateModuleNames(t *testing.T) { + if err := ValidateModuleNames([]string{"pets", "billing"}); err != nil { + t.Fatalf("distinct module names should pass: %v", err) + } + for _, reserved := range []string{"__lathe", "auth", "commands", "help", "login", "search", "update"} { + err := ValidateModuleNames([]string{"pets", reserved}) + if err == nil || !strings.Contains(err.Error(), "reserved root command") { + t.Fatalf("module name %q should be rejected, got %v", reserved, err) + } + } + err := ValidateModuleNames([]string{"pets", "pets"}) + if err == nil || !strings.Contains(err.Error(), "mounted more than once") { + t.Fatalf("duplicate module names should be rejected, got %v", err) + } +} diff --git a/internal/lathecmd/lathecmd.go b/internal/lathecmd/lathecmd.go index 1db9b98..a1addf4 100644 --- a/internal/lathecmd/lathecmd.go +++ b/internal/lathecmd/lathecmd.go @@ -168,6 +168,9 @@ func runCodegen(sourcesPath string, manifestPath string, cacheRoot string, overl if err != nil { return err } + if err := generated.Validate(); err != nil { + return err + } return generated.Write() } diff --git a/internal/lathecmd/lathecmd_test.go b/internal/lathecmd/lathecmd_test.go index 476cf0f..5af98d7 100644 --- a/internal/lathecmd/lathecmd_test.go +++ b/internal/lathecmd/lathecmd_test.go @@ -479,6 +479,45 @@ paths: } } +func TestRunCodegen_RejectsReservedModuleNameBeforeWriting(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + writeCodegenFile(t, "go.mod", "module example.com/fake\n\ngo 1.25\n") + writeCodegenFile(t, "cli.yaml", "cli:\n name: acmectl\n short: Acme CLI\n command_path: namespaced\n") + writeCodegenFile(t, "specs/sources.yaml", `sources: + auth: + repo_url: https://example.com/acme.git + pinned_tag: v1.0.0 + backend: openapi3 + openapi3: + files: [openapi.yaml] +`) + writeCodegenFile(t, ".cache/specs-sync/auth/sync-state.yaml", `source: auth +backend: openapi3 +synced_from: v1.0.0 +resolved_sha: "0000000000000000000000000000000000000000" +`) + writeCodegenFile(t, ".cache/specs-sync/auth/openapi.yaml", `openapi: "3.0.3" +paths: + /users: + get: + operationId: Users_List + tags: [Users] + summary: List users + responses: + "200": + description: OK +`) + + err := RunCodegen([]string{"-sources", "specs/sources.yaml", "-cache", ".cache"}, &bytes.Buffer{}) + if err == nil || !strings.Contains(err.Error(), "reserved root command") { + t.Fatalf("expected reserved module name error, got %v", err) + } + if _, err := os.Stat("internal/generated"); !os.IsNotExist(err) { + t.Fatalf("codegen should fail before writing generated code, stat err = %v", err) + } +} + func TestRunCodegen_MissingManifestFailsWhenSkillEnabled(t *testing.T) { dir := t.TempDir() t.Chdir(dir) diff --git a/pkg/lathe/catalog_test.go b/pkg/lathe/catalog_test.go index d3f6c3e..f142ea8 100644 --- a/pkg/lathe/catalog_test.go +++ b/pkg/lathe/catalog_test.go @@ -12,6 +12,13 @@ import ( "github.com/lathe-cli/lathe/pkg/runtime" ) +func mustBuild(t *testing.T, root *cobra.Command, service string, specs []runtime.CommandSpec) { + t.Helper() + if err := runtime.Build(root, service, specs); err != nil { + t.Fatalf("Build(%q): %v", service, err) + } +} + func TestCommandsJSON_EmptyCatalog(t *testing.T) { root := NewApp(testManifest()) out, err := execute(root, "commands", "--json") @@ -32,7 +39,7 @@ func TestCommandsJSON_EmptyCatalog(t *testing.T) { func TestCommandsShowAndSearchJSON(t *testing.T) { root := NewApp(testManifest()) - runtime.Build(root, "demo", []runtime.CommandSpec{{ + mustBuild(t, root, "demo", []runtime.CommandSpec{{ Group: "Users", Use: "get-user", Short: "Get a user", @@ -96,7 +103,7 @@ func TestCommandsShowAndSearchJSON(t *testing.T) { func TestCommandsShow_EnvelopeBody(t *testing.T) { root := NewApp(testManifest()) const tmpl = `{"query":"mutation CreateApp($name:String!){createApp(name:$name){id}}","variables":{}}` - runtime.Build(root, "demo", []runtime.CommandSpec{{ + mustBuild(t, root, "demo", []runtime.CommandSpec{{ Group: "Apps", Use: "create-app", Short: "Create an app", @@ -130,7 +137,7 @@ func TestCommandsShow_EnvelopeBody(t *testing.T) { func TestCommandsShow_EnvelopeVariableFlag(t *testing.T) { root := NewApp(testManifest()) const tmpl = `{"query":"mutation createApp($name: String!){createApp(name:$name){id}}","variables":{}}` - runtime.Build(root, "demo", []runtime.CommandSpec{{ + mustBuild(t, root, "demo", []runtime.CommandSpec{{ Group: "Apps", Use: "create-app", Short: "Create an app", @@ -184,7 +191,7 @@ func TestCommandsSchemaJSON(t *testing.T) { func TestSearchExcludesHiddenCommands(t *testing.T) { root := NewApp(testManifest()) - runtime.Build(root, "demo", []runtime.CommandSpec{{ + mustBuild(t, root, "demo", []runtime.CommandSpec{{ Group: "Users", Use: "delete-user", Short: "Delete a user", diff --git a/pkg/lathe/verify_test.go b/pkg/lathe/verify_test.go index 96c9731..8a4e67e 100644 --- a/pkg/lathe/verify_test.go +++ b/pkg/lathe/verify_test.go @@ -16,7 +16,7 @@ func TestRunVerifyGeneratedJSON(t *testing.T) { code := run(RunOptions{ Manifest: []byte("cli:\n name: myctl\n short: test cli\n"), Mount: func(root *cobra.Command) error { - runtime.Build(root, "demo", []runtime.CommandSpec{ + if err := runtime.Build(root, "demo", []runtime.CommandSpec{ { Group: "Users", Use: "get-user", @@ -39,7 +39,9 @@ func TestRunVerifyGeneratedJSON(t *testing.T) { PathTpl: "/users", RequestBody: &runtime.RequestBody{Required: true, MediaType: "application/json"}, }, - }) + }); err != nil { + return err + } skills := &cobra.Command{Use: "skills"} pkg := &cobra.Command{Use: "package"} pkg.Flags().String("file", "", "") diff --git a/pkg/runtime/build.go b/pkg/runtime/build.go index 1f94141..1869577 100644 --- a/pkg/runtime/build.go +++ b/pkg/runtime/build.go @@ -30,18 +30,19 @@ func AssertSchema(generated int) error { // Build mounts a service command tree under root, driven entirely by specs. // Replaces the previous per-operation function approach: every operation is // data, the execution path is a single function. -func Build(root *cobra.Command, service string, specs []CommandSpec) { +func Build(root *cobra.Command, service string, specs []CommandSpec) error { + if findChildCommand(root, service) != nil { + return fmt.Errorf("module command %q conflicts with an existing root command", service) + } svc := &cobra.Command{Use: service, Short: service + " API", GroupID: ModuleGroupID} for _, group := range buildGroups(service, specs) { svc.AddCommand(group) } if err := ValidateShortcuts(specs, rootCommandNames(root, svc)); err != nil { - panic(err) + return err } root.AddCommand(svc) - if err := mountShortcuts(root, specs); err != nil { - panic(err) - } + return mountShortcuts(root, specs) } func BuildFlat(root *cobra.Command, service string, specs []CommandSpec) error { diff --git a/pkg/runtime/build_test.go b/pkg/runtime/build_test.go index e98bd54..85b06f8 100644 --- a/pkg/runtime/build_test.go +++ b/pkg/runtime/build_test.go @@ -20,6 +20,26 @@ func newRootWithModuleGroup() *cobra.Command { return root } +func mustBuild(t *testing.T, root *cobra.Command, service string, specs []CommandSpec) { + t.Helper() + if err := Build(root, service, specs); err != nil { + t.Fatalf("Build(%q): %v", service, err) + } +} + +func TestBuild_RejectsExistingRootCommandConflict(t *testing.T) { + root := newRootWithModuleGroup() + root.AddCommand(&cobra.Command{Use: "auth"}) + + err := Build(root, "auth", nil) + if err == nil || !strings.Contains(err.Error(), `module command "auth" conflicts`) { + t.Fatalf("expected root conflict error, got %v", err) + } + if len(root.Commands()) != 1 { + t.Fatalf("conflicting module must not be mounted; root commands = %v", cmdNames(root.Commands())) + } +} + func TestBuild_PopulatesGroupAndOpTree(t *testing.T) { specs := []CommandSpec{ { @@ -46,7 +66,7 @@ func TestBuild_PopulatesGroupAndOpTree(t *testing.T) { } root := newRootWithModuleGroup() - Build(root, "demo", specs) + mustBuild(t, root, "demo", specs) svc := mustFindChild(t, root, "demo") usersGroup := mustFindChild(t, svc, "users") @@ -97,7 +117,7 @@ func TestAssertSchema_Mismatch(t *testing.T) { func TestBuild_EmptySpecsMountsEmptyService(t *testing.T) { root := newRootWithModuleGroup() - Build(root, "demo", nil) + mustBuild(t, root, "demo", nil) svc := mustFindChild(t, root, "demo") if len(svc.Commands()) != 0 { @@ -170,7 +190,7 @@ func TestBuild_BodyFlagsAttachedWhenHasBody(t *testing.T) { }} root := newRootWithModuleGroup() - Build(root, "demo", specs) + mustBuild(t, root, "demo", specs) svc := mustFindChild(t, root, "demo") users := mustFindChild(t, svc, "users") @@ -211,7 +231,7 @@ func TestBuild_SetStrSendsStringBodyFields(t *testing.T) { root := newRootWithModuleGroup() root.PersistentFlags().String("hostname", "", "") root.PersistentFlags().StringP("output", "o", "raw", "") - Build(root, "demo", specs) + mustBuild(t, root, "demo", specs) root.SetArgs([]string{ "--hostname", srv.URL, "demo", "users", "create-user", @@ -264,7 +284,7 @@ func TestBuild_FileSendsRequestBodyMediaType(t *testing.T) { root := newRootWithModuleGroup() root.PersistentFlags().String("hostname", "", "") root.PersistentFlags().StringP("output", "o", "raw", "") - Build(root, "demo", []CommandSpec{{ + mustBuild(t, root, "demo", []CommandSpec{{ Group: "Exports", Use: "create-export", Method: "POST", @@ -293,7 +313,7 @@ func TestBuild_NonJSONRequestBodyRequiresFile(t *testing.T) { root := newRootWithModuleGroup() root.PersistentFlags().String("hostname", "", "") root.PersistentFlags().StringP("output", "o", "raw", "") - Build(root, "demo", []CommandSpec{{ + mustBuild(t, root, "demo", []CommandSpec{{ Group: "Exports", Use: "create-export", Method: "POST", @@ -327,7 +347,7 @@ func TestBuild_DryRunPrintsResolvedRequestWithoutSending(t *testing.T) { root.SetErr(io.Discard) root.PersistentFlags().String("hostname", "", "") root.PersistentFlags().StringP("output", "o", "raw", "") - Build(root, "demo", []CommandSpec{{ + mustBuild(t, root, "demo", []CommandSpec{{ Group: "Users", Use: "create-user", Method: "POST", @@ -598,7 +618,7 @@ func newRecordingGraphQLRoot(t *testing.T, spec CommandSpec) (*cobra.Command, st root := newRootWithModuleGroup() root.PersistentFlags().String("hostname", "", "") root.PersistentFlags().StringP("output", "o", "raw", "") - Build(root, "demo", []CommandSpec{spec}) + mustBuild(t, root, "demo", []CommandSpec{spec}) return root, srv.URL, func() ([]byte, bool) { return rawBody, called } } @@ -634,7 +654,7 @@ func TestBuild_FloatVariableSentAsJSONNumber(t *testing.T) { root := newRootWithModuleGroup() root.PersistentFlags().String("hostname", "", "") root.PersistentFlags().StringP("output", "o", "raw", "") - Build(root, "demo", specs) + mustBuild(t, root, "demo", specs) root.SetArgs([]string{"--hostname", srv.URL, "demo", "apps", "set-weight", "--weight", "1.5"}) if err := root.Execute(); err != nil { t.Fatalf("Execute: %v", err) @@ -682,7 +702,7 @@ func TestBuild_IntListVariableSentAsJSONNumbers(t *testing.T) { root := newRootWithModuleGroup() root.PersistentFlags().String("hostname", "", "") root.PersistentFlags().StringP("output", "o", "raw", "") - Build(root, "demo", specs) + mustBuild(t, root, "demo", specs) root.SetArgs([]string{"--hostname", srv.URL, "demo", "apps", "set-ids", "--ids", "1", "--ids", "2"}) if err := root.Execute(); err != nil { t.Fatalf("Execute: %v", err) @@ -725,7 +745,7 @@ func TestBuild_RequiredQueryParamBlocksBeforeRequest(t *testing.T) { root := newRootWithModuleGroup() root.PersistentFlags().String("hostname", "", "") root.PersistentFlags().StringP("output", "o", "raw", "") - Build(root, "demo", specs) + mustBuild(t, root, "demo", specs) root.SetArgs([]string{"--hostname", srv.URL, "demo", "receivers", "get-receiver"}) err := root.Execute() @@ -752,7 +772,7 @@ func TestBuild_PaginationFlagsAttached(t *testing.T) { }} root := newRootWithModuleGroup() - Build(root, "demo", specs) + mustBuild(t, root, "demo", specs) svc := mustFindChild(t, root, "demo") items := mustFindChild(t, svc, "items") @@ -772,7 +792,7 @@ func TestBuild_WaitFlagOnMutating(t *testing.T) { } root := newRootWithModuleGroup() - Build(root, "demo", specs) + mustBuild(t, root, "demo", specs) svc := mustFindChild(t, root, "demo") resources := mustFindChild(t, svc, "resources") diff --git a/pkg/runtime/catalog_test.go b/pkg/runtime/catalog_test.go index 507b445..9ac3fac 100644 --- a/pkg/runtime/catalog_test.go +++ b/pkg/runtime/catalog_test.go @@ -9,7 +9,7 @@ import ( func TestBuildCatalog_UsesAttachedSpec(t *testing.T) { root := newRootWithModuleGroup() - Build(root, "demo", []CommandSpec{ + mustBuild(t, root, "demo", []CommandSpec{ { Group: "Users", Use: "get-user", @@ -138,7 +138,7 @@ func TestBuildCatalog_UsesAttachedSpec(t *testing.T) { func TestBuildCatalog_ProjectsLegacyExample(t *testing.T) { root := newRootWithModuleGroup() - Build(root, "demo", []CommandSpec{{ + mustBuild(t, root, "demo", []CommandSpec{{ Group: "Users", Use: "get-user", Short: "Get a user", @@ -163,7 +163,7 @@ func TestBuildCatalog_ProjectsLegacyExample(t *testing.T) { func TestBuildCatalog_RequestBodyEnvelope(t *testing.T) { root := newRootWithModuleGroup() const tmpl = `{"query":"mutation CreateApp($name:String!){createApp(name:$name){id}}","variables":{}}` - Build(root, "demo", []CommandSpec{{ + mustBuild(t, root, "demo", []CommandSpec{{ Group: "Apps", Use: "create-app", Short: "Create an app", @@ -216,7 +216,7 @@ func TestBuildCatalog_RequestBodyEnvelope(t *testing.T) { func TestBuildCatalog_SensitiveFlagInputModes(t *testing.T) { root := newRootWithModuleGroup() - Build(root, "demo", []CommandSpec{{ + mustBuild(t, root, "demo", []CommandSpec{{ Group: "Credentials", Use: "create-credential", Short: "Create credential", @@ -244,7 +244,7 @@ func TestBuildCatalog_SensitiveFlagInputModes(t *testing.T) { func TestBuildCatalog_HiddenCommands(t *testing.T) { root := newRootWithModuleGroup() - Build(root, "demo", []CommandSpec{ + mustBuild(t, root, "demo", []CommandSpec{ {Group: "Users", Use: "get-user", Short: "Get a user", Method: "GET", PathTpl: "/users/{id}"}, {Group: "Users", Use: "delete-user", Short: "Delete a user", Method: "DELETE", PathTpl: "/users/{id}", Hidden: true}, }) @@ -261,7 +261,7 @@ func TestBuildCatalog_HiddenCommands(t *testing.T) { func TestFindAndSearchCatalog(t *testing.T) { root := newRootWithModuleGroup() - Build(root, "demo", []CommandSpec{ + mustBuild(t, root, "demo", []CommandSpec{ { Group: "Users", Use: "get-user", @@ -309,7 +309,7 @@ func TestFindAndSearchCatalog(t *testing.T) { func TestSearchCatalog_SoftMatchesNoisyIntent(t *testing.T) { root := newRootWithModuleGroup() - Build(root, "demo", []CommandSpec{ + mustBuild(t, root, "demo", []CommandSpec{ { Group: "Users", Use: "get-user", @@ -347,7 +347,7 @@ func TestSearchCatalog_SoftMatchesNoisyIntent(t *testing.T) { func TestBuildCatalog_DefaultAuthRequired(t *testing.T) { root := newRootWithModuleGroup() - Build(root, "demo", []CommandSpec{{ + mustBuild(t, root, "demo", []CommandSpec{{ Group: "Users", Use: "get-user", Short: "Get a user", diff --git a/pkg/runtime/spec.go b/pkg/runtime/spec.go index 0ef3369..5c2d1bb 100644 --- a/pkg/runtime/spec.go +++ b/pkg/runtime/spec.go @@ -2,7 +2,7 @@ package runtime import "encoding/json" -const SchemaVersion = 8 +const SchemaVersion = 9 type CommandSpec struct { Group string