ai-forge-cli 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cli/walker.py ADDED
@@ -0,0 +1,799 @@
1
+ """
2
+ Per-kind dependency walkers.
3
+
4
+ Each walker returns an ordered dict of section-name -> content, which
5
+ the bundle formatter renders in source order. Sections with None values
6
+ are omitted.
7
+
8
+ Walker outputs:
9
+ - atom: L0 slice, L1, L2 module, policies applied, types, atom
10
+ spec, called signatures, L4 callers, L5
11
+ - module: L1, module spec, owned atoms (full), owned artifacts,
12
+ applied policies, shared module interfaces, L0 slice, L5
13
+ - journey: L1, journey spec, entry point, handler atoms (full),
14
+ invoked orchestrations (full), L0 slice, L5
15
+ - flow: L1, flow spec, trigger payload type, sequence atoms
16
+ (signatures), compensation atoms (signatures), L0 slice, L5
17
+ - artifact: L1, artifact spec, owner module, producer atom
18
+ (signature), source artifacts (shallow), consumers
19
+ (signatures), L0 schema type
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ from collections import OrderedDict
26
+ from typing import Any
27
+
28
+ from cli.index import Entry, Index
29
+
30
+ # Side-effect markers that indicate a step mutates state (for saga
31
+ # compensation implication heuristic).
32
+ _MUTATING_MARKERS = {"WRITES_DB", "WRITES_FS", "WRITES_CACHE", "EMITS_EVENT", "CALLS_EXTERNAL"}
33
+
34
+ # Primitive types from L0.4 — not extracted from L0, filtered out.
35
+ _PRIMITIVES = {"string", "integer", "number", "boolean", "bigint", "bytes", "timestamp", "uuid"}
36
+
37
+ # Regex to pull id-shaped tokens out of free-form logic/invariant strings.
38
+ _ID_TOKEN = re.compile(
39
+ r"const\.([A-Z][A-Z0-9_]+)"
40
+ r"|external\.([a-z][a-z0-9_]*)\."
41
+ r"|(atm\.[a-z]{3}\.[a-z_]+)"
42
+ r"|((?<![A-Za-z0-9_.])[A-Z]{3}\.[A-Z]{3}\.\d{3})"
43
+ r"|(reg\.[a-z]+\.[A-Z][a-zA-Z]+)"
44
+ )
45
+
46
+
47
+ # ======================================================================
48
+ # Public dispatch
49
+ # ======================================================================
50
+
51
+ def walk(idx: Index, entity_id: str) -> tuple[OrderedDict[str, Any], list[str]]:
52
+ """Build a context bundle for entity_id. Returns (bundle, unresolved_ids)."""
53
+ entry = idx.get(entity_id)
54
+ if entry is None:
55
+ raise KeyError(f"Unknown id: {entity_id}")
56
+
57
+ unresolved: list[str] = []
58
+ dispatch = {
59
+ "atom": expand_atom,
60
+ "module": expand_module,
61
+ "journey": expand_journey,
62
+ "flow": expand_flow,
63
+ "artifact": expand_artifact,
64
+ }
65
+ fn = dispatch.get(entry.kind)
66
+ if fn is None:
67
+ raise ValueError(f"{entity_id} (kind={entry.kind}) is not bundleable")
68
+
69
+ bundle = fn(idx, entry, unresolved)
70
+ return bundle, unresolved
71
+
72
+
73
+ # ======================================================================
74
+ # Atom walker
75
+ # ======================================================================
76
+
77
+ def expand_atom(idx: Index, atom: Entry, unresolved: list[str]) -> OrderedDict[str, Any]:
78
+ spec = atom.data.get("spec") or {}
79
+ owner_id = atom.data.get("owner_module")
80
+ owner = idx.get(owner_id) if owner_id else None
81
+
82
+ type_ids = _collect_type_ids(spec)
83
+ error_codes = _collect_error_codes(spec)
84
+ constants = _collect_constants_from_text(_logic_text(spec))
85
+ markers = set(spec.get("side_effects") or [])
86
+ external_schemas = _collect_external_schemas(_logic_text(spec))
87
+ called_atom_ids = _collect_called_atoms(spec)
88
+
89
+ # MODEL atom pulls in its training data artifact.
90
+ training_artifact = None
91
+ if atom.data.get("kind") == "MODEL":
92
+ ts = (spec.get("training_contract") or {}).get("data_source")
93
+ if ts:
94
+ training_artifact = _resolve(idx, ts, "artifact", unresolved)
95
+ fb = (spec.get("fallback") or {}).get("invoke")
96
+ if fb:
97
+ called_atom_ids.add(fb)
98
+
99
+ # L1 propagation wrap_unexpected code is universally relevant.
100
+ unexpected_code = (idx.l1.get("failure", {}).get("propagation", {}) or {}).get("unexpected_code")
101
+ if unexpected_code:
102
+ error_codes.add(unexpected_code)
103
+
104
+ # Applicable policies: those listed on owner module whose applies_when
105
+ # predicate matches this atom.
106
+ policies_applied = _filter_policies_for_atom(idx, atom, owner)
107
+
108
+ # L4 callers.
109
+ callers = _find_atom_callers(idx, atom.id)
110
+
111
+ # Called atom signatures.
112
+ called_sigs = OrderedDict()
113
+ for aid in sorted(called_atom_ids):
114
+ called_sigs[aid] = _atom_signature(idx, aid, unresolved)
115
+
116
+ l0_slice = _build_l0_slice(
117
+ idx,
118
+ type_ids=type_ids,
119
+ error_codes=error_codes,
120
+ constant_ids=constants,
121
+ markers=markers,
122
+ external_schemas=external_schemas,
123
+ )
124
+
125
+ bundle: OrderedDict[str, Any] = OrderedDict()
126
+ bundle["target"] = {"id": atom.id, "kind": "atom", "atom_kind": atom.data.get("kind")}
127
+ bundle["l0_registry_slice"] = l0_slice
128
+ bundle["l1_conventions"] = idx.l1
129
+ if owner:
130
+ bundle["l2_module"] = owner.data
131
+ else:
132
+ bundle["l2_module"] = None
133
+ unresolved.append(owner_id or "<missing owner_module>")
134
+ bundle["policies_applied"] = policies_applied
135
+ bundle["l3_atom"] = atom.data
136
+ bundle["called_atom_signatures"] = called_sigs
137
+ if training_artifact is not None:
138
+ bundle["training_artifact"] = training_artifact
139
+ bundle["l4_callers"] = callers
140
+ bundle["l5_operations"] = idx.l5
141
+ return bundle
142
+
143
+
144
+ # ======================================================================
145
+ # Module walker
146
+ # ======================================================================
147
+
148
+ def expand_module(idx: Index, module: Entry, unresolved: list[str]) -> OrderedDict[str, Any]:
149
+ owned_atoms = module.data.get("owned_atoms") or []
150
+ owned_artifacts = module.data.get("owned_artifacts") or []
151
+ dep_modules = (module.data.get("dependency_whitelist") or {}).get("modules") or []
152
+ policy_ids = module.data.get("policies") or []
153
+
154
+ # Full expansion of each owned atom.
155
+ atoms_full: OrderedDict[str, Any] = OrderedDict()
156
+ aggregated_type_ids: set[str] = set()
157
+ aggregated_errors: set[str] = set()
158
+ aggregated_constants: set[str] = set()
159
+ aggregated_markers: set[str] = set()
160
+ aggregated_externals: set[str] = set()
161
+
162
+ for aid in owned_atoms:
163
+ entry = idx.get(aid)
164
+ if entry is None:
165
+ unresolved.append(aid)
166
+ atoms_full[aid] = {"status": "UNRESOLVED"}
167
+ continue
168
+ atoms_full[aid] = entry.data
169
+ spec = entry.data.get("spec") or {}
170
+ aggregated_type_ids |= _collect_type_ids(spec)
171
+ aggregated_errors |= _collect_error_codes(spec)
172
+ aggregated_constants |= _collect_constants_from_text(_logic_text(spec))
173
+ aggregated_markers |= set(spec.get("side_effects") or [])
174
+ aggregated_externals |= _collect_external_schemas(_logic_text(spec))
175
+
176
+ # Include datastores' entity types.
177
+ for datastore in (module.data.get("persistence_schema") or {}).get("datastores") or []:
178
+ if datastore.get("type"):
179
+ aggregated_type_ids.add(datastore["type"])
180
+
181
+ artifacts_full = OrderedDict()
182
+ for art_id in owned_artifacts:
183
+ entry = idx.get(art_id)
184
+ artifacts_full[art_id] = entry.data if entry else {"status": "UNRESOLVED"}
185
+ if entry is None:
186
+ unresolved.append(art_id)
187
+
188
+ # Shared module interfaces (signatures only).
189
+ shared_deps = OrderedDict()
190
+ for mid in dep_modules:
191
+ entry = idx.get(mid)
192
+ if entry:
193
+ shared_deps[mid] = {
194
+ "id": entry.data.get("id"),
195
+ "description": entry.data.get("description"),
196
+ "interface": entry.data.get("interface"),
197
+ }
198
+ else:
199
+ shared_deps[mid] = {"status": "UNRESOLVED"}
200
+ unresolved.append(mid)
201
+
202
+ policies = OrderedDict()
203
+ for pid in policy_ids:
204
+ entry = idx.get(pid)
205
+ policies[pid] = entry.data if entry else {"status": "UNRESOLVED"}
206
+ if entry is None:
207
+ unresolved.append(pid)
208
+
209
+ l0_slice = _build_l0_slice(
210
+ idx,
211
+ type_ids=aggregated_type_ids,
212
+ error_codes=aggregated_errors,
213
+ constant_ids=aggregated_constants,
214
+ markers=aggregated_markers,
215
+ external_schemas=aggregated_externals,
216
+ )
217
+
218
+ bundle: OrderedDict[str, Any] = OrderedDict()
219
+ bundle["target"] = {"id": module.id, "kind": "module"}
220
+ bundle["l0_registry_slice"] = l0_slice
221
+ bundle["l1_conventions"] = idx.l1
222
+ bundle["l2_module"] = module.data
223
+ bundle["policies"] = policies
224
+ bundle["shared_module_interfaces"] = shared_deps
225
+ bundle["owned_atoms"] = atoms_full
226
+ bundle["owned_artifacts"] = artifacts_full
227
+ bundle["l5_operations"] = idx.l5
228
+ return bundle
229
+
230
+
231
+ # ======================================================================
232
+ # Journey walker
233
+ # ======================================================================
234
+
235
+ def expand_journey(idx: Index, journey: Entry, unresolved: list[str]) -> OrderedDict[str, Any]:
236
+ handlers = journey.data.get("handlers") or {}
237
+ transitions = journey.data.get("transitions") or []
238
+
239
+ handler_atoms: OrderedDict[str, Any] = OrderedDict()
240
+ invoked_flows: OrderedDict[str, Any] = OrderedDict()
241
+ aggregated_type_ids: set[str] = set()
242
+ aggregated_errors: set[str] = set()
243
+ aggregated_constants: set[str] = set()
244
+ aggregated_markers: set[str] = set()
245
+ aggregated_externals: set[str] = set()
246
+
247
+ for state, handler in handlers.items():
248
+ aid = handler.get("atom") if isinstance(handler, dict) else None
249
+ if aid:
250
+ entry = idx.get(aid)
251
+ if entry:
252
+ handler_atoms[aid] = entry.data
253
+ spec = entry.data.get("spec") or {}
254
+ aggregated_type_ids |= _collect_type_ids(spec)
255
+ aggregated_errors |= _collect_error_codes(spec)
256
+ aggregated_constants |= _collect_constants_from_text(_logic_text(spec))
257
+ aggregated_markers |= set(spec.get("side_effects") or [])
258
+ aggregated_externals |= _collect_external_schemas(_logic_text(spec))
259
+ else:
260
+ handler_atoms[aid] = {"status": "UNRESOLVED"}
261
+ unresolved.append(aid)
262
+
263
+ for t in transitions:
264
+ inv = t.get("invoke") if isinstance(t, dict) else None
265
+ if not inv:
266
+ continue
267
+ entry = idx.get(inv)
268
+ if entry is None:
269
+ unresolved.append(inv)
270
+ continue
271
+ if entry.kind == "flow":
272
+ invoked_flows[inv] = entry.data
273
+ elif entry.kind == "atom" and inv not in handler_atoms:
274
+ handler_atoms[inv] = entry.data
275
+
276
+ # L2 entry points that invoke this journey.
277
+ entry_points = []
278
+ for mod in idx.by_kind("module"):
279
+ for ep in (mod.data.get("interface") or {}).get("entry_points") or []:
280
+ if ep.get("invokes") == journey.id:
281
+ entry_points.append({"owner_module": mod.id, **ep})
282
+
283
+ l0_slice = _build_l0_slice(
284
+ idx,
285
+ type_ids=aggregated_type_ids,
286
+ error_codes=aggregated_errors,
287
+ constant_ids=aggregated_constants,
288
+ markers=aggregated_markers,
289
+ external_schemas=aggregated_externals,
290
+ )
291
+
292
+ bundle: OrderedDict[str, Any] = OrderedDict()
293
+ bundle["target"] = {"id": journey.id, "kind": "journey"}
294
+ bundle["l0_registry_slice"] = l0_slice
295
+ bundle["l1_conventions"] = idx.l1
296
+ bundle["l2_entry_points"] = entry_points
297
+ bundle["l4_journey"] = journey.data
298
+ bundle["handler_atoms"] = handler_atoms
299
+ bundle["invoked_orchestrations"] = invoked_flows
300
+ bundle["l5_operations"] = idx.l5
301
+ return bundle
302
+
303
+
304
+ # ======================================================================
305
+ # Flow walker
306
+ # ======================================================================
307
+
308
+ def expand_flow(idx: Index, flow: Entry, unresolved: list[str]) -> OrderedDict[str, Any]:
309
+ sequence = flow.data.get("sequence") or []
310
+
311
+ step_sigs: OrderedDict[str, Any] = OrderedDict()
312
+ aggregated_type_ids: set[str] = set()
313
+ aggregated_errors: set[str] = set()
314
+ aggregated_markers: set[str] = set()
315
+
316
+ trigger = flow.data.get("trigger") or {}
317
+ if trigger.get("payload_type"):
318
+ aggregated_type_ids.add(trigger["payload_type"])
319
+
320
+ for step in sequence:
321
+ inv = step.get("invoke")
322
+ comp = step.get("compensation")
323
+ for aid in filter(None, [inv, comp]):
324
+ if aid in step_sigs:
325
+ continue
326
+ step_sigs[aid] = _atom_signature(idx, aid, unresolved)
327
+ entry = idx.get(aid)
328
+ if entry and entry.kind == "atom":
329
+ spec = entry.data.get("spec") or {}
330
+ aggregated_type_ids |= _collect_type_ids(spec)
331
+ aggregated_errors |= _collect_error_codes(spec)
332
+ aggregated_markers |= set(spec.get("side_effects") or [])
333
+
334
+ # L2 entry points that invoke this flow.
335
+ entry_points = []
336
+ for mod in idx.by_kind("module"):
337
+ for ep in (mod.data.get("interface") or {}).get("entry_points") or []:
338
+ if ep.get("invokes") == flow.id:
339
+ entry_points.append({"owner_module": mod.id, **ep})
340
+
341
+ l0_slice = _build_l0_slice(
342
+ idx,
343
+ type_ids=aggregated_type_ids,
344
+ error_codes=aggregated_errors,
345
+ constant_ids=set(),
346
+ markers=aggregated_markers,
347
+ external_schemas=set(),
348
+ )
349
+
350
+ bundle: OrderedDict[str, Any] = OrderedDict()
351
+ bundle["target"] = {"id": flow.id, "kind": "flow"}
352
+ bundle["l0_registry_slice"] = l0_slice
353
+ bundle["l1_conventions"] = idx.l1
354
+ bundle["l2_entry_points"] = entry_points
355
+ bundle["l4_orchestration"] = flow.data
356
+ bundle["step_atom_signatures"] = step_sigs
357
+ bundle["l5_operations"] = idx.l5
358
+ return bundle
359
+
360
+
361
+ # ======================================================================
362
+ # Artifact walker
363
+ # ======================================================================
364
+
365
+ def expand_artifact(idx: Index, artifact: Entry, unresolved: list[str]) -> OrderedDict[str, Any]:
366
+ owner_id = artifact.data.get("owner_module")
367
+ owner = idx.get(owner_id) if owner_id else None
368
+
369
+ schema = artifact.data.get("schema")
370
+ type_ids: set[str] = set()
371
+ if isinstance(schema, str) and schema not in _PRIMITIVES:
372
+ type_ids.add(schema)
373
+ elif isinstance(schema, dict):
374
+ type_ids |= _scan_types_in_fields(schema)
375
+
376
+ prod = (artifact.data.get("provenance") or {}).get("produced_by")
377
+ producer_sig = None
378
+ if prod and prod not in ("external", "manual"):
379
+ producer_sig = _atom_signature(idx, prod, unresolved)
380
+
381
+ source_artifacts = OrderedDict()
382
+ for sid in (artifact.data.get("provenance") or {}).get("source_artifacts") or []:
383
+ entry = idx.get(sid)
384
+ if entry:
385
+ source_artifacts[sid] = {
386
+ "description": entry.data.get("description"),
387
+ "format": entry.data.get("format"),
388
+ "schema": entry.data.get("schema"),
389
+ }
390
+ else:
391
+ source_artifacts[sid] = {"status": "UNRESOLVED"}
392
+ unresolved.append(sid)
393
+
394
+ consumers = OrderedDict()
395
+ for cid in artifact.data.get("consumers") or []:
396
+ consumers[cid] = _atom_signature(idx, cid, unresolved)
397
+
398
+ l0_slice = _build_l0_slice(
399
+ idx,
400
+ type_ids=type_ids,
401
+ error_codes=set(),
402
+ constant_ids=set(),
403
+ markers=set(),
404
+ external_schemas=set(),
405
+ )
406
+
407
+ bundle: OrderedDict[str, Any] = OrderedDict()
408
+ bundle["target"] = {"id": artifact.id, "kind": "artifact"}
409
+ bundle["l0_registry_slice"] = l0_slice
410
+ bundle["l1_conventions"] = idx.l1
411
+ bundle["l2_module"] = owner.data if owner else None
412
+ if owner is None and owner_id:
413
+ unresolved.append(owner_id)
414
+ bundle["l3_artifact"] = artifact.data
415
+ bundle["producer_atom_signature"] = producer_sig
416
+ bundle["source_artifacts"] = source_artifacts
417
+ bundle["consumer_signatures"] = consumers
418
+ return bundle
419
+
420
+
421
+ # ======================================================================
422
+ # Shared helpers
423
+ # ======================================================================
424
+
425
+ def _atom_signature(idx: Index, atom_id: str, unresolved: list[str]) -> dict[str, Any]:
426
+ entry = idx.get(atom_id)
427
+ if entry is None or entry.kind != "atom":
428
+ unresolved.append(atom_id)
429
+ return {"status": "UNRESOLVED"}
430
+ spec = entry.data.get("spec") or {}
431
+ return {
432
+ "kind": entry.data.get("kind"),
433
+ "description": entry.data.get("description"),
434
+ "input": spec.get("input"),
435
+ "output": spec.get("output"),
436
+ "side_effects": spec.get("side_effects"),
437
+ }
438
+
439
+
440
+ def _resolve(idx: Index, entity_id: str, expected_kind: str, unresolved: list[str]) -> Any:
441
+ entry = idx.get(entity_id)
442
+ if entry is None or entry.kind != expected_kind:
443
+ unresolved.append(entity_id)
444
+ return {"status": "UNRESOLVED"}
445
+ return entry.data
446
+
447
+
448
+ # ---------- collection primitives ----------
449
+
450
+ def _scan_types_in_fields(obj: Any, acc: set[str] | None = None) -> set[str]:
451
+ """Recursively pull type_id strings out of inline field maps."""
452
+ if acc is None:
453
+ acc = set()
454
+ if isinstance(obj, dict):
455
+ t = obj.get("type")
456
+ if isinstance(t, str) and t not in _PRIMITIVES:
457
+ acc.add(t)
458
+ for v in obj.values():
459
+ _scan_types_in_fields(v, acc)
460
+ elif isinstance(obj, list):
461
+ for v in obj:
462
+ _scan_types_in_fields(v, acc)
463
+ return acc
464
+
465
+
466
+ def _collect_type_ids(spec: dict[str, Any]) -> set[str]:
467
+ out: set[str] = set()
468
+ # PROCEDURAL: input + output.success as inline fields OR type id.
469
+ inp = spec.get("input")
470
+ if isinstance(inp, str) and inp not in _PRIMITIVES:
471
+ out.add(inp)
472
+ elif isinstance(inp, dict):
473
+ out |= _scan_types_in_fields(inp)
474
+
475
+ output = spec.get("output") or {}
476
+ succ = output.get("success")
477
+ if isinstance(succ, str) and succ not in _PRIMITIVES:
478
+ out.add(succ)
479
+ elif isinstance(succ, dict):
480
+ out |= _scan_types_in_fields(succ)
481
+
482
+ # COMPONENT: props, local_state, events_emitted.payload_type.
483
+ for key in ("props", "local_state"):
484
+ v = spec.get(key)
485
+ if isinstance(v, dict):
486
+ out |= _scan_types_in_fields(v)
487
+ for evt in spec.get("events_emitted") or []:
488
+ pt = evt.get("payload_type") if isinstance(evt, dict) else None
489
+ if isinstance(pt, str) and pt not in _PRIMITIVES:
490
+ out.add(pt)
491
+
492
+ # MODEL: input_distribution, output_distribution.
493
+ for key in ("input_distribution", "output_distribution"):
494
+ v = spec.get(key)
495
+ if isinstance(v, dict):
496
+ out |= _scan_types_in_fields(v)
497
+
498
+ # DECLARATIVE: desired_state may reference types.
499
+ ds = spec.get("desired_state")
500
+ if isinstance(ds, (dict, list)):
501
+ out |= _scan_types_in_fields(ds)
502
+
503
+ return out
504
+
505
+
506
+ def _collect_error_codes(spec: dict[str, Any]) -> set[str]:
507
+ out: set[str] = set()
508
+ for code in (spec.get("output") or {}).get("errors") or []:
509
+ if isinstance(code, str):
510
+ out.add(code)
511
+ for fm in spec.get("failure_modes") or []:
512
+ e = fm.get("error") if isinstance(fm, dict) else None
513
+ if isinstance(e, str):
514
+ out.add(e)
515
+ # Error codes can also appear inside logic strings (RETURN PAY.VAL.001).
516
+ for match in _ID_TOKEN.finditer(_logic_text(spec)):
517
+ if match.group(4):
518
+ out.add(match.group(4))
519
+ return out
520
+
521
+
522
+ def _collect_called_atoms(spec: dict[str, Any]) -> set[str]:
523
+ out: set[str] = set()
524
+ for match in _ID_TOKEN.finditer(_logic_text(spec)):
525
+ if match.group(3):
526
+ out.add(match.group(3))
527
+ for aid in spec.get("composes") or []:
528
+ if isinstance(aid, str):
529
+ out.add(aid)
530
+ return out
531
+
532
+
533
+ def _collect_constants_from_text(text: str) -> set[str]:
534
+ out: set[str] = set()
535
+ for match in _ID_TOKEN.finditer(text):
536
+ if match.group(1):
537
+ out.add(match.group(1))
538
+ return out
539
+
540
+
541
+ def _collect_external_schemas(text: str) -> set[str]:
542
+ out: set[str] = set()
543
+ for match in _ID_TOKEN.finditer(text):
544
+ if match.group(2):
545
+ out.add(match.group(2))
546
+ return out
547
+
548
+
549
+ def _logic_text(spec: dict[str, Any]) -> str:
550
+ """Stringify spec regions that may contain id tokens (logic, invariants)."""
551
+ parts: list[str] = []
552
+ for key in ("logic", "render_contract"):
553
+ for line in spec.get(key) or []:
554
+ parts.append(_stringify(line))
555
+ invs = spec.get("invariants")
556
+ if isinstance(invs, list):
557
+ for line in invs:
558
+ parts.append(_stringify(line))
559
+ elif isinstance(invs, dict):
560
+ for v in invs.values():
561
+ for line in v or []:
562
+ parts.append(_stringify(line))
563
+ for fm in spec.get("failure_modes") or []:
564
+ if isinstance(fm, dict):
565
+ parts.append(_stringify(fm.get("trigger", "")))
566
+ return "\n".join(parts)
567
+
568
+
569
+ def _stringify(x: Any) -> str:
570
+ if isinstance(x, str):
571
+ return x
572
+ if isinstance(x, dict):
573
+ return " ".join(f"{k}: {_stringify(v)}" for k, v in x.items())
574
+ if isinstance(x, list):
575
+ return " ".join(_stringify(v) for v in x)
576
+ return str(x)
577
+
578
+
579
+ # ---------- L0 slice builder ----------
580
+
581
+ def _build_l0_slice(
582
+ idx: Index,
583
+ *,
584
+ type_ids: set[str],
585
+ error_codes: set[str],
586
+ constant_ids: set[str],
587
+ markers: set[str],
588
+ external_schemas: set[str],
589
+ ) -> OrderedDict[str, Any]:
590
+ l0 = idx.l0
591
+ # Transitive: referenced types may reference other types via fields.
592
+ resolved_types: dict[str, Any] = {}
593
+ frontier = set(type_ids)
594
+ while frontier:
595
+ tid = frontier.pop()
596
+ if tid in resolved_types or tid in _PRIMITIVES:
597
+ continue
598
+ tdef = (l0.get("types") or {}).get(tid)
599
+ if tdef is None:
600
+ continue
601
+ resolved_types[tid] = tdef
602
+ # Pull any nested type refs out of this type's fields.
603
+ frontier |= _scan_types_in_fields(tdef.get("fields") or {}) - resolved_types.keys()
604
+
605
+ # Collect error categories referenced.
606
+ referenced_categories: set[str] = set()
607
+ resolved_errors: dict[str, Any] = {}
608
+ for code in sorted(error_codes):
609
+ err = (l0.get("errors") or {}).get(code)
610
+ if err:
611
+ resolved_errors[code] = err
612
+ if err.get("category"):
613
+ referenced_categories.add(err["category"])
614
+
615
+ resolved_constants: dict[str, Any] = {}
616
+ for cid in sorted(constant_ids):
617
+ c = (l0.get("constants") or {}).get(cid)
618
+ if c:
619
+ resolved_constants[cid] = c
620
+
621
+ resolved_markers: dict[str, str] = {}
622
+ for m in sorted(markers):
623
+ desc = (l0.get("side_effect_markers") or {}).get(m)
624
+ if desc is not None:
625
+ resolved_markers[m] = desc
626
+
627
+ resolved_externals: dict[str, Any] = {}
628
+ for sid in sorted(external_schemas):
629
+ ext = (l0.get("external_schemas") or {}).get(sid)
630
+ if ext:
631
+ resolved_externals[sid] = ext
632
+
633
+ resolved_categories: dict[str, str] = {}
634
+ for cat in sorted(referenced_categories):
635
+ desc = (l0.get("error_categories") or {}).get(cat)
636
+ if desc is not None:
637
+ resolved_categories[cat] = desc
638
+
639
+ out: OrderedDict[str, Any] = OrderedDict()
640
+ out["naming_ledger"] = l0.get("naming_ledger") or {}
641
+ if resolved_categories:
642
+ out["error_categories"] = resolved_categories
643
+ if resolved_errors:
644
+ out["errors"] = resolved_errors
645
+ if resolved_types:
646
+ out["types"] = resolved_types
647
+ if resolved_constants:
648
+ out["constants"] = resolved_constants
649
+ if resolved_markers:
650
+ out["side_effect_markers"] = resolved_markers
651
+ if resolved_externals:
652
+ out["external_schemas"] = resolved_externals
653
+ return out
654
+
655
+
656
+ # ---------- policy predicate evaluator ----------
657
+
658
+ _POLICY_PATTERNS = re.compile(r'atom\.id\s+matches\s+"([^"]+)"')
659
+ _POLICY_SE = re.compile(r'atom\.side_effects\s+contains\s+([A-Z_]+)')
660
+
661
+
662
+ def _filter_policies_for_atom(
663
+ idx: Index,
664
+ atom: Entry,
665
+ owner: Entry | None,
666
+ ) -> OrderedDict[str, Any]:
667
+ """Return applied policies keyed by policy id.
668
+
669
+ Only evaluates policies listed on the owner module. Supports a narrow
670
+ subset of predicate forms:
671
+ - `atom.id matches "<pattern>"` with `*` as wildcard
672
+ - `atom.side_effects contains <MARKER>`
673
+ - conjunctions separated by ` and `
674
+ """
675
+ applied: OrderedDict[str, Any] = OrderedDict()
676
+ if owner is None:
677
+ return applied
678
+
679
+ policy_ids = owner.data.get("policies") or []
680
+ atom_id = atom.id
681
+ markers = set((atom.data.get("spec") or {}).get("side_effects") or [])
682
+
683
+ for pid in policy_ids:
684
+ entry = idx.get(pid)
685
+ if entry is None:
686
+ continue
687
+ predicate = entry.data.get("applies_when") or ""
688
+ if _eval_predicate(predicate, atom_id, markers):
689
+ applied[pid] = entry.data
690
+ return applied
691
+
692
+
693
+ def _eval_predicate(predicate: str, atom_id: str, markers: set[str]) -> bool:
694
+ if not predicate.strip():
695
+ return True
696
+
697
+ ok = True
698
+ for clause in [c.strip() for c in predicate.split(" and ")]:
699
+ m = _POLICY_PATTERNS.search(clause)
700
+ if m:
701
+ pattern = m.group(1).replace(".", r"\.").replace("*", ".*")
702
+ if not re.fullmatch(pattern, atom_id):
703
+ ok = False
704
+ continue
705
+ m = _POLICY_SE.search(clause)
706
+ if m:
707
+ if m.group(1) not in markers:
708
+ ok = False
709
+ continue
710
+ # Unknown predicate form — conservative: skip application.
711
+ ok = False
712
+ return ok
713
+
714
+
715
+ # ---------- L4 caller scanner (for atoms) ----------
716
+
717
+ def _find_atom_callers(idx: Index, atom_id: str) -> OrderedDict[str, Any]:
718
+ callers: OrderedDict[str, Any] = OrderedDict()
719
+
720
+ # Orchestrations that invoke or compensate-with this atom.
721
+ flows_ctx = []
722
+ for flow in idx.by_kind("flow"):
723
+ matches = []
724
+ for step in flow.data.get("sequence") or []:
725
+ role = None
726
+ if step.get("invoke") == atom_id:
727
+ role = "invoke"
728
+ elif step.get("compensation") == atom_id:
729
+ role = "compensation"
730
+ if role:
731
+ matches.append({
732
+ "role": role,
733
+ "step": step.get("step"),
734
+ "with": step.get("with"),
735
+ "on_error": step.get("on_error"),
736
+ "compensation": step.get("compensation"),
737
+ })
738
+ if matches:
739
+ flows_ctx.append({
740
+ "flow_id": flow.id,
741
+ "transaction_boundary": flow.data.get("transaction_boundary"),
742
+ "trigger": flow.data.get("trigger"),
743
+ "matches": matches,
744
+ "implications": _derive_flow_implications(flow.data, matches),
745
+ })
746
+ if flows_ctx:
747
+ callers["orchestrations"] = flows_ctx
748
+
749
+ # Journeys that use this atom as a handler or transition invoke.
750
+ journeys_ctx = []
751
+ for j in idx.by_kind("journey"):
752
+ states_using = [s for s, h in (j.data.get("handlers") or {}).items()
753
+ if isinstance(h, dict) and h.get("atom") == atom_id]
754
+ trans_using = [t for t in j.data.get("transitions") or []
755
+ if isinstance(t, dict) and t.get("invoke") == atom_id]
756
+ if states_using or trans_using:
757
+ journeys_ctx.append({
758
+ "journey_id": j.id,
759
+ "surface": j.data.get("surface"),
760
+ "states": states_using,
761
+ "transitions": trans_using,
762
+ })
763
+ if journeys_ctx:
764
+ callers["journeys"] = journeys_ctx
765
+
766
+ return callers
767
+
768
+
769
+ def _derive_flow_implications(flow: dict[str, Any], matches: list[dict[str, Any]]) -> list[str]:
770
+ """Plain-English notes the implementing agent should honor."""
771
+ out: list[str] = []
772
+ tb = flow.get("transaction_boundary")
773
+ for m in matches:
774
+ if m["role"] == "invoke":
775
+ oe = m.get("on_error") or {}
776
+ for code, action in oe.items():
777
+ action_str = str(action)
778
+ if action_str.startswith("RETRY"):
779
+ out.append(
780
+ f"{code} → {action_str}: atom must be retry-safe "
781
+ f"(idempotency-key path required)."
782
+ )
783
+ if "COMPENSATE" in action_str:
784
+ out.append(
785
+ f"{code} → {action_str}: atom's side effects must be "
786
+ f"reversible by its compensation, or not yet persisted "
787
+ f"when this error is returned."
788
+ )
789
+ if tb == "saga" and m.get("compensation"):
790
+ out.append(
791
+ f"Saga boundary with compensation={m['compensation']}: "
792
+ f"any mutations this atom makes must be reversible by that atom."
793
+ )
794
+ elif m["role"] == "compensation":
795
+ out.append(
796
+ f"Used as compensation for step '{m['step']}': must reverse the "
797
+ f"effects of that step's invoke atom."
798
+ )
799
+ return out