CANP-WP-0002 T03: composition by reference, two kinds
T01 had already delivered half of composition without naming it: a derived default binds an input to a prompt dependency, which is transclusion — run package B, use its output. What was missing was the deterministic half. `include` inlines another package's rendered template as text. No model is involved, so the reference CLI can actually perform it, and a shared preamble, rubric or style block becomes a versioned package instead of copied text. This is the concrete way to honor INTENT principle 9 without any runtime. `derive` stays as it was. Both are input defaults, so composition reuses the resolution machinery rather than adding a second one. No template inheritance. Four of this repo's own documents argue against it: INTENT principle 3 (hidden context defeats reuse), section 19's "make package contents visible before execution", section 17's requirement that behavior changes produce a new version, and the non-goal on range resolution. Version selectors: an exact pin is the expected form, with `any`, `newest` and `>= X.Y.Z` as explicit opt-ins so looseness is written rather than implied by absence. Selectors are evaluated per dependency against what is available — no solver, no cross-dependency constraint satisfaction — which is what keeps them outside the range-resolution non-goal, and the spec says so. Also defines `type` (template | fragment), which appeared once in the section 4 manifest surface and was specified nowhere. Spec: 3.2 (type), 5.1 (inclusion resolution rule, renumbered), 6.1 (included default), 10.1 and 10.2 (new), 18 (rules 14-16), 21. Reference CLI: validate_version_selector, select_version, prompt_dependencies replacing prompt_dependency_ids, check_composition_reference, CatalogComposer with cycle detection, and resolve_inputs gaining composer= and inherited=. Tests 21 -> 42. Examples: house-style is a real fragment package; pqrst-estimate composes it and is bumped 0.1.0 -> 0.2.0 per section 17. Fixes an ordering bug found while testing: inputs resolved before parameters, so an included package could not see the including package's parameters and silently fell back to its own defaults — the fragment rendered tone=neutral where the including package said blunt. Parameters now resolve first; the report still lists inputs first. 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
352ed5b30a
commit
4a56f20209
11 changed files with 697 additions and 58 deletions
|
|
@ -336,3 +336,171 @@ def test_legacy_catalog_layout_is_reported(tmp_path: Path) -> None:
|
|||
(legacy / "prompt.md").write_text("{{ greeting }}\n", encoding="utf-8")
|
||||
with pytest.raises(cp.CannedPromptError, match="pre-registry-scoped"):
|
||||
cp.resolve_installed(catalog, "practice/thing", None)
|
||||
|
||||
|
||||
# --- version selectors (§ 10.1) ---
|
||||
|
||||
AVAILABLE = ["2.1.0", "2.0.0", "1.5.0", "1.0.0"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"available,selector,expected",
|
||||
[
|
||||
(AVAILABLE, None, "2.1.0"),
|
||||
(AVAILABLE, "any", "2.1.0"),
|
||||
(AVAILABLE, "newest", "2.1.0"),
|
||||
(AVAILABLE, "1.5.0", "1.5.0"),
|
||||
(AVAILABLE, ">=2.0.0", "2.1.0"),
|
||||
(AVAILABLE, "9.9.9", None),
|
||||
(AVAILABLE, ">=9.9.9", None),
|
||||
(["1.5.0", "1.0.0"], ">=1.2.0", "1.5.0"),
|
||||
([], "newest", None),
|
||||
],
|
||||
)
|
||||
def test_select_version(available, selector, expected) -> None:
|
||||
assert cp.select_version(available, selector) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["^1.0.0", "~1.2", "1.x", ">=nope", "", "latest"])
|
||||
def test_range_syntax_is_rejected(bad) -> None:
|
||||
with pytest.raises(cp.CannedPromptError):
|
||||
cp.validate_version_selector(bad, "dep")
|
||||
|
||||
|
||||
# --- composition (§ 10.2) ---
|
||||
|
||||
FRAGMENT = """\
|
||||
format: canned-prompt/v0.1
|
||||
id: style/house
|
||||
name: House Style
|
||||
version: 1.0.0
|
||||
summary: Shared style block.
|
||||
type: fragment
|
||||
template: prompt.md
|
||||
parameters:
|
||||
tone:
|
||||
type: enum
|
||||
values: [neutral, blunt]
|
||||
default: neutral
|
||||
"""
|
||||
|
||||
COMPOSER = """\
|
||||
format: canned-prompt/v0.1
|
||||
id: review/change
|
||||
name: Change Review
|
||||
version: 1.0.0
|
||||
summary: Composes the shared style block.
|
||||
template: prompt.md
|
||||
dependencies:
|
||||
prompts:
|
||||
- id: style/house
|
||||
version: 1.0.0
|
||||
inputs:
|
||||
- name: house_style
|
||||
required: false
|
||||
default:
|
||||
include: style/house
|
||||
parameters:
|
||||
tone:
|
||||
type: enum
|
||||
values: [neutral, blunt]
|
||||
default: blunt
|
||||
"""
|
||||
|
||||
|
||||
def place(catalog: Path, registry: str, package_id: str, version: str,
|
||||
manifest: str, template: str) -> None:
|
||||
dst = cp.catalog_package_path(catalog, registry, package_id, version)
|
||||
dst.mkdir(parents=True)
|
||||
(dst / "prompt.yaml").write_text(manifest, encoding="utf-8")
|
||||
(dst / "prompt.md").write_text(template, encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def composed(tmp_path: Path) -> Path:
|
||||
catalog = tmp_path / "catalog"
|
||||
place(catalog, "local", "style/house", "1.0.0", FRAGMENT, "Tone is {{ tone }}.")
|
||||
place(catalog, "local", "review/change", "1.0.0", COMPOSER, "S: {{ house_style }}")
|
||||
return catalog
|
||||
|
||||
|
||||
def test_include_inlines_rendered_template(composed: Path) -> None:
|
||||
package_dir, _ = cp.resolve_installed(composed, "review/change", None)
|
||||
manifest = cp.validate_package(package_dir)
|
||||
resolution = cp.resolve_inputs(manifest, {}, composer=cp.CatalogComposer(composed))
|
||||
assert resolution.values["house_style"] == "Tone is blunt."
|
||||
assert resolution.origins["house_style"] == "included from style/house"
|
||||
|
||||
|
||||
def test_include_inherits_outer_parameters(composed: Path) -> None:
|
||||
"""The fragment's own default is neutral; the including package says blunt."""
|
||||
package_dir, _ = cp.resolve_installed(composed, "review/change", None)
|
||||
manifest = cp.validate_package(package_dir)
|
||||
resolution = cp.resolve_inputs(
|
||||
manifest, {"tone": "neutral"}, composer=cp.CatalogComposer(composed)
|
||||
)
|
||||
assert resolution.values["house_style"] == "Tone is neutral."
|
||||
|
||||
|
||||
def test_include_without_composer_falls_back_to_unresolved(composed: Path) -> None:
|
||||
package_dir, _ = cp.resolve_installed(composed, "review/change", None)
|
||||
manifest = cp.validate_package(package_dir)
|
||||
resolution = cp.resolve_inputs(manifest, {})
|
||||
assert resolution.underivable == ["house_style"]
|
||||
|
||||
|
||||
def test_inclusion_cycle_is_detected(tmp_path: Path) -> None:
|
||||
catalog = tmp_path / "catalog"
|
||||
for this, other in (("cyc/a", "cyc/b"), ("cyc/b", "cyc/a")):
|
||||
manifest = (
|
||||
"format: canned-prompt/v0.1\n"
|
||||
f"id: {this}\nname: X\nversion: 1.0.0\nsummary: s\ntemplate: prompt.md\n"
|
||||
f"dependencies:\n prompts:\n - id: {other}\n version: 1.0.0\n"
|
||||
f"inputs:\n - name: other\n required: false\n"
|
||||
f" default:\n include: {other}\n"
|
||||
)
|
||||
place(catalog, "local", this, "1.0.0", manifest, "{{ other }}")
|
||||
package_dir, _ = cp.resolve_installed(catalog, "cyc/a", None)
|
||||
manifest = cp.validate_package(package_dir)
|
||||
with pytest.raises(cp.CannedPromptError, match="inclusion cycle"):
|
||||
cp.resolve_inputs(manifest, {}, composer=cp.CatalogComposer(catalog))
|
||||
|
||||
|
||||
def test_include_and_derive_together_is_rejected(tmp_path: Path) -> None:
|
||||
pkg = write_pkg(
|
||||
tmp_path / "p",
|
||||
BASE
|
||||
+ """\
|
||||
dependencies:
|
||||
prompts:
|
||||
- id: style/house
|
||||
version: 1.0.0
|
||||
inputs:
|
||||
- name: greeting
|
||||
required: false
|
||||
default:
|
||||
include: style/house
|
||||
derive: style/house
|
||||
""",
|
||||
)
|
||||
with pytest.raises(cp.CannedPromptError, match="at most one of"):
|
||||
cp.validate_package(pkg)
|
||||
|
||||
|
||||
def test_composed_dependency_must_declare_a_version(tmp_path: Path) -> None:
|
||||
pkg = write_pkg(
|
||||
tmp_path / "p",
|
||||
BASE
|
||||
+ """\
|
||||
dependencies:
|
||||
prompts:
|
||||
- id: style/house
|
||||
inputs:
|
||||
- name: greeting
|
||||
required: false
|
||||
default:
|
||||
include: style/house
|
||||
""",
|
||||
)
|
||||
with pytest.raises(cp.CannedPromptError, match="must declare a version"):
|
||||
cp.validate_package(pkg)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue