1"""Fragment library resolution for FSM loops.

2

3 Implements parse-time expansion of ``fragment:`` references in loop YAML files.

4 Fragment libraries define named partial state definitions that any loop can import

5 and reference, eliminating copy-paste duplication across loop files.

6

7 Fragment resolution happens before ``FSMLoop.from_dict`` is called, so the engine

8 never sees ``fragment:`` keys.

9

10 Example loop YAML::

11

12 import:

13 - lib/common.yaml

14

15 states:

16 lint:

17 fragment: shell_exit # inherits action_type + evaluate from fragment

18 action: "ruff check ."

19 on_yes: done

20 on_no: fix

21

22 Example library YAML (``lib/common.yaml``)::

23

24 fragments:

25 shell_exit:

26 action_type: shell

27 evaluate:

28 type: exit_code

29"""

30

31 from __future__ import annotations

32

33 from pathlib import Path

34 from typing import Any

35

36 import yaml

37

38_BUILTIN_LOOPS_DIR = Path(__file__).parent.parent / "loops"

39

40

41 def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:

42 """Deep-merge two dicts; override keys win at every nesting level.

43

44 For nested dicts, recursively merges instead of replacing. For all other

45 value types (str, int, bool, list, None), the override value wins outright.

46 Returns a new dict; neither input is mutated.

47

48 Args:

49 base: Base dict (e.g. a fragment definition).

50 override: Override dict (e.g. state-level fields).

51

52 Returns:

53 New merged dict with override taking precedence.

54 """

55 result = dict(base)

56 for key, value in override.items():

57 if key in result and isinstance(result[key], dict) and isinstance(value, dict):

58 result[key] = _deep_merge(result[key], value)

59 else:

60 result[key] = value

61 return result

62

63

64 def resolve_fragments(raw_loop_dict: dict[str, Any], loop_dir: Path) -> dict[str, Any]:

65 """Load fragment libraries, merge namespaces, and expand ``fragment:`` references.

66

67 Resolution steps:

68 1. Load each path in ``import:`` (relative to ``loop_dir``), collecting all

69 named fragments. Later imports override earlier imports for the same name.

70 2. Merge the loop's own ``fragments:`` block on top (local overrides imports).

71 3. For each state that has a ``fragment:`` key, deep-merge the named fragment

72 into the state dict (state-level keys win), then remove the ``fragment:`` key.

73

74 Returns a new dict with all ``fragment:`` keys expanded. The ``import:`` and

75 ``fragments:`` keys remain in the returned dict so callers can access them for

76 display or validation purposes.

77

78 Args:

79 raw_loop_dict: Raw YAML dict loaded from a loop file.

80 loop_dir: Directory containing the loop file; import paths are resolved

81 relative to this directory.

82

83 Returns:

84 New dict with fragment references expanded.

85

86 Raises:

87 FileNotFoundError: If an ``import:`` path does not exist relative to ``loop_dir``.

88 ValueError: If a state references a ``fragment:`` name that is not defined in

89 any imported library or the local ``fragments:`` block.

90 """

91 # Step 1: load imported fragment libraries

92 imported_fragments: dict[str, dict[str, Any]] = {}

93 for import_path in raw_loop_dict.get("import", []):

94 lib_path = loop_dir / import_path

95 if not lib_path.exists():

96 builtin_path = _BUILTIN_LOOPS_DIR / import_path

97 if builtin_path.exists():

98 lib_path = builtin_path

99 else:

100 raise FileNotFoundError(

101 f"Fragment library not found: {import_path} "

102 f"(checked '{loop_dir / import_path}' and '{builtin_path}')"

103 )

104 with open(lib_path) as f:

105 lib_data = yaml.safe_load(f)

106 if isinstance(lib_data, dict):

107 for name, frag in lib_data.get("fragments", {}).items():

108 imported_fragments[name] = frag

109

110 # Step 2: merge local fragments: block on top (local wins)

111 all_fragments: dict[str, dict[str, Any]] = {

112 **imported_fragments,

113 **raw_loop_dict.get("fragments", {}),

114 }

115

116 result = dict(raw_loop_dict)

117 states: dict[str, Any] = dict(result.get("states", {}))

118

119 # Step 3: if no states reference a fragment, return early (no-op)

120 if not any(isinstance(s, dict) and s.get("fragment") is not None for s in states.values()):

