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:
tegwick 2026-09-06 01:31:58 +02:00
parent 352ed5b30a
commit 4a56f20209
11 changed files with 697 additions and 58 deletions

View file

@ -69,6 +69,8 @@ rather than resolved by guessing.
- `add` copies a package directly to catalog.
- `search`, `show`, `resolve`, and `render` operate on catalog packages, and
print qualified `<registry>:<id>` references.
- `include` defaults are satisfied (inclusion is deterministic); `derive`
defaults are reported, not run. Inclusion cycles are detected and named.
- An optional `registry.yaml` names a registry and records namespace claims.
`publish` warns when a namespace is declared `closed` — it cannot
authenticate a publisher, and says so rather than implying it checked.

View file

@ -97,37 +97,65 @@ def declared_names(manifest: dict[str, Any]) -> set[str]:
def input_default_kind(item: dict[str, Any]) -> str:
"""Classify an input's default as 'none', 'static', or 'derived' (§ 6.1)."""
"""Classify a default as 'none', 'static', 'derived', or 'included' (§ 6.1)."""
if "default" not in item:
return "none"
default = item["default"]
if isinstance(default, dict) and "derive" in default:
return "derived"
if isinstance(default, dict):
if "derive" in default and "include" in default:
return "conflict"
if "derive" in default:
return "derived"
if "include" in default:
return "included"
return "static"
def prompt_dependency_ids(manifest: dict[str, Any]) -> set[str]:
def prompt_dependencies(manifest: dict[str, Any]) -> dict[str, dict[str, Any]]:
dependencies = manifest.get("dependencies") or {}
if not isinstance(dependencies, dict):
raise CannedPromptError("dependencies must be a mapping")
prompts = dependencies.get("prompts") or []
if not isinstance(prompts, list):
raise CannedPromptError("dependencies.prompts must be a list")
ids: set[str] = set()
declared: dict[str, dict[str, Any]] = {}
for entry in prompts:
if isinstance(entry, str):
ids.add(entry)
declared[entry] = {"id": entry}
elif isinstance(entry, dict) and isinstance(entry.get("id"), str):
ids.add(entry["id"])
if "version" in entry:
validate_version_selector(
entry["version"], f"dependency {entry['id']!r}"
)
declared[entry["id"]] = entry
else:
raise CannedPromptError(
"each prompt dependency must be an id string or a mapping with a string id"
)
return ids
return declared
def validate_input_default(item: dict[str, Any], dependency_ids: set[str]) -> None:
"""Validation rules 11-13 of § 18."""
def check_composition_reference(
name: str, reference: str, field: str, declared: dict[str, dict[str, Any]]
) -> None:
if not reference.strip():
raise CannedPromptError(f"input {name!r}: {field} reference is empty")
if reference not in declared:
raise CannedPromptError(
f"input {name!r} {field}s {reference!r}, which is not declared "
"in dependencies.prompts"
)
if "version" not in declared[reference]:
raise CannedPromptError(
f"input {name!r} {field}s {reference!r}, so that dependency must "
"declare a version (§ 10.1)"
)
def validate_input_default(
item: dict[str, Any], declared: dict[str, dict[str, Any]]
) -> None:
"""Validation rules 11-16 of § 18."""
kind = input_default_kind(item)
if kind == "none":
return
@ -139,16 +167,23 @@ def validate_input_default(item: dict[str, Any], dependency_ids: set[str]) -> No
)
if kind == "static":
return
if kind == "conflict":
raise CannedPromptError(
f"input {name!r}: a default declares at most one of 'include' or 'derive'"
)
if kind == "included":
include = item["default"]["include"]
if not isinstance(include, str):
raise CannedPromptError(
f"input {name!r}: include must name a declared prompt dependency"
)
check_composition_reference(name, include, "include", declared)
return
derive = item["default"]["derive"]
if isinstance(derive, str):
if not derive.strip():
raise CannedPromptError(f"input {name!r}: derive reference is empty")
if derive not in dependency_ids:
raise CannedPromptError(
f"input {name!r} derives from {derive!r}, which is not declared "
"in dependencies.prompts"
)
check_composition_reference(name, derive, "derive", declared)
return
if isinstance(derive, dict):
prompt = derive.get("prompt")
@ -204,9 +239,9 @@ def validate_package(package_dir: Path) -> dict[str, Any]:
names = declared_names(manifest)
dependency_ids = prompt_dependency_ids(manifest)
declared = prompt_dependencies(manifest)
for item in manifest.get("inputs") or []:
validate_input_default(item, dependency_ids)
validate_input_default(item, declared)
template = template_path.read_text(encoding="utf-8")
placeholders = set(PLACEHOLDER_RE.findall(template))
@ -302,6 +337,45 @@ def catalog_package_path(catalog: Path, registry: str, package_id: str, version:
return id_path(catalog / check_registry_name(registry), package_id) / version
def validate_version_selector(value: Any, where: str) -> str:
"""A semver literal, `any`, `newest`, or a `>=` lower bound (§ 10.1)."""
if not isinstance(value, str) or not value.strip():
raise CannedPromptError(f"{where}: version must be a non-empty string")
selector = value.strip()
if selector in ("any", "newest"):
return selector
compact = selector.replace(" ", "")
if compact.startswith(">="):
if not SEMVER_RE.match(compact[2:]):
raise CannedPromptError(f"{where}: {value!r} is not a valid lower bound")
return compact
if not SEMVER_RE.match(selector):
raise CannedPromptError(
f"{where}: unsupported version selector {value!r}; expected a semver "
"literal, 'any', 'newest', or '>= X.Y.Z'"
)
return selector
def select_version(available: list[str], selector: str | None) -> str | None:
"""Pick a version from `available` (newest first) per a § 10.1 selector.
Each dependency is selected independently against what is present. There is
no constraint solving across a dependency graph; that stays a non-goal.
"""
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
return None
return selector if selector in available else None
def parse_semver(value: str) -> tuple[int, int, int, str]:
match = SEMVER_RE.match(value)
if not match:
@ -545,15 +619,59 @@ class Resolution:
underivable: list[str] = field(default_factory=list)
def resolve_inputs(manifest: dict[str, Any], raw_values: dict[str, str]) -> Resolution:
def resolve_inputs(
manifest: dict[str, Any],
raw_values: dict[str, str],
composer: "Composer | None" = None,
inherited: dict[str, Any] | None = None,
) -> Resolution:
"""Resolve inputs and parameters per § 5.1.
This tool never calls a model, so a derived default is satisfied only by its
static fallback `value`. An input with a derived default and no fallback is
reported as underivable rather than silently rendered as empty.
`composer`, when supplied, satisfies `include` defaults by rendering the
included package (§ 10.2). Inclusion is deterministic, so a tool that can
render can satisfy it; derivation is not, and this tool never calls a model,
so a derived default is satisfied only by its static fallback `value`.
`inherited` carries the including package's already-resolved values into an
included package (§ 10.2). Those values are used as-is rather than coerced,
and do not count as caller-supplied.
"""
result = Resolution()
inherited = inherited or {}
known: set[str] = set()
declared = prompt_dependencies(manifest)
def compose(name: str, item: dict[str, Any]) -> None:
default = item["default"]
reference = default["include"]
if composer is not None:
entry = declared.get(reference) or {}
result.values[name] = composer(reference, entry.get("version"), result.values)
result.origins[name] = f"included from {reference}"
return
if "value" in default:
result.values[name] = default["value"]
result.origins[name] = "fallback (not included)"
else:
result.origins[name] = "unresolved (included, no fallback)"
result.underivable.append(name)
# Parameters resolve first: a composed input inherits the including
# package's resolved values (§ 10.2), so those must already be settled.
parameters = manifest.get("parameters") or {}
for name, spec in parameters.items():
known.add(name)
if name in raw_values:
result.values[name] = coerce_value(raw_values[name], spec)
result.origins[name] = "supplied"
elif name in inherited:
result.values[name] = inherited[name]
result.origins[name] = "inherited"
elif "default" in spec:
result.values[name] = spec["default"]
result.origins[name] = "default"
else:
result.origins[name] = "unresolved (no default)"
inputs = manifest.get("inputs") or []
for item in inputs:
@ -563,6 +681,10 @@ def resolve_inputs(manifest: dict[str, Any], raw_values: dict[str, str]) -> Reso
result.values[name] = raw_values[name]
result.origins[name] = "supplied"
continue
if name in inherited:
result.values[name] = inherited[name]
result.origins[name] = "inherited"
continue
if item.get("required", False):
raise CannedPromptError(f"missing required input: {name}")
@ -570,6 +692,8 @@ def resolve_inputs(manifest: dict[str, Any], raw_values: dict[str, str]) -> Reso
if kind == "static":
result.values[name] = item["default"]
result.origins[name] = "default"
elif kind == "included":
compose(name, item)
elif kind == "derived":
default = item["default"]
if "value" in default:
@ -581,17 +705,10 @@ def resolve_inputs(manifest: dict[str, Any], raw_values: dict[str, str]) -> Reso
else:
result.origins[name] = "unresolved (optional, no default)"
parameters = manifest.get("parameters") or {}
for name, spec in parameters.items():
known.add(name)
if name in raw_values:
result.values[name] = coerce_value(raw_values[name], spec)
result.origins[name] = "supplied"
elif "default" in spec:
result.values[name] = spec["default"]
result.origins[name] = "default"
else:
result.origins[name] = "unresolved (no default)"
# Report inputs before parameters regardless of resolution order.
ordered = {item["name"]: result.origins[item["name"]] for item in inputs}
ordered.update({name: result.origins[name] for name in parameters})
result.origins = ordered
unknown = sorted(set(raw_values) - known)
if unknown:
@ -604,6 +721,79 @@ def resolve_values(manifest: dict[str, Any], raw_values: dict[str, str]) -> dict
return resolve_inputs(manifest, raw_values).values
class CatalogComposer:
"""Satisfies `include` defaults from the catalog (§ 10.2).
Inclusion is text composition: the included package is rendered and its text
is inlined. No model is involved, so this is deterministic.
"""
def __init__(self, catalog: Path) -> None:
self.catalog = catalog
self._stack: list[str] = []
def __call__(
self, reference: str, selector: str | None, outer_values: dict[str, Any]
) -> str:
if reference in self._stack:
raise CannedPromptError(
"inclusion cycle: " + " -> ".join([*self._stack, reference])
)
registry, package_id = parse_reference(reference)
names = (
[registry]
if registry is not None
else [
name
for name in catalog_registries(self.catalog)
if versions_at(id_path(self.catalog / name, package_id))
]
)
if len(names) > 1:
raise CannedPromptError(
f"included package {package_id} is present in more than one registry: "
+ ", ".join(f"{name}:{package_id}" for name in names)
+ " — qualify the dependency id"
)
if not names:
raise CannedPromptError(f"included package not found: {reference}")
base = id_path(self.catalog / names[0], package_id)
version = select_version(versions_at(base), selector)
if version is None:
raise CannedPromptError(
f"included package {reference}: no version satisfies {selector!r}"
)
package_dir = base / version
manifest = validate_package(package_dir)
template_path = safe_relative_file(package_dir, manifest["template"], "template")
self._stack.append(reference)
try:
inner = resolve_inputs(manifest, {}, composer=self, inherited=outer_values)
except CannedPromptError as exc:
if str(exc).startswith("inclusion cycle:"):
raise
raise CannedPromptError(f"while including {reference}: {exc}") from exc
finally:
self._stack.pop()
text = template_path.read_text(encoding="utf-8")
used = set(PLACEHOLDER_RE.findall(text))
missing = sorted(used - set(inner.values))
if missing:
raise CannedPromptError(
f"included package {reference} has unresolved inputs the including "
"package does not supply: " + ", ".join(missing)
)
return render_template(text, inner.values)
Composer = CatalogComposer
def render_template(template: str, values: dict[str, Any]) -> str:
def replace(match: re.Match[str]) -> str:
name = match.group(1)
@ -625,7 +815,7 @@ def cmd_render(args: argparse.Namespace) -> None:
manifest = validate_package(package_dir)
template_path = safe_relative_file(package_dir, manifest["template"], "template")
raw = supplied_values(args.set_values)
resolution = resolve_inputs(manifest, raw)
resolution = resolve_inputs(manifest, raw, composer=CatalogComposer(catalog))
template = template_path.read_text(encoding="utf-8")
used = set(PLACEHOLDER_RE.findall(template))
@ -647,7 +837,7 @@ def cmd_resolve(args: argparse.Namespace) -> None:
package_dir, _ = resolve_installed(catalog, args.id, args.version)
manifest = validate_package(package_dir)
raw = supplied_values(args.set_values)
resolution = resolve_inputs(manifest, raw)
resolution = resolve_inputs(manifest, raw, composer=CatalogComposer(catalog))
if args.json:
print(
@ -664,12 +854,13 @@ def cmd_resolve(args: argparse.Namespace) -> None:
return
width = max((len(name) for name in resolution.origins), default=0)
origin_width = max((len(o) for o in resolution.origins.values()), default=0)
for name, origin in resolution.origins.items():
if name in resolution.values:
preview = str(resolution.values[name]).replace("\n", " ")
if len(preview) > 48:
preview = preview[:45] + "..."
print(f"{name:<{width}} {origin:<32} {preview}")
print(f"{name:<{width}} {origin:<{origin_width}} {preview}")
else:
print(f"{name:<{width}} {origin}")
if resolution.underivable:

View file

@ -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)