CANP-WP-0002 T07: semver precedence and strict packaging

Two defects from the original review of the seed.

Prerelease ordering was worse than first recorded. parse_semver returned
(major, minor, patch, raw_string), so 1.0.0-rc1 and 1.0.0 tied on the numeric
fields and then compared as strings — "1.0.0-rc1" > "1.0.0". A release
candidate therefore shadowed its own release for `newest` and for `>=`, not
just for the no-version case.

parse_semver now returns a SemVer section 11 precedence key: numeric fields, a
release/prerelease rank, then dot-separated prerelease identifiers with
numeric ones compared numerically. Build metadata is ignored. Beyond ordering,
prereleases are excluded from `any`, `newest` and `>=` entirely; only an exact
pin selects one, so publishing a release candidate never changes what existing
consumers resolve to. A package holding only prereleases now says so rather
than reporting a bare not-found.

Packaging copied the whole source directory, so a stray .git, virtualenv or
scratch file landed in the catalog and registry. Section 2 already required
otherwise — tools MUST ignore unknown non-reserved files unless a manifest
field references them — so this is conformance rather than a new rule. What is
new is that omissions are reported instead of silent:

  not packaged (not a reserved path, not referenced by the manifest): .git/, .venv/, notes.txt

LICENSE joins the reserved paths. Strict packaging would otherwise drop a
package's license text while faithfully copying its `license` field, which
contradicts section 14's instruction to surface licensing on publish and
install.

Spec: 2 (LICENSE, packaging obligation, reporting), 17.1 new, 10.3 note.

Reference CLI: parse_semver rewritten with is_prerelease; select_version and
pick_version updated; copy_package and report_skipped replace copy_immutable.
Tests 65 -> 78.

examples/pqrst-estimate carries a LICENSE and a license field, exercising the
new reserved path.

Also fixes a leaked loop variable in package_members that would have reported
a bad `template` path as an `evals` error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bjefh8NUiEiahN4JLwoSKM

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 388925@bnt-lap001
Assistant-Session: 3507023f-e0fd-4a1e-9d90-a0d4217d1502
This commit is contained in:
tegwick 2026-09-06 09:32:42 +02:00
parent e885eb7b05
commit 95a8bb31d2
8 changed files with 306 additions and 34 deletions

View file

@ -66,6 +66,12 @@ rather than resolved by guessing.
- `{{ name }}` template substitution only.
- No arbitrary expression/code execution.
- Published versions are immutable by default.
- `add`, `publish` and `install` copy only reserved paths and manifest-
referenced files. A working directory's `.git`, virtualenv or scratch files
never ship; whatever is left out is named on stderr.
- Version precedence follows SemVer: a prerelease ranks below its release, and
`any`, `newest` and `>=` skip prereleases entirely. Only an exact pin
selects one.
- `install` copies from registry to catalog.
- `add` copies a package directly to catalog.
- `search`, `show`, `resolve`, and `render` operate on catalog packages, and

View file

