CANP-WP-0002 T01: input defaults, static and derived

Closes the gap that made optional inputs unusable: rendering rule 5.1(4)
made any unresolved placeholder an error while inputs had no `default`, so
an input marked `required: false` and referenced from the template failed
every render in which the caller omitted it — including the spec's own
section 4 example.

Section 10 already carried the derivation mechanism (`requirement: generate`,
resolution deliberately undefined), so a derived default needed a binding
rather than a new concept: the input's default names a declared prompt
dependency.

Spec:
- 5.1 rewritten as "Resolution and rendering". Resolution may be
  non-deterministic and must report what it derived; rendering is
  deterministic and must not derive. A tool that handles only supplied
  values and static defaults is stated to be conforming.
- 6.1 (new) covers both declaration forms. Reference form is preferred, with
  the reason stated — an inline prompt is anonymous, so unversioned,
  unprovenanced and un-evaluable — and validators should warn when a
  published package derives inline.
- A derived default may declare a static fallback `value`. Without one the
  input stays unresolved, which is an error; derivation never silently
  yields empty content.
- 4, 10, 18 (rules 11-13), 19 (two new MUST NOTs), 21 updated accordingly.

Reference CLI:
- New `resolve` verb reporting the origin of every value.
- `Resolution` dataclass and `resolve_inputs`; `resolve_values` kept as a
  wrapper so existing callers are unaffected.
- `render` refuses with a specific error naming underivable inputs rather
  than substituting empty text.
- Tests 3 -> 11. Example package lifecycle re-verified end to end.

INTENT.md is unchanged: splitting resolve from render preserves success
criterion 4 (deterministic rendering) as written.

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 00:59:20 +02:00
parent 0caf065544
commit 169db25d25
6 changed files with 600 additions and 36 deletions

View file

