From 5823b353736bb0db80399c0ca91a431d825b5d69 Mon Sep 17 00:00:00 2001 From: huntedbytheirs Date: Wed, 19 Aug 2026 16:35:23 -0400 Subject: [PATCH] feat(zeta): recipe model, validation, .recipe emitter Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- internal/zeta/recipe.go | 118 ++++++++++++++++++++++ internal/zeta/recipe_test.go | 184 ++++++++++++++++++++++++++++++++++ testdata/golden/mytool.recipe | 9 ++ 3 files changed, 311 insertions(+) create mode 100644 internal/zeta/recipe.go create mode 100644 internal/zeta/recipe_test.go create mode 100644 testdata/golden/mytool.recipe diff --git a/internal/zeta/recipe.go b/internal/zeta/recipe.go new file mode 100644 index 0000000..d3ea890 --- /dev/null +++ b/internal/zeta/recipe.go @@ -0,0 +1,118 @@ +// Package zeta models Zeta package artifacts: package.lua manifests +// (manifest.go) and zeta-makepkg .recipe source-build recipes (recipe.go). +package zeta + +import ( + "fmt" + "regexp" + "strings" +) + +// Recipe models a declarative Zeta .recipe file — the input format for +// zeta-makepkg source builds. It is purely data; zeta-makepkg handles the +// *how* (fetch, extract, build, package). +// +// v1 of zeta-reconstruct always uses build_system = "custom" wrapping an +// arbitrary PKGBUILD-derived build.sh, so BuildScript is always required. +type Recipe struct { + Name string + Version string + Summary string + URL string + SHA256 string // source checksum; empty means "omit the line" (see Emit) + Deps []string // runtime dependencies + BuildScript string // required: build_system is always "custom" in v1 +} + +var ( + // Mirrors path.sanitize_name (zeta-toolchain/toolchain/lib/path.lua:94-100): + // alphanumeric, dots, underscores, plus, hyphen — no leading dot. + recipeNameRE = regexp.MustCompile(`^[\w.+\-]+$`) + // Mirrors recipe.lua HEX64 (toolchain/lib/recipe.lua:37). + recipeHex64RE = regexp.MustCompile(`^[0-9a-f]{64}$`) +) + +func recipeNameValid(name string) bool { + if name == "" || strings.HasPrefix(name, ".") { + return false + } + return recipeNameRE.MatchString(name) +} + +// Validate mirrors zeta-toolchain/toolchain/lib/recipe.lua normalize() +// (:43-173) for the fields zeta-reconstruct v1 emits. build_system is fixed +// to "custom", so a non-empty BuildScript is always required (:121-123). +func (r *Recipe) Validate() error { + if !recipeNameValid(r.Name) { + return fmt.Errorf("recipe: missing or invalid package name") + } + if r.Version == "" { + return fmt.Errorf("recipe %q: version is required and must be a non-empty string", r.Name) + } + if r.Summary == "" { + return fmt.Errorf("recipe %q: summary is required", r.Name) + } + if r.URL == "" { + return fmt.Errorf("recipe %q: url is required", r.Name) + } + if r.SHA256 != "" && !recipeHex64RE.MatchString(strings.ToLower(r.SHA256)) { + return fmt.Errorf("recipe %q: sha256 must be exactly 64 hex characters or nil", r.Name) + } + for _, d := range r.Deps { + if d == "" { + return fmt.Errorf("recipe %q: each dep must be a non-empty string", r.Name) + } + } + if r.BuildScript == "" { + return fmt.Errorf("recipe %q: build_script is required when build_system is 'custom'", r.Name) + } + return nil +} + +// escapeRecipeString escapes a value for a double-quoted Lua string literal. +// Only double quotes and newlines are escaped, matching the emitter used by +// zeta-toolchain's packager.lua. +func escapeRecipeString(s string) string { + s = strings.ReplaceAll(s, `"`, `\"`) + s = strings.ReplaceAll(s, "\n", `\n`) + return s +} + +// Emit renders the recipe as .recipe Lua text: 2-space indent, field keys +// padded to width 13 before " = " (replicating the alignment of recipe.lua +// and examples/custom-demo/custom.recipe), fields in order name, version, +// summary, url, [sha256,] deps, build_system, build_script. +// +// CRITICAL: the sha256 line is omitted ENTIRELY when SHA256 is empty. +// recipe.lua checks `raw.sha256 ~= nil` (toolchain/lib/recipe.lua:74), so an +// empty string is treated as present and fails the 64-hex check. Never emit +// `sha256 = ""`. +func (r *Recipe) Emit() (string, error) { + if err := r.Validate(); err != nil { + return "", err + } + + var b strings.Builder + b.WriteString("return {\n") + fmt.Fprintf(&b, " %-13s= \"%s\",\n", "name", r.Name) + fmt.Fprintf(&b, " %-13s= \"%s\",\n", "version", r.Version) + fmt.Fprintf(&b, " %-13s= \"%s\",\n", "summary", escapeRecipeString(r.Summary)) + fmt.Fprintf(&b, " %-13s= \"%s\",\n", "url", r.URL) + if r.SHA256 != "" { + // recipe.lua normalizes sha256 to lowercase on load (:78). + fmt.Fprintf(&b, " %-13s= \"%s\",\n", "sha256", strings.ToLower(r.SHA256)) + } + if len(r.Deps) == 0 { + b.WriteString(" deps = {},\n") + } else { + quoted := make([]string, len(r.Deps)) + for i, d := range r.Deps { + quoted[i] = `"` + escapeRecipeString(d) + `"` + } + fmt.Fprintf(&b, " %-13s= { %s },\n", "deps", strings.Join(quoted, ", ")) + } + fmt.Fprintf(&b, " %-13s= \"custom\",\n", "build_system") + fmt.Fprintf(&b, " %-13s= \"%s\",\n", "build_script", r.BuildScript) + b.WriteString("}\n") + return b.String(), nil +} diff --git a/internal/zeta/recipe_test.go b/internal/zeta/recipe_test.go new file mode 100644 index 0000000..f1486cd --- /dev/null +++ b/internal/zeta/recipe_test.go @@ -0,0 +1,184 @@ +package zeta + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestRecipe mirrors the zeta-toolchain recipe.lua validation rules: +// name (sanitize_name), version, summary, url required non-empty; +// sha256 empty-or-64-hex; deps non-empty strings; build_script required +// (build_system is always "custom" in v1). +func TestRecipe(t *testing.T) { + valid := func() *Recipe { + return &Recipe{ + Name: "mytool", + Version: "1.0", + Summary: "Example custom-build package", + URL: "mytool-1.0.tar.gz", + SHA256: "", + Deps: []string{"libz"}, + BuildScript: "build.sh", + } + } + + tests := []struct { + name string + mutate func(r *Recipe) + wantErr bool + }{ + {"valid recipe", func(r *Recipe) {}, false}, + {"empty name", func(r *Recipe) { r.Name = "" }, true}, + {"name with slash", func(r *Recipe) { r.Name = "bad/name" }, true}, + {"name with leading dot", func(r *Recipe) { r.Name = ".hidden" }, true}, + {"empty version", func(r *Recipe) { r.Version = "" }, true}, + {"empty summary", func(r *Recipe) { r.Summary = "" }, true}, + {"empty url", func(r *Recipe) { r.URL = "" }, true}, + {"empty build_script", func(r *Recipe) { r.BuildScript = "" }, true}, + {"bad sha256", func(r *Recipe) { r.SHA256 = "zzz" }, true}, + {"short sha256 (63 hex)", func(r *Recipe) { r.SHA256 = strings.Repeat("a", 63) }, true}, + {"empty sha256 is allowed", func(r *Recipe) { r.SHA256 = "" }, false}, + {"64-hex sha256 allowed", func(r *Recipe) { r.SHA256 = strings.Repeat("f", 64) }, false}, + {"empty dep string", func(r *Recipe) { r.Deps = []string{"libz", ""} }, true}, + {"no deps allowed", func(r *Recipe) { r.Deps = nil }, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := valid() + tt.mutate(r) + err := r.Validate() + if (err != nil) != tt.wantErr { + t.Fatalf("Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestRecipeEmit(t *testing.T) { + // Byte-exact render for the canonical custom recipe. Field keys are + // padded to width 13 before " = ", matching recipe.lua and the + // custom-demo/custom.recipe example. + canonical := &Recipe{ + Name: "mytool", + Version: "1.0", + Summary: "Example custom-build package", + URL: "mytool-1.0.tar.gz", + SHA256: "", + Deps: nil, + BuildScript: "build.sh", + } + want := `return { + name = "mytool", + version = "1.0", + summary = "Example custom-build package", + url = "mytool-1.0.tar.gz", + deps = {}, + build_system = "custom", + build_script = "build.sh", +} +` + out, err := canonical.Emit() + if err != nil { + t.Fatalf("Emit() error = %v", err) + } + if out != want { + t.Fatalf("Emit() mismatch:\n--- want ---\n%s\n--- got ---\n%s", want, out) + } + + // Critical: empty SHA256 must OMIT the sha256 line entirely. recipe.lua + // treats sha256 = "" as present (raw.sha256 ~= nil) and fails the + // 64-hex check, so the line must never be emitted empty. + if strings.Contains(out, "sha256") { + t.Fatalf("Emit() with empty SHA256 must not contain a sha256 line, got:\n%s", out) + } + + // Non-empty SHA256 emits the line after url and before deps. + withSHA := *canonical + withSHA.SHA256 = strings.Repeat("a", 64) + outSHA, err := withSHA.Emit() + if err != nil { + t.Fatalf("Emit() with sha256 error = %v", err) + } + shaLine := ` sha256 = "` + strings.Repeat("a", 64) + `",` + if !strings.Contains(outSHA, shaLine) { + t.Fatalf("Emit() missing aligned sha256 line, got:\n%s", outSHA) + } + if !(strings.Index(outSHA, "url") < strings.Index(outSHA, "sha256") && + strings.Index(outSHA, "sha256") < strings.Index(outSHA, "deps")) { + t.Fatalf("Emit() sha256 not between url and deps, got:\n%s", outSHA) + } + + // deps render on one line; empty deps render as {}. + withDeps := *canonical + withDeps.Deps = []string{"libz", "libfoo"} + outDeps, err := withDeps.Emit() + if err != nil { + t.Fatalf("Emit() with deps error = %v", err) + } + if !strings.Contains(outDeps, ` deps = { "libz", "libfoo" },`) { + t.Fatalf("Emit() deps line mismatch, got:\n%s", outDeps) + } + + // Escape double quotes and newlines in summary and deps values. + escaped := *canonical + escaped.Summary = "say \"hi\"\nsecond line" + escaped.Deps = []string{"say \"hi\""} + outEsc, err := escaped.Emit() + if err != nil { + t.Fatalf("Emit() with escapes error = %v", err) + } + if !strings.Contains(outEsc, ` summary = "say \"hi\"\nsecond line",`) { + t.Fatalf("Emit() summary escape mismatch, got:\n%s", outEsc) + } + if !strings.Contains(outEsc, ` deps = { "say \"hi\"" },`) { + t.Fatalf("Emit() deps escape mismatch, got:\n%s", outEsc) + } + + // Emit of an invalid recipe returns the validation error. + invalid := *canonical + invalid.Name = "" + if _, err := invalid.Emit(); err == nil { + t.Fatal("Emit() of invalid recipe should return an error") + } +} + +func TestRecipeGolden(t *testing.T) { + r := &Recipe{ + Name: "mytool", + Version: "1.0", + Summary: "Example custom-build package", + URL: "mytool-1.0.tar.gz", + SHA256: "", + Deps: nil, + BuildScript: "build.sh", + } + out, err := r.Emit() + if err != nil { + t.Fatalf("Emit() error = %v", err) + } + + // Golden files live at the repo root so todos 5/12/14 can share them. + golden := filepath.Join("..", "..", "testdata", "golden", "mytool.recipe") + + if os.Getenv("UPDATE_GOLDEN") == "1" { + if err := os.MkdirAll(filepath.Dir(golden), 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(golden, []byte(out), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + t.Logf("wrote golden %s", golden) + return + } + + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("ReadFile golden %s: %v (run with UPDATE_GOLDEN=1 to create)", golden, err) + } + if string(want) != out { + t.Fatalf("golden mismatch:\n--- want ---\n%s\n--- got ---\n%s", want, out) + } +} diff --git a/testdata/golden/mytool.recipe b/testdata/golden/mytool.recipe new file mode 100644 index 0000000..c3ad6da --- /dev/null +++ b/testdata/golden/mytool.recipe @@ -0,0 +1,9 @@ +return { + name = "mytool", + version = "1.0", + summary = "Example custom-build package", + url = "mytool-1.0.tar.gz", + deps = {}, + build_system = "custom", + build_script = "build.sh", +}