Splux Linux

source-based Linux using SPS

SPS · log · files · refs · tags · releases · people · languages · GitHub · commit

3878b88


Add maintainer tools for auditing and drafting recipes

Date
Commit3878b88fcad11b8967cfba8a890edc7698498fe5
GitHubmirror

Parents

Stat

 .github/workflows/audit-recipes.yml |  70 ++++
 Makefile                            |  26 +-
 tests/test_audit_recipes.sh         | 255 +++++++++++++
 tools/audit-recipes                 | 586 +++++++++++++++++++++++++++++
 tools/new-recipe                    | 709 ++++++++++++++++++++++++++++++++++++
 5 files changed, 1643 insertions(+), 3 deletions(-)

Diff

diff --git a/.github/workflows/audit-recipes.yml b/.github/workflows/audit-recipes.yml
new file mode 100644
index 0000000..d02412b
--- /dev/null
+++ b/.github/workflows/audit-recipes.yml
@@ -0,0 +1,70 @@
+name: audit-recipes
+
+# A recipe whose upstream tarball moved, was re-rolled, or started answering
+# with an HTML sign-in page is the most common way a source distribution stops
+# installing, and nothing in `src update` can see it. This job finds it before
+# a user's build does.
+
+on:
+  schedule:
+    # Weekly, early Monday UTC.
+    - cron: '17 4 * * 1'
+  workflow_dispatch:
+    inputs:
+      net:
+        description: 'Download every source and verify its SHA-256'
+        type: boolean
+        default: true
+
+permissions:
+  contents: read
+
+jobs:
+  audit:
+    runs-on: ubuntu-latest
+    timeout-minutes: 180
+    steps:
+      - name: Check out SPS
+        uses: actions/checkout@v4
+
+      - name: Check out sps-core
+        uses: actions/checkout@v4
+        with:
+          repository: RobertFlexx/sps-core
+          path: trees/core
+
+      - name: Check out sps-extra
+        uses: actions/checkout@v4
+        with:
+          repository: RobertFlexx/sps-extra
+          path: trees/extra
+
+      - name: Offline audit
+        run: |
+          python3 tools/audit-recipes \
+            --trees "$PWD/trees/core:$PWD/trees/extra"
+
+      - name: Verify every source hash
+        if: github.event_name == 'schedule' || inputs.net
+        run: |
+          python3 tools/audit-recipes \
+            --trees "$PWD/trees/core:$PWD/trees/extra" \
+            --net --jobs 16 --timeout 900 --json >audit.json || status=$?
+          python3 - <<'PY'
+          import json
+          doc = json.load(open("audit.json"))
+          bad = [f for f in doc["findings"]
+                 if f["code"] in ("HASH-MISMATCH", "SOURCE-UNREACHABLE")]
+          for f in bad:
+              print(f"{f['code']} {f['package']}\n  {f['detail']}")
+          print(f"\n{doc['recipes']} recipes, {len(bad)} unusable source(s)")
+          raise SystemExit(1 if bad else 0)
+          PY
+
+      - name: Upload findings
+        if: always()
+        uses: actions/upload-artifact@v4
+        with:
+          name: audit-findings
+          path: audit.json
+          if-no-files-found: ignore
diff --git a/Makefile b/Makefile
index a52bb83..51a5e14 100644
--- a/Makefile
+++ b/Makefile
@@ -6,11 +6,12 @@ MANDIR = $(PREFIX)/share/man
 SYSCONFDIR = /etc/sps
 DESTDIR =
 
-PROGRAMS = src sget mkpkg pkin pkdel pkstat pkcheck pkmark setup mkiso
+PROGRAMS = src sget mkpkg pkin pkdel pkstat pkcheck pkmark pkconf setup mkiso
 AWK_LIBS = common.awk config.awk db.awk deps.awk index.awk package.awk recipe.awk repository.awk relations.awk version.awk
 
 .PHONY: all check lint check-version source-archive release-check \
-	install install-config clean iso iso-slim iso-plasma
+	install install-config clean iso iso-slim iso-plasma \
+	audit-recipes audit-recipes-net
 
 all:
 	@printf '%s\n' "SPS is interpreted; run 'make check' or 'make install'."
@@ -34,7 +35,8 @@ lint:
 		[ -f $$script ] || continue; \
 		$(POSIX_SHELL) -n $$script; \
 	done
-	@python3 -m py_compile tools/upgrade-recipes tools/write-stock-services.py
+	@python3 -m py_compile tools/upgrade-recipes tools/write-stock-services.py \
+		tools/audit-recipes
 	@set -e; for script in lib/mkiso/initrd-init lib/mkiso/live-rc \
 		lib/mkiso/live-plasma lib/mkiso/live-plasma-session \
 		lib/mkiso/live-init lib/mkiso/udhcpc-script; do \
@@ -49,6 +51,24 @@ lint:
 	@awk -f lib/common.awk -f lib/config.awk </dev/null >/dev/null
 	@awk -f lib/version.awk </dev/null >/dev/null
 
