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.
- ai_forge_cli-0.1.2.dist-info/METADATA +8 -0
- ai_forge_cli-0.1.2.dist-info/RECORD +21 -0
- ai_forge_cli-0.1.2.dist-info/WHEEL +5 -0
- ai_forge_cli-0.1.2.dist-info/entry_points.txt +2 -0
- ai_forge_cli-0.1.2.dist-info/licenses/LICENSE +21 -0
- ai_forge_cli-0.1.2.dist-info/top_level.txt +1 -0
- cli/__init__.py +2 -0
- cli/__main__.py +4 -0
- cli/bundle.py +117 -0
- cli/commands/__init__.py +28 -0
- cli/commands/base.py +26 -0
- cli/commands/context.py +66 -0
- cli/commands/find.py +122 -0
- cli/commands/init.py +447 -0
- cli/commands/inspect.py +111 -0
- cli/commands/list_cmd.py +72 -0
- cli/commands/update.py +78 -0
- cli/common.py +120 -0
- cli/forge.py +65 -0
- cli/index.py +156 -0
- cli/walker.py +799 -0
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
|