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:
parent
e885eb7b05
commit
95a8bb31d2
8 changed files with 306 additions and 34 deletions
|
|
@ -53,12 +53,24 @@ my-prompt/
|
||||||
| `prompt.yaml` | Required package manifest |
|
| `prompt.yaml` | Required package manifest |
|
||||||
| `prompt.md` | Default prompt template unless overridden by `template` |
|
| `prompt.md` | Default prompt template unless overridden by `template` |
|
||||||
| `README.md` | Optional human documentation |
|
| `README.md` | Optional human documentation |
|
||||||
|
| `LICENSE` | Optional license text |
|
||||||
| `examples/` | Optional examples/fixtures |
|
| `examples/` | Optional examples/fixtures |
|
||||||
| `evals/` | Optional evaluation specifications |
|
| `evals/` | Optional evaluation specifications |
|
||||||
| `assets/` | Optional supporting text/data artifacts |
|
| `assets/` | Optional supporting text/data artifacts |
|
||||||
|
|
||||||
Tools MUST ignore unknown non-reserved files unless a manifest field explicitly references them.
|
Tools MUST ignore unknown non-reserved files unless a manifest field explicitly references them.
|
||||||
|
|
||||||
|
This governs packaging as well as reading: a tool that copies a package into a
|
||||||
|
catalog or registry MUST copy the reserved paths and the files a manifest
|
||||||
|
field references, and nothing else. A working directory's `.git`, virtualenv or
|
||||||
|
scratch files are not part of the package and MUST NOT be published with it. A
|
||||||
|
tool that omits files SHOULD say which, so that an author is never silently
|
||||||
|
surprised by what did not ship.
|
||||||
|
|
||||||
|
`LICENSE` is reserved because § 14 asks tools to surface licensing on publish
|
||||||
|
and install, and because dropping the license text while faithfully copying the
|
||||||
|
`license` field would misrepresent the package.
|
||||||
|
|
||||||
## 3. Manifest
|
## 3. Manifest
|
||||||
|
|
||||||
The canonical manifest is UTF-8 YAML named `prompt.yaml`.
|
The canonical manifest is UTF-8 YAML named `prompt.yaml`.
|
||||||
|
|
@ -680,6 +692,9 @@ reported as ambiguous rather than chosen.
|
||||||
| `newest` | the newest available version |
|
| `newest` | the newest available version |
|
||||||
| `>= 1.2.0` | the newest available version that is at least `1.2.0` |
|
| `>= 1.2.0` | the newest available version that is at least `1.2.0` |
|
||||||
|
|
||||||
|
Prereleases are excluded from `any`, `newest` and `>=` (§ 17.1); name one
|
||||||
|
exactly to select it.
|
||||||
|
|
||||||
**An exact pin is the expected form.** The other three are explicit opt-ins,
|
**An exact pin is the expected form.** The other three are explicit opt-ins,
|
||||||
visible in the manifest, so that looseness is always something an author wrote
|
visible in the manifest, so that looseness is always something an author wrote
|
||||||
rather than something absence implied.
|
rather than something absence implied.
|
||||||
|
|
@ -887,6 +902,17 @@ both is not in a conflict — it is holding two things whose names happen to
|
||||||
coincide. Only a repeat publication *within one registry* violates
|
coincide. Only a repeat publication *within one registry* violates
|
||||||
immutability.
|
immutability.
|
||||||
|
|
||||||
|
### 17.1 Precedence and prereleases
|
||||||
|
|
||||||
|
Where versions are compared, precedence follows Semantic Versioning: numeric
|
||||||
|
fields first, and a prerelease version ranks **below** its own release, so
|
||||||
|
`1.0.0-rc1` precedes `1.0.0`. Build metadata does not affect precedence.
|
||||||
|
|
||||||
|
A prerelease is **not selected** by `any`, `newest`, or a `>=` lower bound
|
||||||
|
(§ 10.3). Only an exact pin selects one. Publishing a release candidate
|
||||||
|
therefore never changes what existing consumers resolve to — which is the
|
||||||
|
point of marking it a candidate.
|
||||||
|
|
||||||
Suggested versioning guidance:
|
Suggested versioning guidance:
|
||||||
|
|
||||||
- PATCH: wording/metadata correction with intended behavior unchanged;
|
- PATCH: wording/metadata correction with intended behavior unchanged;
|
||||||
|
|
|
||||||
16
README.md
16
README.md
|
|
@ -181,6 +181,22 @@ signing and trust scoring are explicit non-goals. Ownership lives with the
|
||||||
registry rather than in the package, so no package carries an unverifiable
|
registry rather than in the package, so no package carries an unverifiable
|
||||||
assertion of authority.
|
assertion of authority.
|
||||||
|
|
||||||
|
## Packaging and versions
|
||||||
|
|
||||||
|
`add`, `publish` and `install` copy the reserved paths (`prompt.yaml`,
|
||||||
|
`prompt.md`, `README.md`, `LICENSE`, `examples/`, `evals/`, `assets/`) plus
|
||||||
|
anything a manifest field references — and nothing else, as § 2 requires. What
|
||||||
|
was left behind is reported rather than silently dropped:
|
||||||
|
|
||||||
|
```text
|
||||||
|
not packaged (not a reserved path, not referenced by the manifest): .git/, .venv/, notes.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Version precedence follows SemVer, so `1.0.0` outranks `1.0.0-rc1`, and
|
||||||
|
`any`, `newest` and `>= X.Y.Z` skip prereleases entirely. Publishing a release
|
||||||
|
candidate never changes what existing consumers resolve to; name it exactly to
|
||||||
|
use it.
|
||||||
|
|
||||||
## Deliberate limitations
|
## Deliberate limitations
|
||||||
|
|
||||||
This seed has no hosted registry, model execution, authentication, network access, dependency resolver, or social features. `publish` and `install` operate on a filesystem registry so that the package semantics can be tested before infrastructure is built around them.
|
This seed has no hosted registry, model execution, authentication, network access, dependency resolver, or social features. `publish` and `install` operate on a filesystem registry so that the package semantics can be tested before infrastructure is built around them.
|
||||||
|
|
|
||||||
9
examples/pqrst-estimate/LICENSE
Normal file
9
examples/pqrst-estimate/LICENSE
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 canned-prompts contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
|
||||||
|
|
@ -45,6 +45,7 @@ tags:
|
||||||
- retrospective
|
- retrospective
|
||||||
- agentic-coding
|
- agentic-coding
|
||||||
- effort-estimation
|
- effort-estimation
|
||||||
|
license: MIT
|
||||||
provenance:
|
provenance:
|
||||||
author: canned-prompts seed
|
author: canned-prompts seed
|
||||||
examples:
|
examples:
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,12 @@ rather than resolved by guessing.
|
||||||
- `{{ name }}` template substitution only.
|
- `{{ name }}` template substitution only.
|
||||||
- No arbitrary expression/code execution.
|
- No arbitrary expression/code execution.
|
||||||
- Published versions are immutable by default.
|
- 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.
|
- `install` copies from registry to catalog.
|
||||||
- `add` copies a package directly to catalog.
|
- `add` copies a package directly to catalog.
|
||||||
- `search`, `show`, `resolve`, and `render` operate on catalog packages, and
|
- `search`, `show`, `resolve`, and `render` operate on catalog packages, and
|
||||||
|
|
|
||||||
|
|
@ -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"
|
EVAL_RUBRIC_SCHEMA = "canned-prompts/eval-rubric/v0.1"
|
||||||
RENDER_CHECKS = ("contains", "not_contains", "resolves_all")
|
RENDER_CHECKS = ("contains", "not_contains", "resolves_all")
|
||||||
CAPABILITY_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
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*}}")
|
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")
|
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
|
Each dependency is selected independently against what is present. There is
|
||||||
no constraint solving across a dependency graph; that stays a non-goal.
|
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:
|
if not available:
|
||||||
return None
|
return None
|
||||||
|
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
|
||||||
if selector is None or selector in ("any", "newest"):
|
if selector is None or selector in ("any", "newest"):
|
||||||
return available[0]
|
return stable[0]
|
||||||
if selector.startswith(">="):
|
|
||||||
floor = parse_semver(selector[2:])
|
floor = parse_semver(selector[2:])
|
||||||
for version in available:
|
for version in stable:
|
||||||
if parse_semver(version) >= floor:
|
if parse_semver(version) >= floor:
|
||||||
return version
|
return version
|
||||||
return None
|
return None
|
||||||
return selector if selector in available else 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)
|
match = SEMVER_RE.match(value)
|
||||||
if not match:
|
if not match:
|
||||||
return (-1, -1, -1, value)
|
return (-1, -1, -1, 0, (), value)
|
||||||
return (int(match.group(1)), int(match.group(2)), int(match.group(3)), 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]:
|
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:
|
def pick_version(base: Path, label: str, version: str | None) -> Path:
|
||||||
|
available = versions_at(base)
|
||||||
if version:
|
if version:
|
||||||
path = base / version
|
path = base / version
|
||||||
if not (path / "prompt.yaml").is_file():
|
if not (path / "prompt.yaml").is_file():
|
||||||
raise CannedPromptError(f"package not found: {label}@{version}")
|
raise CannedPromptError(f"package not found: {label}@{version}")
|
||||||
return path
|
return path
|
||||||
versions = versions_at(base)
|
if not available:
|
||||||
if not versions:
|
|
||||||
raise CannedPromptError(f"package not found: {label}")
|
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:
|
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
|
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():
|
if dst.exists():
|
||||||
raise CannedPromptError(f"{what} already exists: {dst}")
|
raise CannedPromptError(f"{what} already exists: {dst}")
|
||||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
members = package_members(src, manifest)
|
||||||
shutil.copytree(src, dst)
|
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]]]:
|
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()
|
catalog = Path(args.catalog).expanduser()
|
||||||
registry = check_registry_name(args.as_registry)
|
registry = check_registry_name(args.as_registry)
|
||||||
dst = catalog_package_path(catalog, registry, manifest["id"], manifest["version"])
|
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}")
|
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"])
|
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"]
|
label = f"{name}:{manifest['id']}" if name else manifest["id"]
|
||||||
print(f"published {label}@{manifest['version']} -> {dst}")
|
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)
|
src = resolve_in_registry(registry, package_id, args.version)
|
||||||
manifest = validate_package(src)
|
manifest = validate_package(src)
|
||||||
dst = catalog_package_path(catalog, name, manifest["id"], manifest["version"])
|
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}")
|
print(f"installed {name}:{manifest['id']}@{manifest['version']} -> {dst}")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -680,3 +680,85 @@ def test_capability_may_be_both_required_and_observed(tmp_path: Path) -> None:
|
||||||
manifest = cp.validate_package(pkg)
|
manifest = cp.validate_package(pkg)
|
||||||
assert manifest["dependencies"]["capabilities"] == ["web-search"]
|
assert manifest["dependencies"]["capabilities"] == ["web-search"]
|
||||||
assert "long-context" in manifest["compatibility"]["capabilities"]
|
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")
|
||||||
|
|
|
||||||
|
|
@ -388,22 +388,57 @@ the format revision warrants it.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CANP-WP-0002-T07
|
id: CANP-WP-0002-T07
|
||||||
status: todo
|
status: done
|
||||||
priority: low
|
priority: low
|
||||||
state_hub_task_id: "f642abe6-8222-5958-88ee-7a53454f2344"
|
state_hub_task_id: "f642abe6-8222-5958-88ee-7a53454f2344"
|
||||||
```
|
```
|
||||||
|
|
||||||
Two defects found in the same review, independent of the format questions:
|
Two defects found in the same review, independent of the format questions:
|
||||||
|
|
||||||
1. **Prerelease versions sort as newest.** `parse_semver` in
|
1. **Prerelease versions sorted as newest.** Confirmed worse than first
|
||||||
`reference/canned_prompts.py` returns `(major, minor, patch, raw_string)`,
|
recorded: `1.0.0-rc1` shadowed `1.0.0` for `newest` *and* `>= 1.0.0`, so a
|
||||||
so `1.0.0-rc1` and `1.0.0` tie on the numeric fields and then compare as
|
release candidate hid its own release from every selector.
|
||||||
strings — `"1.0.0-rc1" > "1.0.0"`. `resolve_installed` with no `--version`
|
2. **`copy_immutable` copied everything**, so a stray `.git`, `.venv` or
|
||||||
therefore selects a prerelease over its own release. Order prerelease below
|
scratch file landed in the catalog and registry.
|
||||||
release, or state in § 17 that v0.1 ignores prerelease ordering.
|
|
||||||
2. **`copy_immutable` copies everything.** `add`/`publish` use
|
**Decisions (operator, 2026-09-06):**
|
||||||
`shutil.copytree` over the whole source directory, so a stray `.git`,
|
|
||||||
`.venv`, or scratch file lands in the catalog and registry. § 2 says tools
|
- *Prereleases are excluded unless named.* They sort below their release and
|
||||||
MUST ignore unknown non-reserved files unless a manifest field references
|
are skipped by `any`, `newest` and `>=`; only an exact pin selects one.
|
||||||
them. Decide whether packaging is reserved-paths-only or an explicit
|
Publishing a release candidate therefore never changes what existing
|
||||||
ignore list, then align the implementation and § 2.
|
consumers resolve to — the point of marking it a candidate. Matches npm and
|
||||||
|
cargo.
|
||||||
|
- *`LICENSE` joins the reserved paths.* Strict packaging would otherwise drop
|
||||||
|
a package's license text while faithfully copying its `license` field, which
|
||||||
|
contradicts § 14's instruction to surface licensing on publish and install.
|
||||||
|
|
||||||
|
The strict-versus-ignore-list question needed no decision: § 2 already
|
||||||
|
required it — "Tools MUST ignore unknown non-reserved files unless a manifest
|
||||||
|
field explicitly references them" — so packaging by reserved path is
|
||||||
|
conformance, not a new choice. What was worth adding is that omissions are
|
||||||
|
**reported**, never silent.
|
||||||
|
|
||||||
|
Delivered:
|
||||||
|
|
||||||
|
1. `parse_semver` now returns a SemVer § 11 precedence key: numeric fields,
|
||||||
|
then a release/prerelease rank, then dot-separated prerelease identifiers
|
||||||
|
with numeric ones compared numerically. Build metadata is ignored, so
|
||||||
|
`1.0.0+build.1` and `1.0.0+build.2` tie. `1.0.0-rc.10` correctly outranks
|
||||||
|
`1.0.0-rc.2`.
|
||||||
|
2. `select_version` excludes prereleases from `any`, `newest` and `>=`;
|
||||||
|
`pick_version` reports "only prerelease versions are available" rather than
|
||||||
|
a bare not-found.
|
||||||
|
3. `copy_package` replaces `copy_immutable`, copying reserved paths plus
|
||||||
|
manifest-referenced files only, and returning what it skipped;
|
||||||
|
`report_skipped` names those on stderr. `skipped_entries` reports top-level
|
||||||
|
names without walking into an ignored directory, so a stray virtualenv
|
||||||
|
costs nothing to skip.
|
||||||
|
4. § 2 gains `LICENSE`, the packaging obligation, and the reporting SHOULD;
|
||||||
|
§ 17.1 (new) specifies precedence and prerelease selection; § 10.3 notes
|
||||||
|
the exclusion.
|
||||||
|
5. `examples/pqrst-estimate` now carries a `LICENSE` and a `license: MIT`
|
||||||
|
field, exercising the new reserved path. Tests 65 → 78.
|
||||||
|
|
||||||
|
**Fixed while implementing.** `package_members` reused a loop variable as the
|
||||||
|
error label, so a bad `template` path would have been reported as an `evals`
|
||||||
|
error.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue