Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions docs/contracts.md
Original file line number Diff line number Diff line change
@@ -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/<module>/<module>_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 `<cli> commands --json`, `commands show <path...> --json`,
`search "<intent>" --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 `<cli> __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.
65 changes: 65 additions & 0 deletions internal/codegen/app/app.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// 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
}

// 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))
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)
}
20 changes: 18 additions & 2 deletions internal/codegen/render/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Comment thread
samzong marked this conversation as resolved.
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})
}
Expand Down Expand Up @@ -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 {
Expand Down
17 changes: 17 additions & 0 deletions internal/codegen/render/render_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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)
}
}
50 changes: 28 additions & 22 deletions internal/lathecmd/lathecmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -163,6 +164,24 @@ 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
}
if err := generated.Validate(); 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 {
Expand All @@ -172,22 +191,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)
Expand All @@ -200,35 +217,24 @@ 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 {
shortcutRootNames = append(shortcutRootNames, shortcut.Use)
}
}
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) {
Expand Down
39 changes: 39 additions & 0 deletions internal/lathecmd/lathecmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading