diff --git a/CannedPromptFormat-v0.1.md b/CannedPromptFormat-v0.1.md index 38f24f6..44e5f29 100644 --- a/CannedPromptFormat-v0.1.md +++ b/CannedPromptFormat-v0.1.md @@ -53,12 +53,24 @@ my-prompt/ | `prompt.yaml` | Required package manifest | | `prompt.md` | Default prompt template unless overridden by `template` | | `README.md` | Optional human documentation | +| `LICENSE` | Optional license text | | `examples/` | Optional examples/fixtures | | `evals/` | Optional evaluation specifications | | `assets/` | Optional supporting text/data artifacts | 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 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 | | `>= 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, visible in the manifest, so that looseness is always something an author wrote 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 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: - PATCH: wording/metadata correction with intended behavior unchanged; diff --git a/README.md b/README.md index 7ef1ab7..bc9cc1b 100644 --- a/README.md +++ b/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 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 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. diff --git a/examples/pqrst-estimate/LICENSE b/examples/pqrst-estimate/LICENSE new file mode 100644 index 0000000..37e2429 --- /dev/null +++ b/examples/pqrst-estimate/LICENSE @@ -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. diff --git a/examples/pqrst-estimate/prompt.yaml b/examples/pqrst-estimate/prompt.yaml index 4993cdd..5e06966 100644 --- a/examples/pqrst-estimate/prompt.yaml +++ b/examples/pqrst-estimate/prompt.yaml @@ -45,6 +45,7 @@ tags: - retrospective - agentic-coding - effort-estimation +license: MIT provenance: author: canned-prompts seed examples: diff --git a/reference/README.md b/reference/README.md index 909f44f..3eebc94 100644 --- a/reference/README.md +++ b/reference/README.md @@ -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 diff --git a/reference/canned_prompts.py b/reference/canned_prompts.py index f43d120..8bb0eb7 100755 --- a/reference/canned_prompts.py +++ b/reference/canned_prompts.py @@ -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}") diff --git a/reference/tests/test_canned_prompts.py b/reference/tests/test_canned_prompts.py index 4d5b73e..ab0c67a 100644 --- a/reference/tests/test_canned_prompts.py +++ b/reference/tests/test_canned_prompts.py @@ -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") diff --git a/workplans/CANP-WP-0002-format-open-questions.md b/workplans/CANP-WP-0002-format-open-questions.md index 77b5ef9..85b6943 100644 --- a/workplans/CANP-WP-0002-format-open-questions.md +++ b/workplans/CANP-WP-0002-format-open-questions.md @@ -388,22 +388,57 @@ the format revision warrants it. ```task id: CANP-WP-0002-T07 -status: todo +status: done priority: low state_hub_task_id: "f642abe6-8222-5958-88ee-7a53454f2344" ``` Two defects found in the same review, independent of the format questions: -1. **Prerelease versions sort as newest.** `parse_semver` in - `reference/canned_prompts.py` returns `(major, minor, patch, raw_string)`, - so `1.0.0-rc1` and `1.0.0` tie on the numeric fields and then compare as - strings — `"1.0.0-rc1" > "1.0.0"`. `resolve_installed` with no `--version` - therefore selects a prerelease over its own release. Order prerelease below - release, or state in § 17 that v0.1 ignores prerelease ordering. -2. **`copy_immutable` copies everything.** `add`/`publish` use - `shutil.copytree` over the whole source directory, so a stray `.git`, - `.venv`, or scratch file lands in the catalog and registry. § 2 says tools - MUST ignore unknown non-reserved files unless a manifest field references - them. Decide whether packaging is reserved-paths-only or an explicit - ignore list, then align the implementation and § 2. +1. **Prerelease versions sorted as newest.** Confirmed worse than first + recorded: `1.0.0-rc1` shadowed `1.0.0` for `newest` *and* `>= 1.0.0`, so a + release candidate hid its own release from every selector. +2. **`copy_immutable` copied everything**, so a stray `.git`, `.venv` or + scratch file landed in the catalog and registry. + +**Decisions (operator, 2026-09-06):** + +- *Prereleases are excluded unless named.* They sort below their release and + are skipped by `any`, `newest` and `>=`; only an exact pin selects one. + Publishing a release candidate therefore never changes what existing + 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.