@ -13,6 +13,7 @@ import os
import re
import shutil
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable
@ -92,6 +93,78 @@ def declared_names(manifest: dict[str, Any]) -> set[str]:
return names
def input_default_kind(item: dict[str, Any]) -> str:
"""Classify an input's default as 'none', 'static', or 'derived' (§ 6.1)."""
if "default" not in item:
return "none"
default = item["default"]
if isinstance(default, dict) and "derive" in default:
return "derived"
return "static"
def prompt_dependency_ids(manifest: dict[str, Any]) -> set[str]:
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()
for entry in prompts:
if isinstance(entry, str):
ids.add(entry)
elif isinstance(entry, dict) and isinstance(entry.get("id"), str):
ids.add(entry["id"])
else:
raise CannedPromptError(
"each prompt dependency must be an id string or a mapping with a string id"
)
return ids
def validate_input_default(item: dict[str, Any], dependency_ids: set[str]) -> None:
"""Validation rules 11-13 of § 18."""
kind = input_default_kind(item)
if kind == "none":
return
name = item["name"]
if item.get("required", False):
raise CannedPromptError(
f"input {name!r} declares a default and required: true; a required "
"input is always supplied, so the default could never apply"
)
if kind == "static":
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"
)
return
if isinstance(derive, dict):
prompt = derive.get("prompt")
if not isinstance(prompt, str) or not prompt.strip():
raise CannedPromptError(
f"input {name!r}: inline derive requires a non-empty prompt"
)
extra = sorted(set(derive) - {"prompt"})
if extra:
raise CannedPromptError(
f"input {name!r}: an inline derive cannot also reference a "
"dependency; unexpected keys: " + ", ".join(extra)
)
return
raise CannedPromptError(
f"input {name!r}: derive must be a dependency id or a mapping with a prompt"
)
def validate_package(package_dir: Path) -> dict[str, Any]:
package_dir = package_dir.resolve()
if not package_dir.is_dir():
@ -127,6 +200,11 @@ def validate_package(package_dir: Path) -> dict[str, Any]:
safe_relative_file(package_dir, relative, field)
names = declared_names(manifest)
dependency_ids = prompt_dependency_ids(manifest)
for item in manifest.get("inputs") or []:
validate_input_default(item, dependency_ids)
template = template_path.read_text(encoding="utf-8")
placeholders = set(PLACEHOLDER_RE.findall(template))
undeclared = sorted(placeholders - names)
@ -305,8 +383,23 @@ def supplied_values(pairs: list[str]) -> dict[str, str]:
return values
def resolve_values(manifest: dict[str, Any], raw_values: dict[str, str]) -> dict[str, Any]:
resolved: dict[str, Any] = {}
@dataclass
class Resolution:
"""The outcome of § 5.1 resolution, separate from rendering."""
values: dict[str, Any] = field(default_factory=dict)
origins: dict[str, str] = field(default_factory=dict)
underivable: list[str] = field(default_factory=list)
def resolve_inputs(manifest: dict[str, Any], raw_values: dict[str, str]) -> 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.
"""
result = Resolution()
known: set[str] = set()
inputs = manifest.get("inputs") or []
@ -314,23 +407,48 @@ def resolve_values(manifest: dict[str, Any], raw_values: dict[str, str]) -> dict
name = item["name"]
known.add(name)
if name in raw_values:
resolved[name] = raw_values[name]
elif item.get("required", False):
result.values[name] = raw_values[name]
result.origins[name] = "supplied"
continue
if item.get("required", False):
raise CannedPromptError(f"missing required input: {name}")
kind = input_default_kind(item)
if kind == "static":
result.values[name] = item["default"]
result.origins[name] = "default"
elif kind == "derived":
default = item["default"]
if "value" in default:
result.values[name] = default["value"]
result.origins[name] = "fallback (not derived)"
else:
result.origins[name] = "unresolved (derived, no fallback)"
result.underivable.append(name)
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:
resolved[name] = coerce_value(raw_values[name], spec)
result.values[name] = coerce_value(raw_values[name], spec)
result.origins[name] = "supplied"
elif "default" in spec:
resolved[name] = spec["default"]
result.values[name] = spec["default"]
result.origins[name] = "default"
else:
result.origins[name] = "unresolved (no default)"
unknown = sorted(set(raw_values) - known)
if unknown:
raise CannedPromptError("unknown values: " + ", ".join(unknown))
return resolved
return result
def resolve_values(manifest: dict[str, Any], raw_values: dict[str, str]) -> dict[str, Any]:
return resolve_inputs(manifest, raw_values).values
def render_template(template: str, values: dict[str, Any]) -> str:
@ -354,11 +472,65 @@ 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)
values = resolve_values(manifest, raw)
rendered = render_template(template_path.read_text(encoding="utf-8"), values)
resolution = resolve_inputs(manifest, raw)
template = template_path.read_text(encoding="utf-8")
used = set(PLACEHOLDER_RE.findall(template))
blocked = sorted(used.intersection(resolution.underivable))
if blocked:
raise CannedPromptError(
"cannot render: this tool does not derive values, and these inputs "
"declare a derived default with no static fallback: "
+ ", ".join(blocked)
+ " — supply them with --set, or add a fallback `value` to the default"
)
rendered = render_template(template, resolution.values)
print(rendered, end="" if rendered.endswith("\n") else "\n")
def cmd_resolve(args: argparse.Namespace) -> None:
catalog = Path(args.catalog).expanduser()
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)
if args.json:
print(
json.dumps(
{
"values": resolution.values,
"origins": resolution.origins,
"underivable": resolution.underivable,
},
indent=2,
ensure_ascii=False,
)
)
return
width = max((len(name) for name in resolution.origins), 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}")
else:
print(f"{name:<{width}} {origin}")
if resolution.underivable:
print()
plural = len(resolution.underivable) > 1
print(
"note: "
+ ", ".join(resolution.underivable)
+ (" declare" if plural else " declares")
+ " a derived default this tool cannot satisfy; rendering will fail "
+ ("if the template uses them" if plural else "if the template uses it")
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="canned-prompts", description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
@ -388,6 +560,14 @@ def build_parser() -> argparse.ArgumentParser:
render.add_argument("--set", dest="set_values", action="append", default=[], metavar="NAME=VALUE")
render.set_defaults(func=cmd_render)
resolve = sub.add_parser("resolve", help="report how each input and parameter resolves")
resolve.add_argument("id")
resolve.add_argument("--version")
resolve.add_argument("--catalog", default=str(default_catalog()))
resolve.add_argument("--set", dest="set_values", action="append", default=[], metavar="NAME=VALUE")
resolve.add_argument("--json", action="store_true")
resolve.set_defaults(func=cmd_resolve)
install = sub.add_parser("install", help="install a package from a filesystem registry")
install.add_argument("id")
install.add_argument("--version")