Coverage for src/keel/extensions.py: 100%

105 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-18 12:05 +0000

1"""Load + validate project Lego extensions snapped into named backbone slots. 

2 

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``): 

5 

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 ``pre-merge`` slot). 

11* **Agent-neutral.** Each extension declares its ``agent`` (default ``inherit``). 

12""" 

13 

14from __future__ import annotations 

15 

16from dataclasses import dataclass 

17from pathlib import Path 

18from typing import TYPE_CHECKING 

19 

20from . import yaml_helper as yaml 

21from .capabilities import validate_names 

22from .model import SLOTS, slot_meta 

23 

24if TYPE_CHECKING: # pragma: no cover 

25 from .config import ProjectConfig 

26 

27KINDS: tuple[str, ...] = ("agentic", "command") 

28EXECUTION_MODES: tuple[str, ...] = ("deterministic", "agentic", "hybrid") 

29ON_FAIL: tuple[str, ...] = ("warn", "suggest", "block") 

30 

31 

32class ExtensionError(ValueError): 

33 """Raised on a malformed extension (or, in strict mode, any load problem).""" 

34 

35 

36@dataclass(frozen=True) 

37class Extension: 

38 """A parsed, validated Lego piece.""" 

39 

40 id: str 

41 slot: str 

42 kind: str 

43 mode: str 

44 agent: str 

45 on_fail: str 

46 anchorable: bool 

47 run: str | None 

48 prompt: str | None 

49 body: str 

50 source: str 

51 required_capabilities: tuple[str, ...] = () 

52 optional_capabilities: tuple[str, ...] = () 

53 #: Wall-clock seconds for a ``command`` piece that is legitimately slower than the 

54 #: rest. ``None`` ⇒ inherit the project's ``knobs.gate_timeout_s``. 

55 timeout: int | None = None 

56 

57 

58def split_frontmatter(text: str) -> tuple[dict, str]: 

59 """Split ``---\\n…\\n---\\n<body>`` into (metadata dict, body). No fence ⇒ ({}, text).""" 

60 lines = text.splitlines() 

61 if not lines or lines[0].strip() != "---": 

62 return {}, text 

63 for i in range(1, len(lines)): 

64 if lines[i].strip() == "---": 

65 meta = yaml.load("\n".join(lines[1:i])) or {} 

66 body = "\n".join(lines[i + 1:]) 

67 return meta, body 

68 raise ExtensionError("unterminated frontmatter (no closing '---')") 

69 

70 

71def parse_extension(text: str, *, source: str, expected_slot: str | None = None) -> Extension: 

72 """Parse + validate one extension file's text into an :class:`Extension`.""" 

73 meta, body = split_frontmatter(text) 

74 if not isinstance(meta, dict) or not meta: 

75 raise ExtensionError(f"{source}: missing frontmatter mini-spec") 

76 

77 errors: list[str] = [] 

78 ext_id = meta.get("id") 

79 slot = meta.get("slot") 

80 kind = meta.get("kind", "agentic") 

81 mode = meta.get("mode") 

82 on_fail = meta.get("on_fail", "warn") 

83 agent = meta.get("agent", "inherit") 

84 run = meta.get("run") 

85 prompt = meta.get("prompt") 

86 required_capabilities = tuple(meta.get("required_capabilities", [])) 

87 optional_capabilities = tuple(meta.get("optional_capabilities", [])) 

88 timeout = meta.get("timeout") 

89 

90 if not ext_id: 

91 errors.append("missing 'id'") 

92 if not slot: 

93 errors.append("missing 'slot'") 

94 elif slot not in SLOTS: 

95 errors.append(f"unknown slot {slot!r}; valid: {', '.join(SLOTS)}") 

96 elif expected_slot is not None and slot != expected_slot: 

97 errors.append( 

98 f"slot {slot!r} does not match its registered slot ({expected_slot!r})" 

99 ) 

100 

101 if kind not in KINDS: 

102 errors.append(f"invalid kind {kind!r}; valid: {', '.join(KINDS)}") 

103 if mode is None: 

104 mode = "deterministic" if kind == "command" else "agentic" 

105 elif mode not in EXECUTION_MODES: 

106 errors.append(f"invalid mode {mode!r}; valid: {', '.join(EXECUTION_MODES)}") 

107 if on_fail not in ON_FAIL: 

108 errors.append(f"invalid on_fail {on_fail!r}; valid: {', '.join(ON_FAIL)}") 

109 elif on_fail == "block" and slot in SLOTS and not slot_meta(slot).may_block: 

110 blocking = ", ".join(s for s in SLOTS if slot_meta(s).may_block) 

111 errors.append(f"on_fail: block is only allowed in blocking slots: {blocking}") 

112 

113 if kind == "command" and not run: 

114 errors.append("a 'command' extension requires a 'run' value") 

115 if timeout is not None: 

116 # bool is an int subclass — `timeout: true` is a typo, not a limit. 

117 if isinstance(timeout, bool) or not isinstance(timeout, int) or timeout < 1: 

118 errors.append(f"invalid timeout {timeout!r}; expected a positive integer (seconds)") 

119 if kind != "command": 

120 errors.append(f"'timeout' only applies to a 'command' extension (got kind {kind!r})") 

121 if kind == "agentic" and not (prompt or body.strip()): 

122 errors.append("an 'agentic' extension requires a 'prompt' value or a body") 

123 errors.extend(validate_names(required_capabilities, source=f"{source}: required_capabilities")) 

124 errors.extend(validate_names(optional_capabilities, source=f"{source}: optional_capabilities")) 

125 

126 if errors: 

127 raise ExtensionError(f"{source}: " + "; ".join(errors)) 

128 

129 return Extension( 

130 id=ext_id, 

131 slot=slot, 

132 kind=kind, 

133 mode=mode, 

134 agent=agent, 

135 on_fail=on_fail, 

136 anchorable=bool(meta.get("anchorable", False)), 

137 run=run, 

138 prompt=prompt, 

139 required_capabilities=required_capabilities, 

140 optional_capabilities=optional_capabilities, 

141 timeout=timeout, 

142 body=body, 

143 source=source, 

144 ) 

145 

146 

147def load_extensions( 

148 config: ProjectConfig, repo_root: str | Path, *, strict: bool = True 

149) -> tuple[dict[str, list[Extension]], list[str]]: 

150 """Load every extension referenced by ``config`` from ``repo_root``. 

151 

152 Returns ``(loaded, problems)`` where ``loaded`` maps each slot to its 

153 extensions in declared order. In ``strict`` mode any problem raises 

154 :class:`ExtensionError`; otherwise problems are returned (fail-soft) and the 

155 offending pieces are skipped. 

156 """ 

157 ext_dir = Path(repo_root) / config.extensions_dir 

158 loaded: dict[str, list[Extension]] = {slot: [] for slot in SLOTS} 

159 problems: list[str] = [] 

160 

161 for slot in SLOTS: 

162 for fname in config.slot(slot): 

163 path = ext_dir / fname 

164 try: 

165 text = path.read_text(encoding="utf-8") 

166 except OSError as exc: 

167 problems.append(f"{slot}: cannot read {fname}: {exc.strerror or exc}") 

168 continue 

169 try: 

170 loaded[slot].append(parse_extension(text, source=str(path), expected_slot=slot)) 

171 except ExtensionError as exc: 

172 problems.append(str(exc)) 

173 

174 if strict and problems: 

175 raise ExtensionError("invalid extensions:\n - " + "\n - ".join(problems)) 

176 return loaded, problems