feat(arch/src): convert PKGBUILD/.SRCINFO to .recipe + build.sh

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <[email protected]>
This commit is contained in:
2026-08-19 18:37:48 -04:00
co-authored by Sisyphus
parent 44c63b5cf7
commit bcd6c9a2e3
3 changed files with 478 additions and 4 deletions
+218
View File
@@ -0,0 +1,218 @@
// Package src converts Arch Linux source packages (PKGBUILD/.SRCINFO) into
// Zeta recipes.
package src
import (
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"git.spectoria.dev/huntedbytheirs/zeta-reconstruct/internal/depmap"
"git.spectoria.dev/huntedbytheirs/zeta-reconstruct/internal/registry"
"git.spectoria.dev/huntedbytheirs/zeta-reconstruct/internal/zeta"
)
// Convert converts an Arch source package (a PKGBUILD, a .SRCINFO, or a
// directory containing either) into a Zeta .recipe with build_system =
// "custom" plus a generated build.sh, both written into opts.Output:
//
// <out>/<name>.recipe
// <out>/build.sh
//
// v1 fidelity guardrails error out on shapes Zeta cannot represent yet:
// split packages, VCS sources (git+/svn+/hg+/bzr+ protocols, <vcs>:: URL
// fragments, or a dynamic pkgver() function), zero sources and multiple
// sources. A nonzero epoch and the dropped metadata fields (makedepends,
// optdepends, provides, conflicts, replaces) are warned about on stderr.
func Convert(input string, opts registry.Options) error {
warn := func(msg string) { fmt.Fprintf(os.Stderr, "warning: %s\n", msg) }
info, err := Load(input, opts.Arch)
if err != nil {
return fmt.Errorf("convert: %w", err)
}
pkgbuildPath, err := resolvePKGBUILD(input)
if err != nil {
return fmt.Errorf("convert: %w", err)
}
// Fidelity guardrails: v1 covers exactly one package built from exactly
// one static tarball source.
if len(info.PkgNames) != 1 {
return errors.New("convert: split packages not yet supported in v1")
}
if hasVCS(info, pkgbuildPath) {
return errors.New("convert: VCS source not yet supported in v1")
}
if len(info.Sources) == 0 {
return errors.New("convert: no sources not yet supported in v1")
}
if len(info.Sources) > 1 {
return errors.New("convert: multiple sources not yet supported in v1")
}
// Warn-only: an epoch cannot be folded into Zeta versioning, and the
// recipe format has no fields for these relations.
if info.Epoch != "" && info.Epoch != "0" {
warn(fmt.Sprintf("epoch %s is not representable in Zeta versioning", info.Epoch))
}
for _, f := range []struct {
field string
vals []string
}{
{"makedepends", info.Makedepends},
{"optdepends", info.Optdepends},
{"provides", info.Provides},
{"conflicts", info.Conflicts},
{"replaces", info.Replaces},
} {
if len(f.vals) > 0 {
warn(fmt.Sprintf("%s dropped (not representable in Zeta)", f.field))
}
}
// Deps: map each declared dependency through the Arch→Zeta name map.
// Dropped deps are skipped (MapDep already warned).
var deps []string
for _, d := range info.Depends {
if mapped, ok := depmap.MapDep(d, warn); ok {
deps = append(deps, mapped)
}
}
summary := info.Desc
if summary == "" {
// recipe.lua requires a non-empty summary.
summary = info.PkgNames[0]
}
recipe := &zeta.Recipe{
Name: info.PkgNames[0],
Version: info.Version,
Summary: summary,
URL: sourceURL(info.Sources[0]),
SHA256: pickSHA256(info.SHA256Sums, warn),
Deps: deps,
BuildScript: "build.sh",
}
src, err := recipe.Emit()
if err != nil {
return fmt.Errorf("convert: emit recipe: %w", err)
}
if err := os.MkdirAll(opts.Output, 0o755); err != nil {
return fmt.Errorf("convert: mkdir %s: %w", opts.Output, err)
}
recipePath := filepath.Join(opts.Output, recipe.Name+".recipe")
if err := os.WriteFile(recipePath, []byte(src), 0o644); err != nil {
return fmt.Errorf("convert: write %s: %w", recipePath, err)
}
script, err := Generate(info, pkgbuildPath)
if err != nil {
return fmt.Errorf("convert: generate build.sh: %w", err)
}
scriptPath := filepath.Join(opts.Output, "build.sh")
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
return fmt.Errorf("convert: write %s: %w", scriptPath, err)
}
return nil
}
// resolvePKGBUILD returns the file whose text feeds Generate and the
// pkgver() scan: the PKGBUILD when one exists, else the .SRCINFO path (whose
// content yields no function bodies — stub functions result).
func resolvePKGBUILD(input string) (string, error) {
st, err := os.Stat(input)
if err != nil {
return "", err
}
if !st.IsDir() {
return input, nil
}
pkgbuild := filepath.Join(input, "PKGBUILD")
if fileExists(pkgbuild) {
return pkgbuild, nil
}
srcinfo := filepath.Join(input, ".SRCINFO")
if fileExists(srcinfo) {
return srcinfo, nil
}
return "", fmt.Errorf("src: %s: neither .SRCINFO nor PKGBUILD found", input)
}
// hasVCS reports whether the source package relies on a VCS source or a
// dynamic pkgver() function and therefore cannot be represented in v1.
func hasVCS(info *SrcInfo, pkgbuildPath string) bool {
for _, s := range info.Sources {
if isVCSSource(s) {
return true
}
}
return hasPkgverFunc(pkgbuildPath)
}
// isVCSSource reports whether a makepkg source entry names a VCS checkout:
// the git+/svn+/hg+/bzr+ protocol forms or the `<vcs>::<url>` fragment form
// (e.g. git::https://…). A plain https:// tarball download is a normal
// source, as is a `name::<url>` entry (a download-name prefix, not a VCS).
func isVCSSource(s string) bool {
for _, prefix := range []string{"git+", "svn+", "hg+", "bzr+"} {
if strings.HasPrefix(s, prefix) {
return true
}
}
if frag, _, ok := strings.Cut(s, "::"); ok {
switch frag {
case "git", "svn", "hg", "bzr", "fossil", "cvs", "darcs", "mtn":
return true
}
}
return false
}
// pkgverFuncRE matches a top-level pkgver() function definition in bash
// (column 0), the makepkg marker for a dynamically computed version.
var pkgverFuncRE = regexp.MustCompile(`(?m)^pkgver\(`)
func hasPkgverFunc(path string) bool {
data, err := os.ReadFile(path)
if err != nil {
return false
}
return pkgverFuncRE.Match(data)
}
// sourceURL derives the recipe url from a makepkg source entry: the text
// after a "name::" prefix when present, otherwise the whole entry verbatim.
// The URL scheme is never stripped.
func sourceURL(s string) string {
if _, rest, ok := strings.Cut(s, "::"); ok {
return rest
}
return s
}
// sha256Re matches exactly 64 hex characters, the recipe.lua sha256 shape.
var sha256Re = regexp.MustCompile(`^[0-9a-fA-F]{64}$`)
// pickSHA256 returns the source sha256 for the recipe, or "" when absent or
// unusable (an empty value, makepkg's "SKIP" marker, or a non-hex64 string —
// the latter is warned about because the checksum is dropped).
func pickSHA256(sums []string, warn func(string)) string {
if len(sums) == 0 {
return ""
}
s := strings.TrimSpace(sums[0])
if s == "" || strings.EqualFold(s, "SKIP") {
return ""
}
if !sha256Re.MatchString(s) {
warn(fmt.Sprintf("sha256sum %q is not 64 hex characters; checksum dropped", s))
return ""
}
return s
}
+258
View File
@@ -0,0 +1,258 @@
package src
import (
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"git.spectoria.dev/huntedbytheirs/zeta-reconstruct/internal/registry"
)
const (
srcinfoDir = "../../../testdata/srcinfo"
recipeLua = "/home/specter/Public/zeta-toolchain/toolchain/lib/?.lua"
)
// convertMytool runs the full conversion on the mytool fixture directory
// into a fresh temp dir and returns that dir plus the emitted recipe and
// build.sh contents.
func convertMytool(t *testing.T) (out, recipe, script string) {
t.Helper()
out = t.TempDir()
if err := Convert(filepath.Join(srcinfoDir, "mytool"), registry.Options{Output: out}); err != nil {
t.Fatalf("Convert(mytool): %v", err)
}
rb, err := os.ReadFile(filepath.Join(out, "mytool.recipe"))
if err != nil {
t.Fatalf("mytool.recipe not written: %v", err)
}
sb, err := os.ReadFile(filepath.Join(out, "build.sh"))
if err != nil {
t.Fatalf("build.sh not written: %v", err)
}
return out, string(rb), string(sb)
}
// convertStderr runs Convert with os.Stderr redirected into a pipe and
// returns the captured output along with the error. Tests using this helper
// must not run in parallel.
func convertStderr(t *testing.T, input string, opts registry.Options) (string, error) {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
defer r.Close()
old := os.Stderr
os.Stderr = w
err = Convert(input, opts)
if cerr := w.Close(); cerr != nil && err == nil {
err = cerr
}
os.Stderr = old
out, rerr := io.ReadAll(r)
if rerr != nil {
t.Fatalf("read stderr: %v", rerr)
}
return string(out), err
}
// quoteLua renders a path as a safe single-quoted Lua string literal.
func quoteLua(s string) string {
return `'` + strings.ReplaceAll(s, `\`, `\\`) + `'`
}
// writePKGBUILD writes a PKGBUILD fixture into a fresh temp dir and returns
// the dir (passed as input to Convert).
func writePKGBUILD(t *testing.T, body string) string {
t.Helper()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "PKGBUILD"), []byte(body), 0o644); err != nil {
t.Fatalf("write PKGBUILD fixture: %v", err)
}
return dir
}
func TestConvert(t *testing.T) {
out, recipe, script := convertMytool(t)
for _, want := range []string{
`build_system = "custom"`,
`build_script = "build.sh"`,
`name = "mytool"`,
} {
if !strings.Contains(recipe, want) {
t.Errorf("mytool.recipe does not contain %q:\n%s", want, recipe)
}
}
if !strings.Contains(script, `pkgdir="$DESTDIR"`) {
t.Errorf("build.sh does not contain pkgdir=\"$DESTDIR\"")
}
if _, err := os.Stat(filepath.Join(out, "mytool.recipe")); err != nil {
t.Errorf("mytool.recipe missing: %v", err)
}
if _, err := os.Stat(filepath.Join(out, "build.sh")); err != nil {
t.Errorf("build.sh missing: %v", err)
}
}
func TestConvertRecipeLoadsInZeta(t *testing.T) {
lua, err := exec.LookPath("lua")
if err != nil {
t.Skip("lua not available; skipping cross-tool validation")
}
out := t.TempDir()
if err := Convert(filepath.Join(srcinfoDir, "mytool"), registry.Options{Output: out}); err != nil {
t.Fatalf("Convert(mytool): %v", err)
}
recipePath := filepath.Join(out, "mytool.recipe")
probe := `package.path="` + recipeLua + `;"..package.path; ` +
`local r,e=require("recipe").load(` + quoteLua(recipePath) + `); assert(r,e)`
cmd := exec.Command(lua, "-e", probe)
if b, cerr := cmd.CombinedOutput(); cerr != nil {
t.Fatalf("real zeta-makepkg recipe.load rejected the emitted recipe: %v\n%s", cerr, b)
}
}
func TestConvertSplitPackage(t *testing.T) {
err := Convert(filepath.Join(srcinfoDir, "split"), registry.Options{Output: t.TempDir()})
if err == nil {
t.Fatal("Convert(split) error = nil, want guardrail error")
}
if !strings.Contains(err.Error(), "not yet supported") {
t.Errorf("Convert(split) error = %q, want it to contain %q", err, "not yet supported")
}
if !strings.Contains(err.Error(), "split packages not yet supported in v1") {
t.Errorf("Convert(split) error = %q, want the split guardrail message", err)
}
}
func TestConvertVCS(t *testing.T) {
err := Convert(filepath.Join(srcinfoDir, "vcs"), registry.Options{Output: t.TempDir()})
if err == nil {
t.Fatal("Convert(vcs) error = nil, want guardrail error")
}
if !strings.Contains(err.Error(), "not yet supported") {
t.Errorf("Convert(vcs) error = %q, want it to contain %q", err, "not yet supported")
}
if !strings.Contains(err.Error(), "VCS source not yet supported in v1") {
t.Errorf("Convert(vcs) error = %q, want the VCS guardrail message", err)
}
}
func TestConvertEpochWarns(t *testing.T) {
dir := writePKGBUILD(t, `pkgname=epo
pkgver=1.0
pkgrel=1
epoch=1
pkgdesc="Epoch fixture"
arch=(x86_64)
source=("epo-1.0.tar.gz")
`)
stderr, err := convertStderr(t, dir, registry.Options{Output: t.TempDir()})
if err != nil {
t.Fatalf("Convert(epoch) error = %v, want nil (epoch warns only)", err)
}
if !strings.Contains(stderr, "epoch") {
t.Errorf("stderr = %q, want a warning mentioning epoch", stderr)
}
}
func TestConvertNamedSourceURL(t *testing.T) {
dir := writePKGBUILD(t, `pkgname=namedsrc
pkgver=1.0
pkgrel=1
pkgdesc="Named source fixture"
arch=(x86_64)
source=("name::https://example.com/x.tar.gz")
sha256sums=('SKIP')
`)
out := t.TempDir()
if err := Convert(dir, registry.Options{Output: out}); err != nil {
t.Fatalf("Convert(named source) error = %v, want nil", err)
}
content, err := os.ReadFile(filepath.Join(out, "namedsrc.recipe"))
if err != nil {
t.Fatalf("namedsrc.recipe not written: %v", err)
}
const wantLine = `url = "https://example.com/x.tar.gz",`
if !strings.Contains(string(content), wantLine) {
t.Errorf("recipe url = %q, want the line %q (scheme preserved, name:: stripped):\n%s",
string(content), wantLine, content)
}
if strings.Contains(string(content), "name::") {
t.Errorf("recipe still contains the name:: prefix:\n%s", content)
}
}
func TestConvertMissingInput(t *testing.T) {
err := Convert(filepath.Join(t.TempDir(), "nope"), registry.Options{Output: t.TempDir()})
if err == nil {
t.Fatal("Convert(missing) error = nil, want error for nonexistent input")
}
}
func TestConvertZeroSources(t *testing.T) {
dir := writePKGBUILD(t, `pkgname=nosrc
pkgver=1.0
pkgrel=1
pkgdesc="No sources fixture"
arch=(x86_64)
`)
err := Convert(dir, registry.Options{Output: t.TempDir()})
if err == nil {
t.Fatal("Convert(no sources) error = nil, want guardrail error")
}
if !strings.Contains(err.Error(), "no sources not yet supported in v1") {
t.Errorf("Convert(no sources) error = %q, want the zero-source guardrail message", err)
}
}
func TestConvertMultiSource(t *testing.T) {
dir := writePKGBUILD(t, `pkgname=multisrc
pkgver=1.0
pkgrel=1
pkgdesc="Multi source fixture"
arch=(x86_64)
source=("a-1.0.tar.gz" "b-1.0.tar.gz")
sha256sums=('SKIP' 'SKIP')
`)
err := Convert(dir, registry.Options{Output: t.TempDir()})
if err == nil {
t.Fatal("Convert(multi source) error = nil, want guardrail error")
}
if !strings.Contains(err.Error(), "multiple sources not yet supported in v1") {
t.Errorf("Convert(multi source) error = %q, want the multi-source guardrail message", err)
}
}
func TestConvertDroppedMetadataWarns(t *testing.T) {
dir := writePKGBUILD(t, `pkgname=meta
pkgver=1.0
pkgrel=1
pkgdesc="Dropped metadata fixture"
arch=(x86_64)
source=("meta-1.0.tar.gz")
makedepends=('make')
optdepends=('bonus: optional extra')
provides=('virt')
conflicts=('other')
replaces=('old')
`)
stderr, err := convertStderr(t, dir, registry.Options{Output: t.TempDir()})
if err != nil {
t.Fatalf("Convert(dropped metadata) error = %v, want nil (warns only)", err)
}
for _, field := range []string{"makedepends", "optdepends", "provides", "conflicts", "replaces"} {
want := "warning: " + field + " dropped (not representable in Zeta)"
if !strings.Contains(stderr, want) {
t.Errorf("stderr = %q, want warning %q", stderr, want)
}
}
}
+2 -4
View File
@@ -3,8 +3,6 @@
package src
import (
"errors"
"git.spectoria.dev/huntedbytheirs/zeta-reconstruct/internal/registry"
)
@@ -13,9 +11,9 @@ type frontend struct{}
func (frontend) Name() string { return "arch-src" }
// Convert delegates to the package-level converter (convert.go).
func (frontend) Convert(input string, opts registry.Options) error {
// Implemented in a later todo (PKGBUILD/.SRCINFO -> .recipe + build.sh).
return errors.New("not implemented")
return Convert(input, opts)
}
func init() {