121 return result

122

123 for state_name, state_dict in states.items():

124 if not isinstance(state_dict, dict):

125 continue

126 fragment_name = state_dict.get("fragment")

127 if fragment_name is None:

128 continue

129 if fragment_name not in all_fragments:

130 available = ", ".join(sorted(all_fragments))

131 raise ValueError(

132 f"State '{state_name}': fragment '{fragment_name}' not found. "

133 f"Available fragments: {available or '(none)'}"

134 )

135 # Deep merge: fragment is the base, state fields override

136 # Strip description before merge — it is metadata, not a state field

137 frag_copy = dict(all_fragments[fragment_name])

138 frag_copy.pop("description", None)

139 frag_parameters = frag_copy.pop("parameters", {}) # capture before deep merge

140 merged = _deep_merge(frag_copy, state_dict)

141 # Carry fragment metadata for runtime binding and validation

142 fragment_bindings = merged.pop("with", {}) # rename with: → fragment_bindings

143 merged["fragment_name"] = fragment_name # preserve for validation lookup

144 merged["fragment_bindings"] = fragment_bindings # per-state param bindings

145 if frag_parameters:

146 merged["fragment_parameters"] = frag_parameters # fragment's declared params

147 del merged["fragment"] # consume the fragment: key

148 states[state_name] = merged

149

150 result["states"] = states

151 return result

152

153

154 def resolve_inheritance(

155 raw_loop_dict: dict[str, Any],

156 loop_dir: Path,

157 _seen: tuple[str, ...] = (),

158) -> dict[str, Any]:

159 """Resolve ``from:`` template inheritance by deep-merging parent into child.

160

161 A loop YAML with ``from: <name>`` inherits all top-level fields and states

162 from the named parent loop. The child's own fields override the parent's at

163 every nesting level (per :func:`_deep_merge` semantics): scalars and lists

164 are replaced by the child, dicts are merged recursively. The ``from:`` key

165 itself is stripped from the returned dict.

166

167 Parent lookup uses :func:`little_loops.cli.loop._helpers.resolve_loop_path`,

168 which searches ``loop_dir`` first then falls back to the bundled built-in

169 loops directory. Cycles in the ``from:`` chain raise ``ValueError`` with the

170 full chain path; missing parents raise ``FileNotFoundError``.

171

172 Resolution must run *before* :func:`resolve_fragments` and *before* the

173 required-fields check in :func:`load_and_validate`, so a child can omit

174 fields its parent provides (including ``initial`` and ``states``) and so a

175 parent's ``import:``/``fragments:`` blocks survive into the merged result.

176

177 Args:

178 raw_loop_dict: Raw YAML dict loaded from a loop file.

179 loop_dir: Directory containing the (child) loop file; parent names are

180 resolved relative to this directory.

181 _seen: Internal tuple of parent names already visited during recursion;

182 used for cycle detection.

183

184 Returns:

185 New dict with ``from:`` resolved and stripped. If the input has no

186 ``from:`` key, returns it unchanged.

187

188 Raises:

189 ValueError: If ``from:`` is not a string, the parent is not a YAML

190 mapping, or a cycle is detected in the inheritance chain.

191 FileNotFoundError: If the parent loop name cannot be resolved.

192 """

193 if "from" not in raw_loop_dict:

194 return raw_loop_dict

195

196 parent_name = raw_loop_dict["from"]

197 if not isinstance(parent_name, str):

198 raise ValueError(f"`from:` must be a string, got {type(parent_name).__name__}")

199

200 if parent_name in _seen:

201 chain = " -> ".join(_seen + (parent_name,))

202 raise ValueError(f"Circular `from:` chain: {chain}")

203

204 # Lazy import to avoid circular import at module load (cli.loop._helpers

205 # imports from fsm.* indirectly). Mirrors the pattern used in

206 # fsm/executor.py:410 for sub-loop calls.

207 from little_loops.cli.loop._helpers import resolve_loop_path

208

209 parent_path = resolve_loop_path(parent_name, loop_dir)

210 with open(parent_path) as f:

211 parent_data = yaml.safe_load(f)

212

213 if not isinstance(parent_data, dict):

214 raise ValueError(

215 f"Parent loop '{parent_name}' is not a YAML mapping (got {type(parent_data).__name__})"

216 )

217