@ -26,8 +26,14 @@ REGISTRY_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
EVAL_RUBRIC_SCHEMA = "canned-prompts/eval-rubric/v0.1"
RENDER_CHECKS = ("contains", "not_contains", "resolves_all")
CAPABILITY_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
RESERVED_FILES = ("prompt.yaml", "README.md", "LICENSE")
RESERVED_DIRS = ("examples", "evals", "assets")
PLACEHOLDER_RE = re.compile(r"{{\s*([A-Za-z_][A-Za-z0-9_.-]*)\s*}}")
SEMVER_RE = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:[-+].*)?$")
SEMVER_RE = re.compile(
r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?"
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
)
REQUIRED_FIELDS = ("format", "id", "name", "version", "summary", "template")
@ -481,25 +487,52 @@ def select_version(available: list[str], selector: str | None) -> str | None:
Each dependency is selected independently against what is present. There is
no constraint solving across a dependency graph; that stays a non-goal.
Prereleases are excluded from `any`, `newest` and `>=` (§ 17.1); only an
exact pin selects one, so publishing a release candidate never changes what
existing consumers resolve to.
"""
if not available:
return None
if selector is None or selector in ("any", "newest"):
return available[0]
if selector.startswith(">="):
floor = parse_semver(selector[2:])
for version in available:
if parse_semver(version) >= floor:
return version
if selector is not None and selector not in ("any", "newest") and not selector.startswith(">="):
return selector if selector in available else None
stable = [version for version in available if not is_prerelease(version)]
if not stable:
return None
return selector if selector in available else None
if selector is None or selector in ("any", "newest"):
return stable[0]
floor = parse_semver(selector[2:])
for version in stable:
if parse_semver(version) >= floor:
return version
return None
def parse_semver(value: str) -> tuple[int, int, int, str]:
def parse_semver(value: str) -> tuple[Any, ...]:
"""A precedence key following SemVer § 11 (spec § 17.1).
A prerelease ranks below its own release, so `1.0.0-rc1` < `1.0.0`. Build
metadata is ignored. Unparseable versions sort below everything.
"""
match = SEMVER_RE.match(value)
if not match:
return (-1, -1, -1, value)
return (int(match.group(1)), int(match.group(2)), int(match.group(3)), value)
return (-1, -1, -1, 0, (), value)
major, minor, patch = (int(match.group(i)) for i in (1, 2, 3))
prerelease = match.group(4)
if prerelease is None:
# 1 outranks the 0 given to a prerelease of the same numeric version.
return (major, minor, patch, 1, (), "")
identifiers: list[tuple[int, int, str]] = []
for part in prerelease.split("."):
# Numeric identifiers compare numerically and rank below alphanumeric ones.
identifiers.append((0, int(part), "") if part.isdigit() else (1, 0, part))
return (major, minor, patch, 0, tuple(identifiers), "")
def is_prerelease(value: str) -> bool:
match = SEMVER_RE.match(value)
return bool(match and match.group(4) is not None)
def versions_at(base: Path) -> list[str]:
@ -510,15 +543,21 @@ def versions_at(base: Path) -> list[str]:
def pick_version(base: Path, label: str, version: str | None) -> Path:
available = versions_at(base)
if version:
path = base / version
if not (path / "prompt.yaml").is_file():
raise CannedPromptError(f"package not found: {label}@{version}")
return path
versions = versions_at(base)
if not versions:
if not available:
raise CannedPromptError(f"package not found: {label}")
return base / versions[0]
chosen = select_version(available, None)
if chosen is None:
raise CannedPromptError(
f"{label}: only prerelease versions are available "
f"({', '.join(available)}); name one exactly to use it"
)
return base / chosen
def resolve_in_registry(registry: Path, package_id: str, version: str | None) -> Path:
@ -578,11 +617,69 @@ def resolve_installed(
return pick_version(id_path(catalog / name, package_id), f"{name}:{package_id}", version), name
def copy_immutable(src: Path, dst: Path, what: str) -> None:
def package_members(package_dir: Path, manifest: dict[str, Any]) -> set[str]:
"""Relative paths that belong to the package (§ 2).
Reserved paths plus whatever a manifest field references. Everything else in
the working directory `.git`, a virtualenv, scratch files is not part of
the package.
"""
members: set[str] = set()
for name in RESERVED_FILES:
if (package_dir / name).is_file():
members.add(name)
for name in RESERVED_DIRS:
directory = package_dir / name
if directory.is_dir():
for path in directory.rglob("*"):
if path.is_file():
members.add(path.relative_to(package_dir).as_posix())
referenced = [("template", manifest["template"])]
for key in ("examples", "evals"):
referenced.extend((key, relative) for relative in (manifest.get(key) or []))
for field, relative in referenced:
resolved = safe_relative_file(package_dir, relative, field)
members.add(resolved.relative_to(package_dir.resolve()).as_posix())
return members
def skipped_entries(package_dir: Path, members: set[str]) -> list[str]:
"""Top-level entries that will not be packaged.
Reported by name only; nothing walks into an ignored directory, so a stray
virtualenv costs nothing to skip.
"""
covered = {member.split("/", 1)[0] for member in members}
skipped = []
for entry in sorted(package_dir.iterdir()):
if entry.name in covered:
continue
skipped.append(entry.name + ("/" if entry.is_dir() else ""))
return skipped
def copy_package(src: Path, dst: Path, manifest: dict[str, Any], what: str) -> list[str]:
"""Copy only what belongs to the package (§ 2). Returns what was skipped."""
if dst.exists():
raise CannedPromptError(f"{what} already exists: {dst}")
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(src, dst)
members = package_members(src, manifest)
skipped = skipped_entries(src, members)
dst.mkdir(parents=True)
for relative in sorted(members):
target = dst / relative
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src / relative, target)
return skipped
def report_skipped(skipped: list[str]) -> None:
if skipped:
print(
"not packaged (not a reserved path, not referenced by the manifest): "
+ ", ".join(skipped),
file=sys.stderr,
)
def iter_catalog(catalog: Path) -> Iterable[tuple[str, Path, dict[str, Any]]]:
@ -602,7 +699,7 @@ def cmd_add(args: argparse.Namespace) -> None:
catalog = Path(args.catalog).expanduser()
registry = check_registry_name(args.as_registry)
dst = catalog_package_path(catalog, registry, manifest["id"], manifest["version"])
copy_immutable(src.resolve(), dst, "catalog package")
report_skipped(copy_package(src.resolve(), dst, manifest, "catalog package"))
print(f"added {registry}:{manifest['id']}@{manifest['version']} -> {dst}")
@ -623,7 +720,7 @@ def cmd_publish(args: argparse.Namespace) -> None:
)
dst = registry_package_path(registry, manifest["id"], manifest["version"])
copy_immutable(src.resolve(), dst, "published package")
report_skipped(copy_package(src.resolve(), dst, manifest, "published package"))
label = f"{name}:{manifest['id']}" if name else manifest["id"]
print(f"published {label}@{manifest['version']} -> {dst}")
@ -642,7 +739,7 @@ def cmd_install(args: argparse.Namespace) -> None:
src = resolve_in_registry(registry, package_id, args.version)
manifest = validate_package(src)
dst = catalog_package_path(catalog, name, manifest["id"], manifest["version"])
copy_immutable(src, dst, "catalog package")
copy_package(src, dst, manifest, "catalog package")
print(f"installed {name}:{manifest['id']}@{manifest['version']} -> {dst}")

View file

@ -680,3 +680,85 @@ def test_capability_may_be_both_required_and_observed(tmp_path: Path) -> None:
manifest = cp.validate_package(pkg)
assert manifest["dependencies"]["capabilities"] == ["web-search"]
assert "long-context" in manifest["compatibility"]["capabilities"]
# --- semver precedence and prereleases (§ 17.1) ---
def test_release_outranks_its_prerelease() -> None:
assert cp.parse_semver("1.0.0") > cp.parse_semver("1.0.0-rc1")
def test_prerelease_ordering_follows_semver() -> None:
ordered = sorted(
["1.0.0", "1.0.0-rc.2", "1.0.0-rc.10", "1.0.0-alpha", "0.9.0"],
key=cp.parse_semver,
reverse=True,
)
assert ordered == ["1.0.0", "1.0.0-rc.10", "1.0.0-rc.2", "1.0.0-alpha", "0.9.0"]
def test_build_metadata_is_ignored_for_precedence() -> None:
assert cp.parse_semver("1.0.0+build.1") == cp.parse_semver("1.0.0+build.2")
@pytest.mark.parametrize("selector", ["newest", "any", ">=1.0.0", None])
def test_prerelease_is_not_selected_implicitly(selector) -> None:
available = ["1.1.0-rc1", "1.0.0"]
assert cp.select_version(available, selector) == "1.0.0"
def test_exact_pin_selects_a_prerelease() -> None:
assert cp.select_version(["1.1.0-rc1", "1.0.0"], "1.1.0-rc1") == "1.1.0-rc1"
def test_only_prereleases_available_selects_nothing() -> None:
assert cp.select_version(["1.0.0-rc1"], "newest") is None
def test_prerelease_only_package_reports_why(tmp_path: Path) -> None:
catalog = tmp_path / "catalog"
place(catalog, "local", "practice/thing", "1.0.0-rc1", MINIMAL.replace(
"version: 1.0.0", "version: 1.0.0-rc1"), "{{ greeting }}\n")
with pytest.raises(cp.CannedPromptError, match="only prerelease versions"):
cp.resolve_installed(catalog, "practice/thing", None)
# --- strict packaging (§ 2) ---
def test_only_reserved_and_referenced_paths_are_packaged(tmp_path: Path) -> None:
src = write_evaluated(tmp_path / "src", RUBRIC)
(src / "LICENSE").write_text("MIT\n", encoding="utf-8")
(src / "notes.txt").write_text("scratch\n", encoding="utf-8")
(src / ".git").mkdir()
(src / ".git" / "config").write_text("junk\n", encoding="utf-8")
manifest = cp.validate_package(src)
dst = tmp_path / "out"
skipped = cp.copy_package(src, dst, manifest, "package")
packaged = sorted(p.relative_to(dst).as_posix() for p in dst.rglob("*") if p.is_file())
assert packaged == [
"LICENSE",
"evals/quality.yaml",
"examples/basic.yaml",
"prompt.md",
"prompt.yaml",
]
assert skipped == [".git/", "notes.txt"]
def test_packaged_copy_is_still_valid(tmp_path: Path) -> None:
src = write_evaluated(tmp_path / "src", RUBRIC)
manifest = cp.validate_package(src)
dst = tmp_path / "out"
cp.copy_package(src, dst, manifest, "package")
assert cp.validate_package(dst)["id"] == "demo/evaluated"
def test_copy_refuses_to_overwrite(tmp_path: Path) -> None:
src = write_evaluated(tmp_path / "src", RUBRIC)
manifest = cp.validate_package(src)
dst = tmp_path / "out"
cp.copy_package(src, dst, manifest, "package")
with pytest.raises(cp.CannedPromptError, match="already exists"):
cp.copy_package(src, dst, manifest, "package")