Coverage for src/keel/extensions.py: 100%
105 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-02 20:26 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-02 20:26 +0000
1"""Load + validate project Lego extensions snapped into named backbone slots.
3An extension is a small markdown file with a YAML frontmatter mini-spec plus a
4body (prompt or command). The contract (see ``docs/proposals/keel-architecture.md``):
6* **Add-only.** An extension may only register into one of the named
7 :data:`keel.model.SLOTS`; it can never remove/reorder/replace a backbone step.
8* **Fail-soft.** A broken extension degrades to a no-op (``strict=False`` returns
9 the problems instead of raising) — unless it declares itself a hard gate
10 (``on_fail: block``, valid only in the slots :data:`keel.model.SLOT_DEFINITIONS`
11 marks ``may_block``: ``guard``, ``tester``, ``test`` and ``pre-merge``). This named
12 ``pre-merge`` alone until #1100 — a restriction :func:`parse_extension` never had, and
13 the direction of error that costs a reader work: an author who wants a blocking
14 ``guard`` believes the slot cannot block and builds a workaround for nothing. Prose
15 cannot compute the set, so ``tests/test_docs_claims.py`` reads the names back out of
16 this sentence and compares them to ``may_block`` — here and in every doc that
17 restates them.
18* **Agent-neutral.** Each extension declares its ``agent`` (default ``inherit``).
19"""
21from __future__ import annotations
23from dataclasses import dataclass
24from pathlib import Path
25from typing import TYPE_CHECKING
27from . import yaml_helper as yaml
28from .capabilities import validate_names
29from .model import SLOTS, slot_meta
31if TYPE_CHECKING: # pragma: no cover
32 from .config import ProjectConfig
34KINDS: tuple[str, ...] = ("agentic", "command")
35EXECUTION_MODES: tuple[str, ...] = ("deterministic", "agentic", "hybrid")
36ON_FAIL: tuple[str, ...] = ("warn", "suggest", "block")
39class ExtensionError(ValueError):
40 """Raised on a malformed extension (or, in strict mode, any load problem)."""
43@dataclass(frozen=True)
44class Extension:
45 """A parsed, validated Lego piece."""
47 id: str
48 slot: str
49 kind: str
50 mode: str
51 agent: str
52 on_fail: str
53 anchorable: bool
54 run: str | None
55 prompt: str | None
56 body: str
57 source: str
58 required_capabilities: tuple[str, ...] = ()
59 optional_capabilities: tuple[str, ...] = ()
60 #: Wall-clock seconds for a ``command`` piece that is legitimately slower than the
61 #: rest. ``None`` ⇒ inherit the project's ``knobs.gate_timeout_s``.
62 timeout: int | None = None
65def split_frontmatter(text: str) -> tuple[dict, str]:
66 """Split ``---\\n…\\n---\\n<body>`` into (metadata dict, body). No fence ⇒ ({}, text)."""
67 lines = text.splitlines()
68 if not lines or lines[0].strip() != "---":
69 return {}, text
70 for i in range(1, len(lines)):
71 if lines[i].strip() == "---":
72 meta = yaml.load("\n".join(lines[1:i])) or {}
73 body = "\n".join(lines[i + 1 :])
74 return meta, body
75 raise ExtensionError("unterminated frontmatter (no closing '---')")
78def parse_extension(text: str, *, source: str, expected_slot: str | None = None) -> Extension:
79 """Parse + validate one extension file's text into an :class:`Extension`."""
80 meta, body = split_frontmatter(text)
81 if not isinstance(meta, dict) or not meta:
82 raise ExtensionError(f"{source}: missing frontmatter mini-spec")
84 errors: list[str] = []
85 ext_id = meta.get("id")
86 slot = meta.get("slot")
87 kind = meta.get("kind", "agentic")
88 mode = meta.get("mode")
89 on_fail = meta.get("on_fail", "warn")
90 agent = meta.get("agent", "inherit")
91 run = meta.get("run")
92 prompt = meta.get("prompt")
93 required_capabilities = tuple(meta.get("required_capabilities", []))
94 optional_capabilities = tuple(meta.get("optional_capabilities", []))
95 timeout = meta.get("timeout")
97 if not ext_id:
98 errors.append("missing 'id'")
99 if not slot:
100 errors.append("missing 'slot'")
101 elif slot not in SLOTS:
102 errors.append(f"unknown slot {slot!r}; valid: {', '.join(SLOTS)}")
103 elif expected_slot is not None and slot != expected_slot:
104 errors.append(f"slot {slot!r} does not match its registered slot ({expected_slot!r})")
106 if kind not in KINDS:
107 errors.append(f"invalid kind {kind!r}; valid: {', '.join(KINDS)}")
108 if mode is None:
109 mode = "deterministic" if kind == "command" else "agentic"
110 elif mode not in EXECUTION_MODES:
111 errors.append(f"invalid mode {mode!r}; valid: {', '.join(EXECUTION_MODES)}")
112 if on_fail not in ON_FAIL:
113 errors.append(f"invalid on_fail {on_fail!r}; valid: {', '.join(ON_FAIL)}")
114 elif on_fail == "block" and slot in SLOTS and not slot_meta(slot).may_block:
115 blocking = ", ".join(s for s in SLOTS if slot_meta(s).may_block)
116 errors.append(f"on_fail: block is only allowed in blocking slots: {blocking}")
118 if kind == "command" and not run:
119 errors.append("a 'command' extension requires a 'run' value")
120 if timeout is not None:
121 # bool is an int subclass — `timeout: true` is a typo, not a limit.
122 if isinstance(timeout, bool) or not isinstance(timeout, int) or timeout < 1:
123 errors.append(f"invalid timeout {timeout!r}; expected a positive integer (seconds)")
124 if kind != "command":
125 errors.append(f"'timeout' only applies to a 'command' extension (got kind {kind!r})")
126 if kind == "agentic" and not (prompt or body.strip()):
127 errors.append("an 'agentic' extension requires a 'prompt' value or a body")
128 errors.extend(validate_names(required_capabilities, source=f"{source}: required_capabilities"))
129 errors.extend(validate_names(optional_capabilities, source=f"{source}: optional_capabilities"))
131 if errors:
132 raise ExtensionError(f"{source}: " + "; ".join(errors))
134 return Extension(
135 id=ext_id,
136 slot=slot,
137 kind=kind,
138 mode=mode,
139 agent=agent,
140 on_fail=on_fail,
141 anchorable=bool(meta.get("anchorable", False)),
142 run=run,
143 prompt=prompt,
144 required_capabilities=required_capabilities,
145 optional_capabilities=optional_capabilities,
146 timeout=timeout,
147 body=body,
148 source=source,
149 )
152def load_extensions(
153 config: ProjectConfig, repo_root: str | Path, *, strict: bool = True
154) -> tuple[dict[str, list[Extension]], list[str]]:
155 """Load every extension referenced by ``config`` from ``repo_root``.
157 Returns ``(loaded, problems)`` where ``loaded`` maps each slot to its
158 extensions in declared order. In ``strict`` mode any problem raises
159 :class:`ExtensionError`; otherwise problems are returned (fail-soft) and the
160 offending pieces are skipped.
161 """
162 ext_dir = Path(repo_root) / config.extensions_dir
163 loaded: dict[str, list[Extension]] = {slot: [] for slot in SLOTS}
164 problems: list[str] = []
166 for slot in SLOTS:
167 for fname in config.slot(slot):
168 path = ext_dir / fname
169 try:
170 text = path.read_text(encoding="utf-8")
171 except OSError as exc:
172 problems.append(f"{slot}: cannot read {fname}: {exc.strerror or exc}")
173 continue
174 try:
175 loaded[slot].append(parse_extension(text, source=str(path), expected_slot=slot))
176 except ExtensionError as exc:
177 problems.append(str(exc))
179 if strict and problems:
180 raise ExtensionError("invalid extensions:\n - " + "\n - ".join(problems))
181 return loaded, problems