+# Recipe-tree audits. SPS_RECIPE_TREES, or RECIPE_TREES on the command line,
+# selects the trees; the default is the usual /usr/src/sps checkouts.
+#
+#   make audit-recipes RECIPE_TREES=/usr/src/sps/core:/usr/src/sps/extra
+#
+# audit-recipes is offline and fast enough for every commit. audit-recipes-net
+# downloads every declared source and verifies its SHA-256, which is the only
+# way to catch an upstream tarball that moved or was re-rolled. Run it on a
+# schedule, not on every push.
+RECIPE_TREES =
+
+audit-recipes:
+	@python3 tools/audit-recipes $(if $(RECIPE_TREES),--trees $(RECIPE_TREES))
+
+audit-recipes-net:
+	@python3 tools/audit-recipes --net \
+		$(if $(RECIPE_TREES),--trees $(RECIPE_TREES))
+
 check-version:
 	@LC_ALL=C awk '\
 		NR == 1 && $$0 ~ /^[0-9]+\.[0-9]+\.[0-9]+$$/ { valid = 1 } \
diff --git a/tests/test_audit_recipes.sh b/tests/test_audit_recipes.sh
new file mode 100755
index 0000000..5bf48c9
--- /dev/null
+++ b/tests/test_audit_recipes.sh
@@ -0,0 +1,255 @@
+#!/bin/sh
+set -eu
+
+case $0 in */*) test_dir=${0%/*} ;; *) test_dir=. ;; esac
+project_dir=$(CDPATH= cd "$test_dir/.." 2>/dev/null && pwd) || exit 1
+fail() { printf 'test_audit_recipes: %s\n' "$*" >&2; exit 1; }
+
+tool=$project_dir/tools/audit-recipes
+[ -f "$tool" ] || fail "missing $tool"
+command -v python3 >/dev/null 2>&1 || fail 'python3 is required for audit-recipes'
+
+tmp=$(mktemp -d "${TMPDIR:-/tmp}/sps-audit-test.XXXXXX") || fail mktemp
+trap 'rm -rf "$tmp"' 0 HUP INT TERM
+
+# audit-recipes never reaches the network unless --net is passed, so every
+# case below is offline and deterministic.
+audit() { python3 "$tool" --trees "$1" --json 2>/dev/null; }
+
+codes() {
+	python3 -c '
+import json, sys
+doc = json.load(sys.stdin)
+print(" ".join(sorted({f["code"] for f in doc["findings"]})))
+'
+}
+
+# A clean tree must produce no findings and exit 0.
+mkdir -p "$tmp/clean/base/alpha" "$tmp/clean/base/beta"
+cat >"$tmp/clean/base/alpha/recipe" <<'EOF'
+name        alpha
+version     1.0
+release     1
+arch        any
+description alpha: clean fixture
+
+source      https://example.org/alpha-1.0.tar.gz
+hash        sha256:8ed3f6ad685b959ead7022518e1af76cd816f8e8ec7ccdda1ed4018e8f2223f8
+
+depend      beta
+install     mkdir -p "$PKG/usr/share/alpha"
+EOF
+cat >"$tmp/clean/base/beta/recipe" <<'EOF'
+name        beta
+version     2.0
+release     1
+arch        any
+description beta: clean fixture
+
+source      https://example.org/beta-2.0.tar.gz
+hash        sha256:f44e64e75f3948e9f73f8dfa94721c4ce8cbb4f265c4790c702b2d41cfbf2753
+
+install     mkdir -p "$PKG/usr/share/beta"
+EOF
+if ! python3 "$tool" --trees "$tmp/clean" >/dev/null 2>&1; then
+	fail 'a clean tree must exit 0'
+fi
+[ -z "$(audit "$tmp/clean" | codes)" ] ||
+	fail "clean tree reported findings: $(audit "$tmp/clean" | codes)"
+
+# A dependency on a package no recipe provides halts a real install, so it
+# must be an error rather than a warning.
+mkdir -p "$tmp/missing/base/alpha"
+cat >"$tmp/missing/base/alpha/recipe" <<'EOF'
+name        alpha
+version     1.0
+release     1
+arch        any
+description alpha: missing dependency fixture
+
+depend      nosuchpackage
+install     mkdir -p "$PKG/usr/share/alpha"
+EOF
+audit "$tmp/missing" | codes | grep -q MISSING-DEPENDENCY ||
+	fail 'a dependency with no providing recipe must be reported'
+if python3 "$tool" --trees "$tmp/missing" >/dev/null 2>&1; then
+	fail 'a missing dependency must make audit-recipes exit nonzero'
+fi
+
+# sget rejects a dependency cycle at install time; the audit must find it in
+# the tree first.
+mkdir -p "$tmp/cycle/base/one" "$tmp/cycle/base/two"
+cat >"$tmp/cycle/base/one/recipe" <<'EOF'
+name        one
+version     1.0
+release     1
+arch        any
+description one: cycle fixture
+
+depend      two
+install     mkdir -p "$PKG/usr/share/one"
+EOF
+cat >"$tmp/cycle/base/two/recipe" <<'EOF'
+name        two
+version     1.0
+release     1
+arch        any
+description two: cycle fixture
+
+depend      one
+install     mkdir -p "$PKG/usr/share/two"
+EOF
+audit "$tmp/cycle" | codes | grep -q DEPENDENCY-CYCLE ||
+	fail 'a runtime dependency cycle must be reported'
+
+# Two recipes for one name in a single repository is the duplicate src(1)
+# rejects; the same name in two trees only shadows, so it is a warning.
+mkdir -p "$tmp/dup/base/thing" "$tmp/dup/other/thing"
+for dir in "$tmp/dup/base/thing" "$tmp/dup/other/thing"; do
+	cat >"$dir/recipe" <<'EOF'
+name        thing
+version     1.0
+release     1
+arch        any
+description thing: duplicate fixture
+
+install     mkdir -p "$PKG/usr/share/thing"
+EOF
+done
+audit "$tmp/dup" | codes | grep -q DUPLICATE-IN-REPOSITORY ||
+	fail 'a duplicate package name in one tree must be reported'
+
+# Malformed metadata.
+mkdir -p "$tmp/bad/base/broken"
+cat >"$tmp/bad/base/broken/recipe" <<'EOF'
+name        broken
+version     1.0
+release     1
+arch        any
+description broken: malformed fixture
+homepage    https://example.org/
+
+source      https://example.org/broken-1.0.tar.gz
+source      https://example.org/extra.patch
+hash        sha256:nothexatall
+
+depend      broken
+install     make install
+EOF
+bad_codes=$(audit "$tmp/bad" | codes)
+for expected in UNKNOWN-KEY SOURCE-HASH-COUNT BAD-HASH-FORMAT SELF-DEPENDENCY \
+	INSTALL-OUTSIDE-PKG; do
+	case " $bad_codes " in
+		*" $expected "*) ;;
+		*) fail "expected $expected in: $bad_codes" ;;
+	esac
+done
+
+# A recipe whose name does not match its directory still indexes, but the
+# maintainer tooling and `cd extra/CATEGORY/NAME` both key off the directory.
+mkdir -p "$tmp/mismatch/base/oldname"
+cat >"$tmp/mismatch/base/oldname/recipe" <<'EOF'
+name        newname
+version     1.0
+release     1
+arch        any
+description newname: directory mismatch fixture
+
+install     mkdir -p "$PKG/usr/share/newname"
+EOF
+audit "$tmp/mismatch" | codes | grep -q NAME-DIR-MISMATCH ||
+	fail 'a recipe whose name differs from its directory must be reported'
+
+# A recipe that reuses another package's digest for a different URL is a
+# copy-paste mistake that silently builds the wrong source.
+mkdir -p "$tmp/reuse/base/aaa" "$tmp/reuse/base/bbb"
+cat >"$tmp/reuse/base/aaa/recipe" <<'EOF'
+name        aaa
+version     1.0
+release     1
+arch        any
+description aaa: hash reuse fixture
+
+source      https://example.org/aaa-1.0.tar.gz
+hash        sha256:a4d26868017c0ccffe2efe50944ef4211834660cca834c6e9f86dec6a88246fa
+
+install     mkdir -p "$PKG/usr/share/aaa"
+EOF
+cat >"$tmp/reuse/base/bbb/recipe" <<'EOF'
+name        bbb
+version     1.0
+release     1
+arch        any
+description bbb: hash reuse fixture
+
+source      https://example.org/bbb-1.0.tar.gz
+hash        sha256:a4d26868017c0ccffe2efe50944ef4211834660cca834c6e9f86dec6a88246fa
+
+install     mkdir -p "$PKG/usr/share/bbb"
+EOF
+audit "$tmp/reuse" | codes | grep -q HASH-REUSED-DIFFERENT-SOURCE ||
+	fail 'one digest shared by two different URLs must be reported'
+
+# The sudo package configures --with-vardir=/var/db/sudo. That is not
+# privilege escalation and must not be flagged.
+mkdir -p "$tmp/sudo/base/sudo"
+cat >"$tmp/sudo/base/sudo/recipe" <<'EOF'
+name        sudo
+version     1.0
+release     1
+arch        any
+description sudo: false-positive fixture
+
+configure   ./configure --prefix=/usr --with-vardir=/var/db/sudo
+install     make install DESTDIR="$PKG"
+EOF
+if audit "$tmp/sudo" | codes | grep -q PHASE-USES-SUDO; then
+	fail 'the sudo package must not be flagged as using sudo'
+fi
+mkdir -p "$tmp/realsudo/base/thing"
+cat >"$tmp/realsudo/base/thing/recipe" <<'EOF'
+name        thing
+version     1.0
+release     1
+arch        any
+description thing: real escalation fixture
+
+install     sudo make install DESTDIR="$PKG"
+EOF
+audit "$tmp/realsudo" | codes | grep -q PHASE-USES-SUDO ||
+	fail 'a phase that actually runs sudo must be reported'
+
+# SPS builds CPython with --with-platlibdir=lib64. A module staged under
+# /usr/lib/pythonX.Y is unimportable, but naming that path in order to
+# relocate it into lib64 is exactly right and must not be flagged.
+mkdir -p "$tmp/pylib/base/python-bad"
+cat >"$tmp/pylib/base/python-bad/recipe" <<'EOF'
+name        python-bad
+version     1.0
+release     1
+arch        any
+description python-bad: wrong site-packages fixture
+
+install     mkdir -p "$PKG/usr/lib/python3.14/site-packages"
+EOF
+audit "$tmp/pylib" | codes | grep -q PYTHON-SITE-PACKAGES-LIB ||
+	fail 'a module staged under /usr/lib/pythonX.Y must be reported'
+
+mkdir -p "$tmp/pyok/base/python-good"
+cat >"$tmp/pyok/base/python-good/recipe" <<'EOF'
+name        python-good
+version     1.0
+release     1
+arch        any
+description python-good: relocating fixture
+
+install     python3 -m pip install --root="$PKG" --prefix=/usr .
+install     if [ -d "$PKG/usr/lib/python3.14" ]; then \
+            cp -a "$PKG/usr/lib/python3.14/." "$PKG/usr/lib64/python3.14/"; \
+            rm -rf "$PKG/usr/lib/python3.14"; fi
+EOF
+if audit "$tmp/pyok" | codes | grep -q PYTHON-SITE-PACKAGES-LIB; then
+	fail 'a recipe that relocates into lib64 must not be flagged'
+fi
+
+printf '%s\n' 'audit-recipes tests passed'
diff --git a/tools/audit-recipes b/tools/audit-recipes
new file mode 100755
index 0000000..370eb0b
--- /dev/null
+++ b/tools/audit-recipes
@@ -0,0 +1,586 @@
+#!/usr/bin/env python3
+"""Audit SPS recipe trees for defects that break an install.
+
+src(1) already validates recipe syntax, same-repository uniqueness, and the
+selected dependency graph when it builds an index. This tool covers what an
+index build cannot: cross-tree consistency, copy-paste mistakes, and whether
+every declared source still downloads and still matches its recorded SHA-256.
+
+A recipe whose upstream tarball moved, was re-rolled, or silently changed is
+the single most common way a source distribution stops installing. Nothing
+notices until a user runs sget and mkpkg aborts on a checksum. Run this in CI
+and before a release.
+
+Offline checks (default):
+
+    python3 tools/audit-recipes --trees /usr/src/sps/core:/usr/src/sps/extra
+
+Online checks (downloads every source, streams it through sha256, keeps
+nothing on disk):
+
+    python3 tools/audit-recipes --trees ... --net --jobs 16
+
+Exit status is 0 when no finding is at or above --fail-level.
+"""
+from __future__ import annotations
+
+import argparse
+import concurrent.futures
+import hashlib
+import json
+import os
+import re
+import ssl
+import sys
+import urllib.error
+import urllib.request
+from pathlib import Path
+
+# Do NOT send a browser User-Agent. SourceForge and several other hosts serve
+# an HTML interstitial to browser agents and the real archive to curl/wget, so
+# a browser UA turns every SourceForge recipe into a bogus hash mismatch.
+UA = "curl/8.5.0"
+
+# Keys accepted by the SPS 1.0 recipe parser. Anything else is an error.
+SINGLE_KEYS = {"name", "version", "release", "arch", "description"}
+MULTI_KEYS = {
+    "source", "hash", "depend", "builddep", "optional", "conflict",
+    "prepare", "configure", "build", "install",
+}
+ALL_KEYS = SINGLE_KEYS | MULTI_KEYS
+
+NAME_RE = re.compile(r"^[0-9A-Za-z][0-9A-Za-z+_.-]*$")
+VERSION_RE = re.compile(r"^[0-9A-Za-z][0-9A-Za-z+_.~-]*$")
+HASH_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
+CONSTRAINT_RE = re.compile(r"^([0-9A-Za-z][0-9A-Za-z+_.-]*)(?:(<=|>=|=|<|>)(.+))?$")
+
+# Severity ordering, low to high.
+LEVELS = ["info", "warn", "error"]
+
+
+def level_at_least(level: str, floor: str) -> bool:
+    return LEVELS.index(level) >= LEVELS.index(floor)
+
+
+class Finding:
+    __slots__ = ("level", "code", "package", "path", "detail")
+
+    def __init__(self, level: str, code: str, package: str, path: Path, detail: str):
+        self.level = level
+        self.code = code
+        self.package = package
+        self.path = path
+        self.detail = detail
+
+    def as_dict(self) -> dict:
+        return {
+            "level": self.level,
+            "code": self.code,
+            "package": self.package,
+            "path": str(self.path),
+            "detail": self.detail,
+        }
+
+    def __str__(self) -> str:
+        head = f"{self.level:<5} {self.code:<22} {self.package or '-'}"
+        if self.detail:
+            return f"{head}\n      {self.detail}\n      {self.path}"
+        return f"{head}\n      {self.path}"
+
+
+def parse_recipe(path: Path) -> dict:
+    """Parse one recipe the way lib/recipe.awk does.
+
+    Returns single-valued keys as strings and repeatable keys as lists. Keys
+    the parser does not know are collected in "unknown" so the audit can
+    report them rather than silently dropping them.
+    """
+    rec: dict = {
+        "path": path,
+        "name": "",
+        "version": "",
+        "release": "",
+        "arch": "",
+        "description": "",
+        "unknown": [],
+        "duplicate_single": [],
+        "malformed": [],
+    }
+    for key in MULTI_KEYS:
+        rec[key] = []
+    try:
+        raw = path.read_text(encoding="utf-8", errors="replace")
+    except OSError as exc:
+        rec["malformed"].append(f"unreadable: {exc}")
+        return rec
+    rec["raw"] = raw
+
+    pending = ""
+    for physical in raw.splitlines():
+        line = pending + physical if pending else physical
+        pending = ""
+        if line.endswith("\\"):
+            pending = line[:-1]
+            continue
+        stripped = line.strip()
+        if not stripped or stripped.startswith("#"):
+            continue
+        parts = stripped.split(None, 1)
+        key = parts[0]
+        value = parts[1].strip() if len(parts) > 1 else ""
+        if key not in ALL_KEYS:
+            rec["unknown"].append(key)
+            continue
+        if not value:
+            rec["malformed"].append(f"empty value for '{key}'")
+            continue
+        if key in SINGLE_KEYS:
+            if rec[key]:
+                rec["duplicate_single"].append(key)
+            rec[key] = value
+        else:
+            rec[key].append(value)
+    if pending:
+        rec["malformed"].append("recipe ends with a line continuation")
+    return rec
+
+
+def expand(value: str, rec: dict) -> str:
+    """Expand the four variables the recipe parser defines."""
+    out = value
+    for var, key in (("name", "name"), ("version", "version"),
+                     ("release", "release"), ("arch", "arch")):
+        out = out.replace("${" + var + "}", rec.get(key) or "")
+    return out
+
+
+def split_specs(records: list[str]) -> list[str]:
+    """A depend/builddep record may hold several whitespace/comma specs."""
+    out: list[str] = []
+    for record in records:
+        for tok in re.split(r"[,\s]+", record.strip()):
+            if tok:
+                out.append(tok)
+    return out
+
+
+def collect(trees: list[Path]) -> list[dict]:
+    recipes: list[dict] = []
+    for tree in trees:
+        for path in sorted(tree.rglob("recipe")):
+            if not path.is_file():
+                continue
+            rec = parse_recipe(path)
+            rec["tree"] = tree
+            recipes.append(rec)
+    return recipes
+
+
+def static_checks(recipes: list[dict], universe: list[dict] | None = None) -> list[Finding]:
+    """Check `recipes`, resolving dependencies against `universe`.
+
+    With --only the caller audits a handful of packages but their dependencies
+    still live in the rest of the tree, so the name universe must stay whole or
+    every external dependency looks missing.
+    """
+    if universe is None:
+        universe = recipes
+    findings: list[Finding] = []
+
+    def add(level, code, rec, detail=""):
+        findings.append(Finding(level, code, rec.get("name") or "", rec["path"], detail))
+
+    by_name: dict[str, list[dict]] = {}
+    for rec in universe:
+        if rec["name"]:
+            by_name.setdefault(rec["name"], []).append(rec)
+
+    hash_owner: dict[str, str] = {}
+    url_owner: dict[str, str] = {}
+
+    for rec in recipes:
+        path = rec["path"]
+        pkgdir = path.parent.name
+        name = rec["name"]
+
+        for problem in rec["malformed"]:
+            add("error", "MALFORMED", rec, problem)
+        for key in rec["unknown"]:
+            add("error", "UNKNOWN-KEY", rec, f"'{key}' is not a recipe key")
+        for key in rec["duplicate_single"]:
+            add("error", "DUPLICATE-KEY", rec, f"'{key}' may appear only once")
+
+        # Required metadata.
+        if not name:
+            add("error", "MISSING-NAME", rec)
+        elif not NAME_RE.match(name):
+            add("error", "BAD-NAME", rec, name)
+        if not rec["version"]:
+            add("error", "MISSING-VERSION", rec)
+        elif not VERSION_RE.match(rec["version"]):
+            add("error", "BAD-VERSION", rec, rec["version"])
+        if not rec["release"]:
+            add("error", "MISSING-RELEASE", rec)
+        elif not rec["release"].isdigit():
+            add("warn", "NONNUMERIC-RELEASE", rec, rec["release"])
+        if not rec["install"]:
+            add("error", "NO-INSTALL-PHASE", rec)
+        if not rec["description"]:
+            add("warn", "NO-DESCRIPTION", rec)
+        if rec["arch"] and rec["arch"] == "noarch":
+            add("info", "NOARCH-SPELLING", rec, "official recipes use 'any'")
+
+        # The package directory should be the package name. src(1) does not
+        # require it (categories are not identity) but a mismatch means
+        # `cd extra/libs/foo` does not get you package foo, and the upgrade
+        # tooling keys off directory names.
+        if name and name != pkgdir:
+            add("warn", "NAME-DIR-MISMATCH", rec, f"name '{name}' in directory '{pkgdir}'")
+
+        # Sources and hashes pair up by declaration order.
+        nsrc, nhash = len(rec["source"]), len(rec["hash"])
+        if nsrc != nhash:
+            add("error", "SOURCE-HASH-COUNT", rec,
+                f"{nsrc} source record(s) but {nhash} hash record(s)")
+        for digest in rec["hash"]:
+            if not HASH_RE.match(digest):
+                add("error", "BAD-HASH-FORMAT", rec, digest)
+                continue
+            body = digest[7:]
+            if len(set(body)) <= 2:
+                add("error", "PLACEHOLDER-HASH", rec, digest)
+
+        for index, source in enumerate(rec["source"]):
+            url = expand(source, rec)
+            if re.search(r"\$\{[a-z]+\}", url):
+                add("error", "UNEXPANDED-VARIABLE", rec, source)
+            if any(c.isspace() for c in url):
+                add("error", "SOURCE-WHITESPACE", rec, source)
+            remote = url.startswith(("http://", "https://", "ftp://"))
+            if url.startswith("http://"):
+                add("warn", "PLAINTEXT-SOURCE", rec, url)
+            if remote:
+                # A remote URL that never mentions the version will silently
+                # keep serving an old tarball after a version bump, or (worse)
+                # serve a moving target that breaks the recorded hash.
+                if rec["version"] and rec["version"] not in url and \
+                        "${version}" not in source:
+                    add("warn", "URL-WITHOUT-VERSION", rec, url)
+                if url in url_owner and url_owner[url] != name:
+                    add("info", "SHARED-SOURCE-URL", rec,
+                        f"{url} is also used by {url_owner[url]}")
+                else:
+                    url_owner[url] = name
+            elif url.startswith("file://"):
+                if not url.startswith("file:///"):
+                    add("error", "RELATIVE-FILE-URL", rec, url)
+            else:
+                # Bare relative path, resolved from the recipe directory.
+                if url.startswith("/"):
+                    add("warn", "ABSOLUTE-LOCAL-SOURCE", rec, url)
+                elif not (path.parent / url).exists():
+                    add("error", "MISSING-LOCAL-SOURCE", rec, url)
+
+            if index < len(rec["hash"]) and HASH_RE.match(rec["hash"][index]):
+                body = rec["hash"][index][7:]
+                owner = f"{name}:{url}"
+                if body in hash_owner and hash_owner[body] != owner:
+                    prior = hash_owner[body]
+                    # Identical URL in two packages legitimately shares a
+                    # digest (the kernel recipes all build the same tarball).
+                    if prior.split(":", 1)[1] != url:
+                        add("error", "HASH-REUSED-DIFFERENT-SOURCE", rec,
+                            f"{body} is also the hash of {prior}")
+                else:
+                    hash_owner.setdefault(body, owner)
+
+        if rec["source"] and not rec["hash"]:
+            add("error", "SOURCE-WITHOUT-HASH", rec)
+
+        # Dependency hygiene.
+        deps = split_specs(rec["depend"])
+        bdeps = split_specs(rec["builddep"])
+        for group, label in ((deps, "depend"), (bdeps, "builddep"),
+                             (split_specs(rec["optional"]), "optional"),
+                             (split_specs(rec["conflict"]), "conflict")):
+            for spec in group:
+                m = CONSTRAINT_RE.match(spec)
+                if not m:
+                    add("error", "BAD-DEPENDENCY-SPEC", rec, f"{label} {spec}")
+                    continue
+                if m.group(1) == name:
+                    add("error", "SELF-DEPENDENCY", rec, f"{label} {spec}")
+        seen_dep: set[str] = set()
+        for spec in deps:
+            base = spec.split("<")[0].split(">")[0].split("=")[0]
+            if base in seen_dep:
+                add("warn", "DUPLICATE-DEPEND", rec, base)
+            seen_dep.add(base)
+        seen_build: set[str] = set()
+        for spec in bdeps:
+            base = spec.split("<")[0].split(">")[0].split("=")[0]
+            if base in seen_build:
+                add("warn", "DUPLICATE-BUILDDEP", rec, base)
+            seen_build.add(base)
+            if base in seen_dep:
+                # A runtime dependency is always installed before the build,
+                # so naming it again as a build dependency is redundant.
+                add("info", "DEPEND-AND-BUILDDEP", rec, base)
+        for spec in split_specs(rec["conflict"]):
+            base = spec.split("<")[0].split(">")[0].split("=")[0]
+            if base in seen_dep:
+                add("error", "DEPEND-AND-CONFLICT", rec, base)
+
+        # A phase that writes outside $PKG corrupts the build host.
+        for phase in ("prepare", "configure", "build", "install"):
+            for command in rec[phase]:
+                # Only a leading command word counts. The sudo package's own
+                # --with-vardir=/var/db/sudo is not privilege escalation.
+                if re.search(r"(?:^|[;&|]\s*|\$\(\s*)(?:sudo|doas)\s", command):
+                    add("error", "PHASE-USES-SUDO", rec, f"{phase} {command}")
+                if re.search(r"(^|\s)(make|ninja|meson|cmake)\s+install(\s|$)", command) \
+                        and "$PKG" not in command and "DESTDIR" not in command:
+                    add("error", "INSTALL-OUTSIDE-PKG", rec, f"{phase} {command}")
+                if re.search(r"--libdir[= ]/?usr/lib(?![0-9])", command) or \
+                        re.search(r"--libdir[= ]lib(?![0-9])(\s|$)", command):
+                    add("warn", "LIBDIR-NOT-LIB64", rec, f"{phase} {command}")
+
+        # SPS builds CPython with --with-platlibdir=lib64, so a module staged
+        # under /usr/lib/pythonX.Y is somewhere the interpreter never looks.
+        # Judge the recipe as a whole: mentioning that path is fine when the
+        # recipe also relocates it into lib64.
+        phase_text = " ".join(
+            command for phase in ("prepare", "configure", "build", "install")
+            for command in rec[phase])
+        if "/usr/lib/python" in phase_text and "lib64/python" not in phase_text:
+            add("error", "PYTHON-SITE-PACKAGES-LIB", rec,
+                "stages a module under /usr/lib/pythonX.Y and never moves it "
+                "to /usr/lib64")
+
+    # Cross-tree duplicate names. src(1) only rejects duplicates inside one
+    # repository; the same name in core and extra resolves by priority and
+    # quietly shadows one of them.
+    selected = {id(rec) for rec in recipes}
+    for name, group in sorted(by_name.items()):
+        if len(group) < 2:
+            continue
+        trees = {str(r["tree"]) for r in group}
+        level = "error" if len(trees) == 1 else "warn"
+        code = "DUPLICATE-IN-REPOSITORY" if len(trees) == 1 else "SHADOWED-ACROSS-REPOSITORIES"
+        for rec in group:
+            if id(rec) not in selected:
+                continue
+            others = [str(r["path"]) for r in group if r is not rec]
+            findings.append(Finding(level, code, name, rec["path"],
+                                    "also defined at " + ", ".join(others)))
+
+    findings.extend(graph_checks(recipes, by_name))
+    return findings
+
+
+def graph_checks(recipes: list[dict], by_name: dict[str, list[dict]]) -> list[Finding]:
+    """Missing dependencies and dependency cycles across the whole tree."""
+    findings: list[Finding] = []
+    known = set(by_name)
+
+    runtime: dict[str, set[str]] = {}
+    for rec in recipes:
+        name = rec["name"]
+        if not name:
+            continue
+        edges: set[str] = set()
+        for label, records in (("depend", rec["depend"]), ("builddep", rec["builddep"])):
+            for spec in split_specs(records):
+                base = CONSTRAINT_RE.match(spec)
+                if not base:
+                    continue
+                dep = base.group(1)
+                if dep not in known:
+                    findings.append(Finding(
+                        "error", "MISSING-DEPENDENCY", name, rec["path"],
+                        f"{label} {dep} is not provided by any recipe in the tree"))
+                elif label == "depend":
+                    edges.add(dep)
+        runtime.setdefault(name, set()).update(edges)
+
+    # Iterative DFS so a deep tree cannot blow the recursion limit.
+    WHITE, GREY, BLACK = 0, 1, 2
+    colour = dict.fromkeys(runtime, WHITE)
+    reported: set[tuple[str, ...]] = set()
+    for root in sorted(runtime):
+        if colour[root] != WHITE:
+            continue
+        stack = [(root, iter(sorted(runtime.get(root, ()))))]
+        path = [root]
+        colour[root] = GREY
+        while stack:
+            node, children = stack[-1]
+            advanced = False
+            for child in children:
+                if child not in colour:
+                    continue
+                if colour[child] == GREY:
+                    cycle = path[path.index(child):] + [child]
+                    key = tuple(sorted(set(cycle)))
+                    if key not in reported:
+                        reported.add(key)
+                        rec = by_name[child][0]
+                        findings.append(Finding(
+                            "error", "DEPENDENCY-CYCLE", child, rec["path"],
+                            " -> ".join(cycle)))
+                    continue
+                if colour[child] == WHITE:
+                    colour[child] = GREY
+                    path.append(child)
+                    stack.append((child, iter(sorted(runtime.get(child, ())))))
+                    advanced = True
+                    break
+            if not advanced:
+                colour[node] = BLACK
+                stack.pop()
+                if path:
+                    path.pop()
+    return findings
+
+
+def fetch_sha256(url: str, timeout: int) -> tuple[str, int]:
+    """Stream a URL through sha256 without keeping it on disk."""
+    req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "*/*"})
+    ctx = ssl.create_default_context()
+    digest = hashlib.sha256()
+    total = 0
+    with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
+        while True:
+            chunk = resp.read(1 << 20)
+            if not chunk:
+                break
+            digest.update(chunk)
+            total += len(chunk)
+    return digest.hexdigest(), total
+
+
+def net_checks(recipes: list[dict], jobs: int, timeout: int,
+               progress: bool) -> list[Finding]:
+    tasks: list[tuple[dict, str, str]] = []
+    for rec in recipes:
+        for index, source in enumerate(rec["source"]):
+            if index >= len(rec["hash"]):
+                continue
+            url = expand(source, rec)
+            if not url.startswith(("http://", "https://", "ftp://")):
+                continue
+            want = rec["hash"][index]
+            if not HASH_RE.match(want):
+                continue
+            tasks.append((rec, url, want[7:]))
+
+    findings: list[Finding] = []
+    done = 0
+    total = len(tasks)
+
+    def run(task):
+        rec, url, want = task
+        try:
+            got, size = fetch_sha256(url, timeout)
+        except (urllib.error.HTTPError, urllib.error.URLError, OSError,
+                ssl.SSLError, ValueError) as exc:
+            return Finding("error", "SOURCE-UNREACHABLE", rec["name"], rec["path"],
+                           f"{url}\n      {exc}")
+        if got != want:
+            return Finding("error", "HASH-MISMATCH", rec["name"], rec["path"],
+                           f"{url}\n      recorded sha256:{want}\n"
+                           f"      actual   sha256:{got} ({size} bytes)")
+        return None
+
+    with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as pool:
+        for result in pool.map(run, tasks):
+            done += 1
+            if progress and (done % 25 == 0 or done == total):
+                print(f"  verified {done}/{total} sources", file=sys.stderr, flush=True)
+            if result is not None:
+                findings.append(result)
+    return findings
+
+
+def default_trees() -> list[Path]:
+    env = os.environ.get("SPS_RECIPE_TREES")
+    if env:
+        return [Path(p) for p in env.split(":") if p]
+    candidates = [Path("/usr/src/sps/core"), Path("/usr/src/sps/extra")]
+    return [p for p in candidates if p.is_dir()]
+
+
+def main(argv: list[str] | None = None) -> int:
+    ap = argparse.ArgumentParser(
+        prog="audit-recipes",
+        description="Audit SPS recipe trees for install-breaking defects.")
+    ap.add_argument("--trees", help="colon-separated recipe tree roots")
+    ap.add_argument("--only", help="comma-separated package names to audit")
+    ap.add_argument("--net", action="store_true",
+                    help="download every source and verify its SHA-256")
+    ap.add_argument("--jobs", type=int, default=12, help="parallel downloads")
+    ap.add_argument("--timeout", type=int, default=600,
+                    help="per-source download timeout in seconds")
+    ap.add_argument("--fail-level", choices=LEVELS, default="error",
+                    help="lowest severity that makes this command fail")
+    ap.add_argument("--json", action="store_true", help="machine-readable output")
+    ap.add_argument("--quiet", action="store_true", help="only print findings")
+    args = ap.parse_args(argv)
+
+    trees = [Path(p) for p in args.trees.split(":") if p] if args.trees else default_trees()
+    if not trees:
+        print("audit-recipes: no recipe trees; pass --trees or set SPS_RECIPE_TREES",
+              file=sys.stderr)
+        return 2
+    for tree in trees:
+        if not tree.is_dir():
+            print(f"audit-recipes: not a directory: {tree}", file=sys.stderr)
+            return 2
+
+    universe = collect(trees)
+    recipes = universe
+    if args.only:
+        wanted = {n.strip() for n in args.only.split(",") if n.strip()}
+        recipes = [r for r in universe if r["name"] in wanted]
+        unknown = wanted - {r["name"] for r in recipes}
+        for name in sorted(unknown):
+            print(f"audit-recipes: no recipe named {name}", file=sys.stderr)
+    if not recipes:
+        print("audit-recipes: no recipes found", file=sys.stderr)
+        return 2
+
+    if not args.quiet:
+        print(f"audit-recipes: {len(recipes)} recipes in "
+              f"{', '.join(str(t) for t in trees)}", file=sys.stderr)
+
+    findings = static_checks(recipes, universe)
+    if args.net:
+        if not args.quiet:
+            print("audit-recipes: verifying sources over the network",
+                  file=sys.stderr)
+        findings.extend(net_checks(recipes, args.jobs, args.timeout,
+                                   not args.quiet))
+
+    findings.sort(key=lambda f: (-LEVELS.index(f.level), f.code, f.package))
+
+    if args.json:
+        json.dump({"recipes": len(recipes),
+                   "findings": [f.as_dict() for f in findings]},
+                  sys.stdout, indent=2)
+        sys.stdout.write("\n")
+    else:
+        for finding in findings:
+            print(finding)
+        counts = {level: 0 for level in LEVELS}
+        for finding in findings:
+            counts[finding.level] += 1
+        if not args.quiet or findings:
+            print(f"\n{len(recipes)} recipes: "
+                  f"{counts['error']} error, {counts['warn']} warn, "
+                  f"{counts['info']} info", file=sys.stderr)
+
+    return 1 if any(level_at_least(f.level, args.fail_level) for f in findings) else 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/tools/new-recipe b/tools/new-recipe
new file mode 100755
index 0000000..1c1ee80
--- /dev/null
+++ b/tools/new-recipe
@@ -0,0 +1,709 @@
+#!/usr/bin/env python3
+"""Draft SPS recipes for upstream projects.
+
+Writing a recipe by hand means finding the newest release, downloading it,
+computing a SHA-256, working out the build system, and translating upstream's
+dependency names into SPS package names. This tool does that from upstream
+metadata so a maintainer reviews a draft instead of typing one.
+
+Every recipe it writes carries a real digest. Nothing is guessed: a source
+that cannot be resolved and hashed is reported and skipped.
+
+    tools/new-recipe --tree /usr/src/sps/extra pypi:requests
+    tools/new-recipe --tree /usr/src/sps/extra --category devel pypi:pytest
+    tools/new-recipe --tree /usr/src/sps/extra --from-file wanted.txt
+    tools/new-recipe --tree /usr/src/sps/extra url:https://x.org/foo-1.2.tar.xz
+
+A drafted recipe is a starting point, not a finished package. Build it before
+you commit it, and run tools/audit-recipes over the tree afterwards.
+"""
+from __future__ import annotations
+
+import argparse
+import concurrent.futures
+import hashlib
+import io
+import json
+import os
+import re
+import ssl
+import sys
+import tarfile
+import urllib.error
+import urllib.parse
+import urllib.request
+import zipfile
+from pathlib import Path
+
+# See tools/audit-recipes: a browser User-Agent makes SourceForge and friends
+# answer with an HTML interstitial instead of the archive.
+UA = "curl/8.5.0"
+
+PYPI = "https://pypi.org/pypi/{}/json"
+CRATES = "https://crates.io/api/v1/crates/{}"
+METACPAN = "https://fastapi.metacpan.org/v1/release/{}"
+GITHUB_API = "https://api.github.com"
+
+# PyPI build backends that SPS packages, so --no-build-isolation works.
+BACKEND_PACKAGES = {
+    "setuptools.build_meta": "python-setuptools",
+    "setuptools.build_meta:__legacy__": "python-setuptools",
+    "flit_core.buildapi": "python-flit-core",
+    "flit.buildapi": "python-flit-core",
+    "hatchling.build": "python-hatchling",
+    "hatchling.ouroboros": "python-hatchling",
+    "poetry.core.masonry.api": "python-poetry-core",
+    "poetry.masonry.api": "python-poetry-core",
+    "pdm.backend": "python-pdm-backend",
+    "maturin": "python-maturin",
+    "scikit_build_core.build": "python-scikit-build-core",
+    "mesonpy": "python-meson-python",
+}
+
+# Upstream pkg-config / distribution names that do not match the SPS name.
+NAME_OVERRIDES = {
+    "pillow": "python-pillow",
+    "pyyaml": "python-yaml",
+    "beautifulsoup4": "python-beautifulsoup4",
+}
+
+
+# Staged Python modules must end up under /usr/lib64, which is where a CPython
+# configured with --with-platlibdir=lib64 looks. Written as one logical recipe
+# line with continuations so it stays a single `install` record.
+PY_LIB64_FIXUP = (
+    'pyver=$(python3 -c \'import sys; print("%d.%d" % sys.version_info[:2])\'); \\\n'
+    '            if [ -d "$PKG/usr/lib/python$pyver" ]; then \\\n'
+    '            mkdir -p "$PKG/usr/lib64"; \\\n'
+    '            cp -a "$PKG/usr/lib/python$pyver/." "$PKG/usr/lib64/python$pyver/"; \\\n'
+    '            rm -rf "$PKG/usr/lib/python$pyver"; \\\n'
+    '            rmdir "$PKG/usr/lib" 2>/dev/null || :; fi'
+)
+
+
+class ResolveError(RuntimeError):
+    pass
+
+
+def http_bytes(url: str, timeout: int = 60, accept: str = "") -> bytes:
+    headers = {"User-Agent": UA}
+    if accept:
+        headers["Accept"] = accept
+    token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
+    if token and "api.github.com" in url:
+        headers["Authorization"] = f"Bearer {token}"
+    req = urllib.request.Request(url, headers=headers)
+    ctx = ssl.create_default_context()
+    try:
+        with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
+            return resp.read()
+    except (urllib.error.HTTPError, urllib.error.URLError, OSError) as exc:
+        raise ResolveError(f"{url}: {exc}") from exc
+
+
+def http_json(url: str, timeout: int = 60) -> dict:
+    try:
+        return json.loads(http_bytes(url, timeout, "application/json"))
+    except json.JSONDecodeError as exc:
+        raise ResolveError(f"{url}: invalid JSON: {exc}") from exc
+
+
+def fetch_and_hash(url: str, timeout: int = 900) -> tuple[bytes, str]:
+    data = http_bytes(url, timeout)
+    return data, hashlib.sha256(data).hexdigest()
+
+
+def sps_python_name(dist: str) -> str:
+    """Translate a PyPI distribution name into an SPS package name."""
+    slug = re.sub(r"[_.]+", "-", dist.strip().lower())
+    if slug in NAME_OVERRIDES:
+        return NAME_OVERRIDES[slug]
+    if slug.startswith("python-"):
+        return slug
+    return f"python-{slug}"
+
+
+def parse_requires_dist(entries: list[str] | None) -> list[str]:
+    """Runtime dependencies only: drop extras and non-matching markers.
+
+    A requirement guarded by `extra == "..."` is optional by definition, and
+    one guarded by a python_version or sys_platform marker that cannot apply
+    here would install a package nothing imports.
+    """
+    out: list[str] = []
+    for entry in entries or []:
+        spec = entry.split(";", 1)
+        name = re.split(r"[\s(\[<>=!~]", spec[0].strip(), maxsplit=1)[0].strip()
+        if not name:
+            continue
+        if len(spec) > 1:
+            marker = spec[1]
+            if "extra" in marker:
+                continue
+            if "sys_platform" in marker and "linux" not in marker:
+                continue
+            if re.search(r"platform_system\s*==\s*[\"'](Windows|Darwin)", marker):
+                continue
+        mapped = sps_python_name(name)
+        if mapped not in out:
+            out.append(mapped)
+    return out
+
+
+def read_member(data: bytes, filename: str, wanted: str) -> str | None:
+    """Read one file out of an sdist without unpacking it to disk."""
+    try:
+        if filename.endswith(".zip"):
+            with zipfile.ZipFile(io.BytesIO(data)) as zf:
+                for name in zf.namelist():
+                    if name.split("/", 1)[-1] == wanted:
+                        return zf.read(name).decode("utf-8", "replace")
+            return None
+        with tarfile.open(fileobj=io.BytesIO(data), mode="r:*") as tf:
+            for member in tf.getmembers():
+                if not member.isfile():
+                    continue
+                parts = member.name.split("/", 1)
+                if len(parts) == 2 and parts[1] == wanted:
+                    handle = tf.extractfile(member)
+                    if handle is None:
+                        return None
+                    return handle.read().decode("utf-8", "replace")
+    except (tarfile.TarError, zipfile.BadZipFile, OSError, EOFError):
+        return None
+    return None
+
+
+def detect_pypi_backend(data: bytes, filename: str) -> str | None:
+    """Return the SPS package providing this sdist's build backend.
+
+    None means no build dependency is needed. That is the case for a
+    self-hosting backend: setuptools and flit_core declare `backend-path`, so
+    pip imports the backend out of the unpacked sdist itself and the package
+    can be built before any backend is installed. Without that, the bootstrap
+    would be circular.
+    """
+    pyproject = read_member(data, filename, "pyproject.toml")
+    if pyproject:
+        if re.search(r"^\s*backend-path\s*=", pyproject, re.M):
+            return None
+        match = re.search(r'build-backend\s*=\s*["\']([^"\']+)["\']', pyproject)
+        if match:
+            backend = match.group(1).strip()
+            if backend in BACKEND_PACKAGES:
+                return BACKEND_PACKAGES[backend]
+            root = backend.split(".", 1)[0].split(":", 1)[0]
+            return f"python-{root.replace('_', '-')}"
+        # A pyproject.toml with no build-backend defaults to setuptools.
+        return "python-setuptools"
+    return "python-setuptools"
+
+
+def resolve_pypi(spec: str) -> dict:
+    dist = spec
+    doc = http_json(PYPI.format(urllib.parse.quote(dist)))
+    info = doc["info"]
+    version = info["version"]
+    sdist = None
+    for entry in doc.get("urls", []):
+        if entry.get("packagetype") == "sdist":
+            sdist = entry
+            break
+    if sdist is None:
+        raise ResolveError(f"pypi:{dist} {version} publishes no source distribution")
+    url = sdist["url"]
+    digest = sdist["digests"]["sha256"]
+    filename = sdist["filename"]
+
+    # The sdist is needed only to read pyproject.toml. Verify it against the
+    # digest PyPI published rather than trusting the transfer.
+    backend: str | None = "python-setuptools"
+    try:
+        data = http_bytes(url, 300)
+        if hashlib.sha256(data).hexdigest() != digest:
+            raise ResolveError(f"pypi:{dist}: sdist digest does not match PyPI metadata")
+        backend = detect_pypi_backend(data, filename)
+    except ResolveError:
+        raise
+    except Exception:  # noqa: BLE001 - backend detection is best effort
+        pass
+
+    summary = (info.get("summary") or f"{dist} Python module").strip()
+    summary = summary.rstrip(".")
+    name = sps_python_name(dist)
+    depends = ["filesystem", "python"] + parse_requires_dist(info.get("requires_dist"))
+    # python is built --with-ensurepip=install, so pip needs no package.
+    builddeps = [backend] if backend else []
+
+    # Rebuild the URL from ${version} where the filename embeds it, so a later
+    # version bump only has to change one line.
+    source = url
+    if f"-{version}." in url:
+        source = url.replace(f"-{version}.", "-${version}.")
+
+    return {
+        "name": name,
+        "version": version,
+        "description": f"{name}: {summary}",
+        "arch": "any",
+        "source": source,
+        "hash": digest,
+        "depend": depends,
+        "builddep": [d for d in builddeps if d != name],
+        "phases": [
+            ("install", 'python3 -m pip install --no-deps --no-build-isolation '
+                        '--no-compile --root="$PKG" --prefix=/usr .'),
+            # pip follows the running interpreter's install scheme. SPS builds
+            # CPython with --with-platlibdir=lib64, but a build driven by a
+            # host interpreter that uses plain "lib" would stage the module
+            # where the target python never looks. Normalize it either way.
+            ("install", PY_LIB64_FIXUP),
+        ],
+    }
+
+
+def resolve_crate(spec: str) -> dict:
+    doc = http_json(CRATES.format(urllib.parse.quote(spec)))
+    crate = doc["crate"]
+    version = crate["max_stable_version"]
+    checksum = None
+    for entry in doc.get("versions", []):
+        if entry.get("num") == version:
+            checksum = entry.get("checksum")
+            break
+    if not checksum:
+        raise ResolveError(f"crate:{spec}: no checksum for {version}")
+    summary = (crate.get("description") or f"{spec} Rust crate").strip().rstrip(".")
+    return {
+        "name": spec,
+        "version": version,
+        "description": f"{spec}: {summary}",
+        "arch": "",
+        "source": f"https://static.crates.io/crates/{spec}/{spec}-${{version}}.crate",
+        "hash": checksum,
+        "depend": ["filesystem"],
+        "builddep": ["rust"],
+        "phases": [
+            ("build", 'cargo build --release --locked'),
+            ("install", 'install -Dm755 "target/release/%s" "$PKG/usr/bin/%s"' % (spec, spec)),
+        ],
+    }
+
+
+# Dual-life distributions that ship inside perl itself. They exist on CPAN, so
+# nothing stops a resolver from drafting them, but packaging them separately
+# would shadow files the perl package already owns. Depending on them is also
+# pointless: installing perl already satisfies the requirement.
+CORE_PERL_DISTS = {
+    "archive-tar", "attribute-handlers", "autodie", "autouse", "base",
+    "bignum", "carp", "compress-raw-bzip2", "compress-raw-lzma",
+    "compress-raw-zlib", "config-perl-v", "constant", "cpan", "data-dumper",
+    "db_file", "devel-ppport", "digest", "digest-md5", "digest-sha",
+    "dumpvalue", "encode", "encoding-warnings", "env", "errno",
+    "experimental", "exporter", "extutils-cbuilder", "extutils-constant",
+    "extutils-install", "extutils-makemaker", "extutils-manifest",
+    "extutils-parsexs", "file-fetch", "file-path", "file-temp",
+    "filter-simple", "filter-util-call", "getopt-long", "i18n-langtags",
+    "if", "io", "io-compress", "io-socket-ip", "io-zlib", "ipc-cmd",
+    "ipc-sysv", "json-pp", "libnet", "locale-maketext",
+    "locale-maketext-simple", "math-bigint", "math-bigint-fastcalc",
+    "math-bigrat", "math-complex", "memoize", "mime-base64",
+    "module-corelist", "module-load", "module-load-conditional",
+    "module-loaded", "module-metadata", "net-ping", "params-check",
+    "parent", "pathtools", "perl-ostype", "perlfaq", "perlio-via-quotedprint",
+    "pod-checker", "pod-escapes", "pod-perldoc", "pod-simple", "pod-usage",
+    "podlators", "safe", "scalar-list-utils", "search-dict",
+    "selfloader", "socket", "storable", "sys-syslog", "term-ansicolor",
+    "term-cap", "term-complete", "term-readline", "test", "test-harness",
+    "test-simple", "text-abbrev", "text-balanced", "text-parsewords",
+    "text-tabs+wrap", "thread-queue", "thread-semaphore", "tie-file",
+    "tie-refhash", "time-hires", "time-local", "time-piece",
+    "unicode-collate", "unicode-normalize", "version", "xsloader",
+}
+
+_CPAN_DIST_CACHE: dict[str, str | None] = {}
+
+
+def cpan_distribution_of(module: str) -> str | None:
+    """Map a Perl module name to its CPAN distribution.
+
+    Returns None for modules that ship with perl itself, which must not become
+    packages: there is no separate release to build, and depending on one
+    would be an unsatisfiable dependency.
+    """
+    if module in _CPAN_DIST_CACHE:
+        return _CPAN_DIST_CACHE[module]
+    result: str | None = None
+    try:
+        doc = http_json(
+            "https://fastapi.metacpan.org/v1/module/"
+            f"{urllib.parse.quote(module)}?fields=distribution", 30)
+        dist = doc.get("distribution")
+        if dist and dist != "perl" and dist.lower() not in CORE_PERL_DISTS:
+            result = dist
+    except ResolveError:
+        result = None
+    _CPAN_DIST_CACHE[module] = result
+    return result
+
+
+def cpan_release(module: str) -> dict:
+    """Fetch a release document, tolerating the wrong case.
+
+    The /release/NAME endpoint matches the distribution name exactly, so
+    "html-treebuilder" 404s where "HTML-Tree" succeeds. Fall back to a search
+    that is case-insensitive and also resolves a module name to its release.
+    """
+    try:
+        return http_json(METACPAN.format(urllib.parse.quote(module)))
+    except ResolveError:
+        pass
+    query = urllib.parse.quote(module.replace("-", " "))
+    url = ("https://fastapi.metacpan.org/v1/release/_search"
+           f"?q=distribution:{query}%20AND%20status:latest&size=5")
+    doc = http_json(url)
+    hits = doc.get("hits", {}).get("hits", [])
+    for hit in hits:
+        source = hit.get("_source", {})
+        if (source.get("distribution", "")).lower() == module.lower():
+            return source
+    # A module name rather than a distribution name: ask which release owns it.
+    owner = http_json(
+        "https://fastapi.metacpan.org/v1/module/"
+        f"{urllib.parse.quote(module.replace('-', '::'))}?fields=distribution", 30)
+    dist = owner.get("distribution")
+    if not dist:
+        raise ResolveError(f"cpan:{module}: no release found")
+    return http_json(METACPAN.format(urllib.parse.quote(dist)))
+
+
+def cpan_runtime_depends(doc: dict) -> list[str]:
+    modules: list[str] = []
+    for entry in doc.get("dependency") or []:
+        if entry.get("phase") != "runtime":
+            continue
+        if entry.get("relationship") != "requires":
+            continue
+        module = entry.get("module", "")
+        if not module or module == "perl":
+            continue
+        modules.append(module)
+    out: list[str] = []
+    for module in dict.fromkeys(modules):
+        dist = cpan_distribution_of(module)
+        if not dist:
+            continue
+        name = "perl-" + dist.lower()
+        if name not in out:
+            out.append(name)
+    return out
+
+
+def resolve_cpan(spec: str) -> dict:
+    module = spec.replace("::", "-")
+    doc = cpan_release(module)
+    version = str(doc["version"])
+    url = doc["download_url"]
+    _data, digest = fetch_and_hash(url)
+    summary = (doc.get("abstract") or f"{spec} Perl module").strip().rstrip(".")
+    dist = doc.get("distribution") or module
+    name = "perl-" + dist.lower()
+    source = url.replace(f"-{version}.", "-${version}.") \
+        if f"-{version}." in url else url
+
+    uses_build_pl = doc.get("metadata", {}).get("generated_by", "")
+    phases = [
+        ("configure", 'perl Makefile.PL INSTALLDIRS=vendor'),
+        ("build", 'make -j"$MAKEJOBS"'),
+        ("install", 'make install DESTDIR="$PKG"'),
+    ]
+    if "Module::Build" in uses_build_pl:
+        phases = [
+            ("configure", 'perl Build.PL installdirs=vendor'),
+            ("build", './Build'),
+            ("install", './Build install destdir="$PKG"'),
+        ]
+    depends = ["filesystem", "perl"]
+    for dep in cpan_runtime_depends(doc):
+        if dep != name and dep not in depends:
+            depends.append(dep)
+    return {
+        "name": name,
+        "version": version,
+        "description": f"{name}: {summary}",
+        "arch": "",
+        "source": source,
+        "hash": digest,
+        "depend": depends,
+        "builddep": [],
+        "phases": phases,
+    }
+
+
+def resolve_github(spec: str) -> dict:
+    if "/" not in spec:
+        raise ResolveError(f"github:{spec} must be owner/repo")
+    owner, repo = spec.split("/", 1)
+    tags = http_json(f"{GITHUB_API}/repos/{owner}/{repo}/tags?per_page=50")
+    chosen = None
+    for tag in tags:
+        name = tag.get("name", "")
+        if re.search(r"(alpha|beta|rc|pre|dev|snapshot)", name, re.I):
+            continue
+        chosen = name
+        break
+    if not chosen:
+        raise ResolveError(f"github:{spec}: no stable tag found")
+    version = chosen.lstrip("vV")
+    # Always the refs/tags spelling: a repository with both a tag and a branch
+    # of the same name rejects the short /archive/NAME.tar.gz form.
+    url = f"https://github.com/{owner}/{repo}/archive/refs/tags/{chosen}.tar.gz"
+    data, digest = fetch_and_hash(url)
+    source = url.replace(f"/{chosen}.tar.gz", "/${version}.tar.gz") \
+        if chosen == version else url
+    meta = {}
+    try:
+        meta = http_json(f"{GITHUB_API}/repos/{owner}/{repo}")
+    except ResolveError:
+        pass
+    summary = (meta.get("description") or f"{repo}").strip().rstrip(".")
+    phases = detect_c_build(data)
+    return {
+        "name": repo.lower(),
+        "version": version,
+        "description": f"{repo.lower()}: {summary}",
+        "arch": "",
+        "source": source,
+        "hash": digest,
+        "depend": ["filesystem"],
+        "builddep": phases[1],
+        "phases": phases[0],
+    }
+
+
+def resolve_url(spec: str) -> dict:
+    url = spec
+    data, digest = fetch_and_hash(url)
+    leaf = url.rsplit("/", 1)[-1]
+    match = re.match(r"^(?P<name>[A-Za-z0-9_+.-]+?)[-_](?P<version>[0-9][0-9A-Za-z.]*)"
+                     r"(?:\.orig)?\.(?:tar\.(?:gz|xz|bz2|zst)|tgz|txz|tbz2|zip)$", leaf)
+    if not match:
+        raise ResolveError(f"url:{url}: cannot infer name and version from '{leaf}'")
+    name = match.group("name").lower()
+    version = match.group("version").rstrip(".")
+    source = url.replace(version, "${version}", 1) if version in url else url
+    phases = detect_c_build(data)
+    return {
+        "name": name,
+        "version": version,
+        "description": f"{name}: TODO write a one-line description",
+        "arch": "",
+        "source": source,
+        "hash": digest,
+        "depend": ["filesystem"],
+        "builddep": phases[1],
+        "phases": phases[0],
+    }
+
+
+def detect_c_build(data: bytes) -> tuple[list[tuple[str, str]], list[str]]:
+    """Pick build phases by looking at the archive's top-level files."""
+    top: set[str] = set()
+    try:
+        with tarfile.open(fileobj=io.BytesIO(data), mode="r:*") as tf:
+            for member in tf.getmembers()[:4000]:
+                parts = member.name.split("/")
+                if len(parts) == 2 and parts[1]:
+                    top.add(parts[1])
+    except (tarfile.TarError, OSError, EOFError):
+        pass
+
+    if "meson.build" in top:
+        return ([
+            ("configure", 'meson setup build --prefix=/usr --libdir=lib64 '
+                          '--buildtype=release'),
+            ("build", 'meson compile -C build -j "$MAKEJOBS"'),
+            ("install", 'DESTDIR="$PKG" meson install -C build'),
+        ], ["meson", "ninja", "pkgconf"])
+    if "CMakeLists.txt" in top:
+        return ([
+            ("configure", 'cmake -S . -B build -G Ninja -DCMAKE_INSTALL_PREFIX=/usr '
+                          '-DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_LIBDIR=lib64'),
+            ("build", 'cmake --build build --parallel "$MAKEJOBS"'),
+            ("install", 'DESTDIR="$PKG" cmake --install build'),
+        ], ["cmake", "ninja", "pkgconf"])
+    if "configure" in top:
+        return ([
+            ("configure", './configure --prefix=/usr --libdir=/usr/lib64 --disable-static'),
+            ("build", 'make -j"$MAKEJOBS"'),
+            ("install", 'make install DESTDIR="$PKG"'),
+        ], ["pkgconf"])
+    if "configure.ac" in top or "configure.in" in top or "autogen.sh" in top:
+        return ([
+            ("prepare", './autogen.sh || autoreconf -fi'),
+            ("configure", './configure --prefix=/usr --libdir=/usr/lib64 --disable-static'),
+            ("build", 'make -j"$MAKEJOBS"'),
+            ("install", 'make install DESTDIR="$PKG"'),
+        ], ["autoconf", "automake", "libtool", "pkgconf"])
+    if "Cargo.toml" in top:
+        return ([
+            ("build", 'cargo build --release --locked'),
+            ("install", 'TODO install the built binaries into "$PKG"'),
+        ], ["rust"])
+    if "go.mod" in top:
+        return ([
+            ("build", 'go build -trimpath -ldflags "-s -w" ./...'),
+            ("install", 'TODO install the built binaries into "$PKG"'),
+        ], ["go"])
+    if "Makefile" in top or "GNUmakefile" in top:
+        return ([
+            ("build", 'make -j"$MAKEJOBS"'),
+            ("install", 'make install PREFIX=/usr DESTDIR="$PKG"'),
+        ], [])
+    return ([
+        ("install", 'TODO write the install phase'),
+    ], [])
+
+
+RESOLVERS = {
+    "pypi": resolve_pypi,
+    "crate": resolve_crate,
+    "cpan": resolve_cpan,
+    "github": resolve_github,
+    "url": resolve_url,
+    "https": lambda s: resolve_url("https:" + s),
+    "http": lambda s: resolve_url("http:" + s),
+}
+
+
+def resolve(spec: str) -> dict:
+    if ":" not in spec:
+        raise ResolveError(f"'{spec}' must be SCHEME:VALUE (pypi:, cpan:, crate:, github:, url:)")
+    scheme, value = spec.split(":", 1)
+    scheme = scheme.lower()
+    if scheme not in RESOLVERS:
+        raise ResolveError(f"unknown scheme '{scheme}' in '{spec}'")
+    return RESOLVERS[scheme](value)
+
+
+def render(rec: dict) -> str:
+    lines = [
+        f"name        {rec['name']}",
+        f"version     {rec['version']}",
+        "release     1",
+    ]
+    if rec.get("arch"):
+        lines.append(f"arch        {rec['arch']}")
+    lines.append(f"description {rec['description']}")
+    lines.append("")
+    lines.append(f"source      {rec['source']}")
+    lines.append(f"hash        sha256:{rec['hash']}")
+    lines.append("")
+    for dep in rec["depend"]:
+        lines.append(f"depend      {dep}")
+    for dep in rec.get("builddep", []):
+        lines.append(f"builddep    {dep}")
+    if rec["depend"] or rec.get("builddep"):
+        lines.append("")
+    for key, command in rec["phases"]:
+        lines.append(f"{key:<11} {command}")
+    return "\n".join(lines) + "\n"
+
+
+def default_category(rec: dict, override: str | None) -> str:
+    if override:
+        return override
+    name = rec["name"]
+    if name.startswith("python-"):
+        return "python"
+    if name.startswith("perl-"):
+        return "perl"
+    return "libs"
+
+
+def main(argv: list[str] | None = None) -> int:
+    ap = argparse.ArgumentParser(
+        prog="new-recipe",
+        description="Draft SPS recipes from upstream metadata.")
+    ap.add_argument("specs", nargs="*", metavar="SPEC",
+                    help="pypi:NAME, cpan:Module::Name, crate:NAME, "
+                         "github:owner/repo, or url:URL")
+    ap.add_argument("--tree", help="recipe tree to write into")
+    ap.add_argument("--category", help="category directory (default: by kind)")
+    ap.add_argument("--from-file", help="read one SPEC per line from this file")
+    ap.add_argument("--jobs", type=int, default=8, help="parallel resolutions")
+    ap.add_argument("--force", action="store_true",
+                    help="overwrite an existing recipe")
+    ap.add_argument("--print", dest="print_only", action="store_true",
+                    help="write nothing; print the drafted recipes")
+    args = ap.parse_args(argv)
+
+    specs = list(args.specs)
+    if args.from_file:
+        for line in Path(args.from_file).read_text(encoding="utf-8").splitlines():
+            line = line.split("#", 1)[0].strip()
+            if line:
+                specs.append(line)
+    if not specs:
+        ap.error("no specs given")
+    if not args.print_only and not args.tree:
+        ap.error("--tree is required unless --print is used")
+
+    tree = Path(args.tree) if args.tree else None
+    if tree is not None and not tree.is_dir():
+        print(f"new-recipe: not a directory: {tree}", file=sys.stderr)
+        return 2
+
+    existing: set[str] = set()
+    if tree is not None:
+        for path in tree.rglob("recipe"):
+            for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
+                if line.startswith("name"):
+                    parts = line.split(None, 1)
+                    if len(parts) == 2:
+                        existing.add(parts[1].strip())
+                    break
+
+    written = 0
+    skipped = 0
+    failed = 0
+
+    def work(spec: str):
+        try:
+            return spec, resolve(spec), None
+        except ResolveError as exc:
+            return spec, None, str(exc)
+        except Exception as exc:  # noqa: BLE001
+            return spec, None, f"{type(exc).__name__}: {exc}"
+
+    with concurrent.futures.ThreadPoolExecutor(max_workers=args.jobs) as pool:
+        for spec, rec, error in pool.map(work, specs):
+            if error:
+                print(f"new-recipe: {spec}: {error}", file=sys.stderr)
+                failed += 1
+                continue
+            text = render(rec)
+            if args.print_only:
+                print(f"# ---- {spec} ----")
+                print(text)
+                written += 1
+                continue
+            if rec["name"] in existing and not args.force:
+                print(f"new-recipe: {rec['name']} already exists; skipping",
+                      file=sys.stderr)
+                skipped += 1
+                continue
+            dest = tree / default_category(rec, args.category) / rec["name"]
+            dest.mkdir(parents=True, exist_ok=True)
+            (dest / "recipe").write_text(text, encoding="utf-8")
+            existing.add(rec["name"])
+            print(f"new-recipe: wrote {dest / 'recipe'}")
+            written += 1
+
+    print(f"\n{written} drafted, {skipped} skipped, {failed} failed",
+          file=sys.stderr)
+    return 1 if failed else 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())