218 parent_data = resolve_inheritance(parent_data, parent_path.parent, _seen + (parent_name,))

219

220 child_without_from = {k: v for k, v in raw_loop_dict.items() if k != "from"}

221 merged = _deep_merge(parent_data, child_without_from)

222 merged.pop("from", None)

223 return merged

224

225

226 def resolve_flow(raw_loop_dict: dict[str, Any]) -> dict[str, Any]:

227 """Expand ``flow:`` linear shorthand into a verbose ``states:`` map.

228

229 A loop YAML with ``flow: [<state>, ...]`` declares an ordered linear chain

230 of states. Each entry is either a bare name (unconditional forward

231 transition) or a ternary ``name?yes_target:no_target`` (conditional

232 branching). The last entry is implicitly ``terminal: true``.

233

234 Optional ``state_defs:`` supplies prompt/action/evaluate bodies that are

235 deep-merged into the generated state skeletons. If both ``flow:`` and

236 ``states:`` are present, raises ``ValueError`` — the two are mutually

237 exclusive.

238

239 Resolution runs *after* :func:`resolve_inheritance` (so a child can

240 override a parent's states with its own ``flow:``) and *before* the

241 required-fields check in :func:`load_and_validate` (so the expanded

242 ``states:`` key satisfies the validator).

243

244 Args:

245 raw_loop_dict: Raw YAML dict loaded from a loop file.

246

247 Returns:

248 New dict with ``flow:`` expanded to ``states:`` and both ``flow:``

249 and ``state_defs:`` stripped. If the input has no ``flow:`` key,

250 returns it unchanged.

251

252 Raises:

253 ValueError: If ``flow:`` is not a list, is empty, or contains a

254 malformed ternary entry.

255 """

256 if "flow" not in raw_loop_dict:

257 return raw_loop_dict

258

259 # If both flow: and states: are present, flow: takes precedence. This

260 # handles the case where states was inherited via `from:` — the child's

261 # explicit flow: overrides the parent's states.

262 #

263

264 flow = raw_loop_dict["flow"]

265 if not isinstance(flow, list):

266 raise ValueError(f"'flow:' must be a list, got {type(flow).__name__}")

267 if len(flow) < 1:

268 raise ValueError("'flow:' must contain at least one state")

269

270 state_defs: dict[str, dict[str, Any]] = raw_loop_dict.get("state_defs", {})

271 if not isinstance(state_defs, dict):

272 state_defs = {}

273

274 generated_states: dict[str, dict[str, Any]] = {}

275

276 # Pre-parse all entries to extract state names (strip ternary suffixes)

277 def _parse_name(raw: str) -> str:

278 return raw.split("?", 1)[0] if "?" in raw else raw

279

280 parsed_names = [_parse_name(e) if isinstance(e, str) else e for e in flow]

281

282 for i, entry in enumerate(flow):

283 is_last = i == len(flow) - 1

284 next_name = parsed_names[i + 1] if not is_last else None

285

286 if not isinstance(entry, str):

287 raise ValueError(f"'flow:' entry {i} must be a string, got {type(entry).__name__}")

288

289 if "?" in entry:

290 # Ternary form: name?yes_target:no_target

291 parts = entry.split("?", 1)

292 state_name = parts[0]

293 targets = parts[1]

294

295 if ":" not in targets or targets.endswith(":") or targets.startswith(":"):

296 raise ValueError(

297 f"Malformed ternary in flow entry '{entry}': must be name?yes_target:no_target"

298 )

299 yes_target, no_target = targets.split(":", 1)

300 if not yes_target or not no_target:

301 raise ValueError(

302 f"Malformed ternary in flow entry '{entry}': "

303 f"both yes and no targets must be non-empty"

304 )

305

306 skeleton: dict[str, Any] = {"on_yes": yes_target, "on_no": no_target}

307 else:

308 state_name = entry

309 skeleton = {}

310 if next_name is not None:

311 skeleton["next"] = next_name

312

313 if is_last:

314 skeleton["terminal"] = True

315

316 # Deep-merge state_defs body into generated skeleton

317 if state_name in state_defs:

318 skeleton = _deep_merge(skeleton, state_defs[state_name])

319

320 generated_states[state_name] = skeleton

321

322 result = {k: v for k, v in raw_loop_dict.items() if k not in ("flow", "state_defs")}

323 result["states"] = generated_states

324 return result