clm-kernel 0.0.0__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.
Files changed (333) hide show
  1. clm/__init__.py +230 -0
  2. clm/__main__.py +25 -0
  3. clm/cross_cutting/__init__.py +50 -0
  4. clm/cross_cutting/_test_runner.py +36 -0
  5. clm/cross_cutting/async_utils.py +25 -0
  6. clm/cross_cutting/cel_builder.py +123 -0
  7. clm/cross_cutting/cel_converter.py +697 -0
  8. clm/cross_cutting/cel_engine.py +252 -0
  9. clm/cross_cutting/cel_ops.py +72 -0
  10. clm/cross_cutting/cel_ops_db.py +157 -0
  11. clm/cross_cutting/cel_ops_files.py +99 -0
  12. clm/cross_cutting/cel_ops_state.py +71 -0
  13. clm/cross_cutting/cel_ops_strings.py +64 -0
  14. clm/cross_cutting/cel_resolver.py +63 -0
  15. clm/cross_cutting/cel_slices.py +56 -0
  16. clm/cross_cutting/cel_store.py +330 -0
  17. clm/cross_cutting/clm_logger.py +140 -0
  18. clm/cross_cutting/config/__init__.py +155 -0
  19. clm/cross_cutting/config/config_constants.py +392 -0
  20. clm/cross_cutting/config/env_parameters.py +90 -0
  21. clm/cross_cutting/config/logging.py +378 -0
  22. clm/cross_cutting/config/settings.py +504 -0
  23. clm/cross_cutting/context.py +5 -0
  24. clm/cross_cutting/domain_models.py +55 -0
  25. clm/cross_cutting/domain_types.py +61 -0
  26. clm/cross_cutting/effects.py +67 -0
  27. clm/cross_cutting/errors.py +153 -0
  28. clm/cross_cutting/governance.py +331 -0
  29. clm/cross_cutting/guards.py +41 -0
  30. clm/cross_cutting/mcard.py +321 -0
  31. clm/cross_cutting/native.py +185 -0
  32. clm/cross_cutting/observability.py +289 -0
  33. clm/cross_cutting/pcard.py +434 -0
  34. clm/cross_cutting/registry.py +71 -0
  35. clm/cross_cutting/resources.py +84 -0
  36. clm/cross_cutting/secrets.py +44 -0
  37. clm/cross_cutting/subprocess_utils.py +118 -0
  38. clm/cross_cutting/telemetry.py +164 -0
  39. clm/cross_cutting/testing/__init__.py +8 -0
  40. clm/cross_cutting/testing/comparator.py +187 -0
  41. clm/cross_cutting/testing/datasets.py +87 -0
  42. clm/cross_cutting/timeutil.py +20 -0
  43. clm/cross_cutting/types/new_type_check.py +0 -0
  44. clm/cross_cutting/utils/__init__.py +1 -0
  45. clm/cross_cutting/utils/url_safety.py +50 -0
  46. clm/cross_cutting/vcard.py +560 -0
  47. clm/cross_cutting/writer.py +118 -0
  48. clm/layer0/README.md +50 -0
  49. clm/layer0/__init__.py +223 -0
  50. clm/layer0/algebra.py +60 -0
  51. clm/layer0/algebra_baldwin.py +133 -0
  52. clm/layer0/algebra_baldwin_generators.py +36 -0
  53. clm/layer0/algebra_equivalence.py +262 -0
  54. clm/layer0/algebra_ingest.py +306 -0
  55. clm/layer0/algebra_manifest_io.py +156 -0
  56. clm/layer0/assembler.py +481 -0
  57. clm/layer0/cel/__init__.py +23 -0
  58. clm/layer0/cel_eval.py +491 -0
  59. clm/layer0/codec.py +106 -0
  60. clm/layer0/context.py +122 -0
  61. clm/layer0/db.py +160 -0
  62. clm/layer0/exceptions.py +73 -0
  63. clm/layer0/fibration.py +106 -0
  64. clm/layer0/gates.py +50 -0
  65. clm/layer0/hash.py +80 -0
  66. clm/layer0/loader.py +826 -0
  67. clm/layer0/math_utils.py +163 -0
  68. clm/layer0/mcard.py +96 -0
  69. clm/layer0/model/__init__.py +0 -0
  70. clm/layer0/model/action.py +223 -0
  71. clm/layer0/model/card.py +280 -0
  72. clm/layer0/model/card_triad.py +427 -0
  73. clm/layer0/model/compatibility.py +60 -0
  74. clm/layer0/model/dictionary.py +46 -0
  75. clm/layer0/model/dots.py +331 -0
  76. clm/layer0/model/event_producer.py +88 -0
  77. clm/layer0/model/g_time.py +89 -0
  78. clm/layer0/model/handle.py +163 -0
  79. clm/layer0/model/hash/__init__.py +6 -0
  80. clm/layer0/model/hash/algorithms/__init__.py +3 -0
  81. clm/layer0/model/hash/algorithms/custom_hash.py +7 -0
  82. clm/layer0/model/hash/algorithms/local_sha256.py +178 -0
  83. clm/layer0/model/hash/constants.py +53 -0
  84. clm/layer0/model/hash/enums.py +60 -0
  85. clm/layer0/model/hash/validator.py +307 -0
  86. clm/layer0/model/interpreter.py +275 -0
  87. clm/layer0/model/pagination.py +45 -0
  88. clm/layer0/model/pcard.py +716 -0
  89. clm/layer0/model/schema.py +44 -0
  90. clm/layer0/model/utils/__init__.py +1 -0
  91. clm/layer0/model/utils/content_analyzer.py +152 -0
  92. clm/layer0/model/validators/__init__.py +1 -0
  93. clm/layer0/model/validators/base_validator.py +44 -0
  94. clm/layer0/model/validators/binary_validator.py +66 -0
  95. clm/layer0/model/validators/text_validator.py +82 -0
  96. clm/layer0/model/validators/validation_registry.py +49 -0
  97. clm/layer0/model/vcard.py +823 -0
  98. clm/layer0/model/vcard_ext/__init__.py +39 -0
  99. clm/layer0/model/vcard_ext/core.py +60 -0
  100. clm/layer0/model/vcard_ext/network.py +23 -0
  101. clm/layer0/model/vcard_ext/observability.py +63 -0
  102. clm/layer0/model/vcard_ext/storage.py +60 -0
  103. clm/layer0/model/vcard_ext/vendors.py +211 -0
  104. clm/layer0/model/vcard_sandwich.py +119 -0
  105. clm/layer0/model/vcard_vocabulary.py +463 -0
  106. clm/layer0/model/workflow.py +274 -0
  107. clm/layer0/narrative.py +431 -0
  108. clm/layer0/ontology/collection.py +125 -0
  109. clm/layer0/parser.py +429 -0
  110. clm/layer0/relations.py +73 -0
  111. clm/layer0/rule_evaluator.py +251 -0
  112. clm/layer0/schema/__init__.py +38 -0
  113. clm/layer0/schema/ddl.py +97 -0
  114. clm/layer0/templates/abstract.yaml +99 -0
  115. clm/layer0/templates/balanced.yaml +253 -0
  116. clm/layer0/templates/concrete.yaml +172 -0
  117. clm/layer0/type_registry.py +223 -0
  118. clm/layer0/types.py +204 -0
  119. clm/layer0/utils.py +886 -0
  120. clm/layer0/verifier_core.py +262 -0
  121. clm/layer1/__init__.py +31 -0
  122. clm/layer1/action_dispatcher.py +275 -0
  123. clm/layer1/adapter_registry.py +233 -0
  124. clm/layer1/algebra_baldwin_generators.py +192 -0
  125. clm/layer1/arrow_compiler.py +525 -0
  126. clm/layer1/batch_processor.py +152 -0
  127. clm/layer1/cel/__init__.py +27 -0
  128. clm/layer1/cel/builder.py +173 -0
  129. clm/layer1/engine.py +759 -0
  130. clm/layer1/engine_async.py +89 -0
  131. clm/layer1/eval_callback.py +49 -0
  132. clm/layer1/gate_composer.py +229 -0
  133. clm/layer1/hoare_sandwich.py +298 -0
  134. clm/layer1/identity_guard.py +348 -0
  135. clm/layer1/matrix.py +138 -0
  136. clm/layer1/neural_runtime.py +221 -0
  137. clm/layer1/operations/__init__.py +123 -0
  138. clm/layer1/operations/augment.py +156 -0
  139. clm/layer1/operations/base.py +109 -0
  140. clm/layer1/operations/builtins.py +338 -0
  141. clm/layer1/operations/dispatch.py +144 -0
  142. clm/layer1/operations/exclude.py +107 -0
  143. clm/layer1/operations/handle.py +197 -0
  144. clm/layer1/operations/identity.py +139 -0
  145. clm/layer1/operations/invert.py +66 -0
  146. clm/layer1/operations/loader.py +116 -0
  147. clm/layer1/operations/manipulators/__init__.py +23 -0
  148. clm/layer1/operations/manipulators/document.py +211 -0
  149. clm/layer1/operations/manipulators/media.py +134 -0
  150. clm/layer1/operations/manipulators/structured.py +159 -0
  151. clm/layer1/operations/manipulators/tabular.py +334 -0
  152. clm/layer1/operations/port.py +65 -0
  153. clm/layer1/operations/services.py +529 -0
  154. clm/layer1/operations/split.py +117 -0
  155. clm/layer1/operations/substitute.py +75 -0
  156. clm/layer1/petri.py +1052 -0
  157. clm/layer1/pocketflow_orchestrator.py +408 -0
  158. clm/layer1/pocketflow_scheduler.py +519 -0
  159. clm/layer1/protocols.py +197 -0
  160. clm/layer1/runtime_adapter.py +718 -0
  161. clm/layer1/runtimes/__init__.py +71 -0
  162. clm/layer1/runtimes/_generated_assertions.py +125 -0
  163. clm/layer1/runtimes/_generated_types.py +42 -0
  164. clm/layer1/runtimes/base.py +265 -0
  165. clm/layer1/runtimes/binary.py +101 -0
  166. clm/layer1/runtimes/factory.py +203 -0
  167. clm/layer1/runtimes/javascript.py +321 -0
  168. clm/layer1/runtimes/javascript_runtime.js +33 -0
  169. clm/layer1/runtimes/js_scripts/isomorphism.mjs +22 -0
  170. clm/layer1/runtimes/js_scripts/loader.mjs +52 -0
  171. clm/layer1/runtimes/lambda_calc.py +126 -0
  172. clm/layer1/runtimes/python.py +288 -0
  173. clm/layer1/runtimes/python_runtime.py +41 -0
  174. clm/layer1/runtimes/script.py +114 -0
  175. clm/layer1/sandbox.py +334 -0
  176. clm/layer1/savepoint_guard.py +136 -0
  177. clm/layer1/sparse_net.py +122 -0
  178. clm/layer1/spec_tester.py +165 -0
  179. clm/layer1/store.py +295 -0
  180. clm/layer1/test_runner.py +498 -0
  181. clm/layer1/type_inspector.py +166 -0
  182. clm/layer1/verifier.py +772 -0
  183. clm/layer1/vm.py +91 -0
  184. clm/layer2/__init__.py +78 -0
  185. clm/layer2/card_collection.py +678 -0
  186. clm/layer2/classifier.py +479 -0
  187. clm/layer2/engine/__init__.py +1 -0
  188. clm/layer2/engine/abstract_sql_engine.py +320 -0
  189. clm/layer2/engine/base.py +193 -0
  190. clm/layer2/engine/duckdb_engine.py +576 -0
  191. clm/layer2/engine/sqlite_engine.py +518 -0
  192. clm/layer2/fiber/__init__.py +31 -0
  193. clm/layer2/fiber/context.py +89 -0
  194. clm/layer2/fiber/disposable.py +163 -0
  195. clm/layer2/fiber/fiber.py +125 -0
  196. clm/layer2/fiber/hoare.py +191 -0
  197. clm/layer2/file_io.py +376 -0
  198. clm/layer2/file_registrar.py +77 -0
  199. clm/layer2/ingest.py +202 -0
  200. clm/layer2/lineage.py +322 -0
  201. clm/layer2/mcard_fs.py +262 -0
  202. clm/layer2/merkle.py +11 -0
  203. clm/layer2/mime.py +244 -0
  204. clm/layer2/mime_detector.py +245 -0
  205. clm/layer2/private_collection.py +118 -0
  206. clm/layer2/shared_store.py +11 -0
  207. clm/layer2/storage/__init__.py +190 -0
  208. clm/layer2/storage/db_bitemporal.py +141 -0
  209. clm/layer2/storage/db_driver.py +331 -0
  210. clm/layer2/storage/db_hyperlinks.py +214 -0
  211. clm/layer2/storage/db_pool.py +139 -0
  212. clm/layer2/storage/db_repository.py +229 -0
  213. clm/layer2/storage/savepoint.py +78 -0
  214. clm/layer2/storage/sqlite_wal.py +74 -0
  215. clm/layer2/tridb.py +245 -0
  216. clm/layer2/vcard_receipt.py +71 -0
  217. clm/layer2/vfs.py +17 -0
  218. clm/layer3/__init__.py +141 -0
  219. clm/layer3/agency/__init__.py +19 -0
  220. clm/layer3/agency/config.py +220 -0
  221. clm/layer3/agency/providers/__init__.py +15 -0
  222. clm/layer3/agency/providers/base.py +91 -0
  223. clm/layer3/agency/providers/mlc_llm.py +169 -0
  224. clm/layer3/agency/providers/ollama.py +203 -0
  225. clm/layer3/agency/router.py +71 -0
  226. clm/layer3/agency/runtime.py +524 -0
  227. clm/layer3/gateway.py +353 -0
  228. clm/layer3/open_interpreter_adapter.py +159 -0
  229. clm/layer3/rag/__init__.py +70 -0
  230. clm/layer3/rag/cli.py +228 -0
  231. clm/layer3/rag/config.py +153 -0
  232. clm/layer3/rag/embeddings/__init__.py +16 -0
  233. clm/layer3/rag/embeddings/base.py +79 -0
  234. clm/layer3/rag/embeddings/ollama.py +211 -0
  235. clm/layer3/rag/embeddings/vision.py +290 -0
  236. clm/layer3/rag/engine.py +276 -0
  237. clm/layer3/rag/graph/__init__.py +22 -0
  238. clm/layer3/rag/graph/community.py +198 -0
  239. clm/layer3/rag/graph/engine.py +433 -0
  240. clm/layer3/rag/graph/extractor.py +255 -0
  241. clm/layer3/rag/graph/schema.py +115 -0
  242. clm/layer3/rag/graph/store.py +641 -0
  243. clm/layer3/rag/indexer.py +333 -0
  244. clm/layer3/rag/llm_providers.json +52 -0
  245. clm/layer3/rag/semantic_versioning.py +313 -0
  246. clm/layer3/rag/vector/__init__.py +28 -0
  247. clm/layer3/rag/vector/handle_vector_store.py +749 -0
  248. clm/layer3/rag/vector/schema.py +151 -0
  249. clm/layer3/rag/vector/store.py +632 -0
  250. clm/layer3/reticulum/__init__.py +55 -0
  251. clm/layer3/reticulum/bridge.py +74 -0
  252. clm/layer3/reticulum/daemon.py +88 -0
  253. clm/layer3/reticulum/discovery.py +164 -0
  254. clm/layer3/reticulum/identity.py +107 -0
  255. clm/layer3/reticulum/media.py +148 -0
  256. clm/layer3/reticulum/transport.py +133 -0
  257. clm/layer3/satori/__init__.py +209 -0
  258. clm/layer3/satori/alpha_conversion.py +168 -0
  259. clm/layer3/satori/beta_reduction.py +359 -0
  260. clm/layer3/satori/continuation.py +38 -0
  261. clm/layer3/satori/eta_conversion.py +205 -0
  262. clm/layer3/satori/free_variables.py +163 -0
  263. clm/layer3/satori/io_effects.py +370 -0
  264. clm/layer3/satori/lambda_runtime.py +726 -0
  265. clm/layer3/satori/lambda_term.py +230 -0
  266. clm/layer3/satori/sheaf_audit.py +261 -0
  267. clm/layer3/satori/speech_act.py +279 -0
  268. clm/layer4/README.md +9 -0
  269. clm/layer4/__init__.py +67 -0
  270. clm/layer4/_sovereign_executor.py +580 -0
  271. clm/layer4/action_commands.py +719 -0
  272. clm/layer4/api.py +236 -0
  273. clm/layer4/bootstrap/__init__.py +13 -0
  274. clm/layer4/bootstrap/bootstrapper.py +118 -0
  275. clm/layer4/bootstrap/cleanup.py +44 -0
  276. clm/layer4/bootstrap/clm_hooks.py +49 -0
  277. clm/layer4/bootstrap/genesis.py +77 -0
  278. clm/layer4/bootstrap/injection.py +125 -0
  279. clm/layer4/cli.py +549 -0
  280. clm/layer4/collection_manager.py +553 -0
  281. clm/layer4/commands/__init__.py +81 -0
  282. clm/layer4/commands/action_cmd.py +42 -0
  283. clm/layer4/commands/check_cmd.py +82 -0
  284. clm/layer4/commands/envelope.py +73 -0
  285. clm/layer4/commands/evaluate_cmd.py +224 -0
  286. clm/layer4/commands/mcard_cmd.py +24 -0
  287. clm/layer4/commands/petri_cmd.py +92 -0
  288. clm/layer4/commands/tridb_cmd.py +319 -0
  289. clm/layer4/commands/version_cmd.py +27 -0
  290. clm/layer4/diff_commands.py +167 -0
  291. clm/layer4/eoa_sponsor.py +232 -0
  292. clm/layer4/events.py +69 -0
  293. clm/layer4/flux_gateway.py +133 -0
  294. clm/layer4/gateway.py +91 -0
  295. clm/layer4/loader.py +307 -0
  296. clm/layer4/mcard_commands.py +166 -0
  297. clm/layer4/membrane.py +273 -0
  298. clm/layer4/navigation.py +50 -0
  299. clm/layer4/protocol/protocol_entry_python.py +108 -0
  300. clm/layer4/protocol/python_sync.py +126 -0
  301. clm/layer4/runner.py +150 -0
  302. clm/layer4/satori.py +262 -0
  303. clm/layer4/server/python_sync_client.py +150 -0
  304. clm/layer4/server/python_sync_server.py +139 -0
  305. clm/layer4/server/service_api.py +315 -0
  306. clm/layer4/server/start_servers.py +107 -0
  307. clm/layer4/server/stop_servers.py +24 -0
  308. clm/layer4/simulated_optimization_fix.py +0 -0
  309. clm/layer4/storage.py +463 -0
  310. clm/layer4/tridb_admin.py +268 -0
  311. clm/layer4/websocket/test_ws_client.py +104 -0
  312. clm/layer4/websocket/test_ws_server.py +138 -0
  313. clm/layer4/websocket/ws_server_local.py +160 -0
  314. clm/layer5/__init__.py +51 -0
  315. clm/layer5/async_batch_writer.py +133 -0
  316. clm/layer5/learning.py +276 -0
  317. clm/layer5/telemetry_hook.py +107 -0
  318. clm/layer5/type_lattice.py +238 -0
  319. clm/py.typed +0 -0
  320. clm/schemas/__init__.py +652 -0
  321. clm/schemas/abstract_face_schema.yaml +77 -0
  322. clm/schemas/cel_schema.yaml +416 -0
  323. clm/schemas/lifecycle/__init__.py +45 -0
  324. clm/schemas/lifecycle/models.py +123 -0
  325. clm/schemas/lifecycle/service.py +364 -0
  326. clm/schemas/mcard_schema.sql +192 -0
  327. clm/schemas/mcard_vector_schema.sql +283 -0
  328. clm/schemas/mlp-context-v1.json +91 -0
  329. clm_kernel-0.0.0.dist-info/METADATA +121 -0
  330. clm_kernel-0.0.0.dist-info/RECORD +333 -0
  331. clm_kernel-0.0.0.dist-info/WHEEL +4 -0
  332. clm_kernel-0.0.0.dist-info/entry_points.txt +3 -0
  333. clm_kernel-0.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,697 @@
1
+ #!/usr/bin/env python3
2
+ """CLM CEL Converter — AST-based Python→CEL conversion and schema validation.
3
+
4
+ Promoted from the root-level ``convert_actual_to_cel_v2.py`` (Sprint 120).
5
+ This module is the canonical, importable home for the conversion pipeline.
6
+
7
+ Public API:
8
+ parse_expr_to_cel(expr_str) — convert a Python expression string to a CEL dict
9
+ convert_yaml_file(path, ...) — convert all ``actual`` fields in a YAML file
10
+ load_schema(path) — load cel_schema.yaml into a dict of op definitions
11
+ validate_cel(cel, ...) — validate a CEL dict against the schema
12
+ validate_yaml_file(path, ...) — validate all CEL ``actual`` fields in a YAML file
13
+
14
+ CLI:
15
+ python -m clm.tools.cel_converter <file.yaml> [--dry-run] [--backup] [--validate]
16
+ """
17
+
18
+ import ast
19
+ import os
20
+ import sys
21
+ from typing import Any
22
+
23
+ from clm.cross_cutting.clm_logger import get_logger
24
+
25
+ _logger = get_logger("clm.tools.cel_converter")
26
+
27
+
28
+ def _ast_to_cel(node):
29
+ """Convert an AST node to a CEL dict. Returns None if not convertible."""
30
+ if node is None:
31
+ return None
32
+
33
+ # --- String literal ---
34
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
35
+ return node.value
36
+
37
+ # --- Numeric literal ---
38
+ if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
39
+ return node.value
40
+
41
+ # --- List literal ---
42
+ if isinstance(node, ast.List):
43
+ return [_ast_to_cel(elt) for elt in node.elts]
44
+
45
+ # --- Boolean literal ---
46
+ if isinstance(node, ast.Constant) and isinstance(node.value, bool):
47
+ return node.value
48
+
49
+ # --- Tuple literal ---
50
+ if isinstance(node, ast.Tuple):
51
+ return tuple(_ast_to_cel(elt) for elt in node.elts)
52
+
53
+ # --- Dict literal ---
54
+ if isinstance(node, ast.Dict):
55
+ return {_ast_to_cel(k): _ast_to_cel(v) for k, v in zip(node.keys, node.values)}
56
+
57
+ # --- Name (identifier) ---
58
+ if isinstance(node, ast.Name):
59
+ name = node.id
60
+ if name == 'True':
61
+ return True
62
+ if name == 'False':
63
+ return False
64
+ if name == 'None':
65
+ return None
66
+ return {"op": "value_ref", "name": name}
67
+
68
+ # --- Subscript: inputs['key'], outputs['key'], retrieve('key')[N], call()[N]['key'] ---
69
+ if isinstance(node, ast.Subscript):
70
+ if isinstance(node.value, ast.Name) and node.value.id == 'inputs':
71
+ key = _ast_to_cel(node.slice)
72
+ if isinstance(key, str):
73
+ return {"ref": f"inputs.{key}"}
74
+ if isinstance(node.value, ast.Name) and node.value.id == 'outputs':
75
+ key = _ast_to_cel(node.slice)
76
+ if isinstance(key, str):
77
+ return {"ref": f"outputs.{key}"}
78
+ # General subscript: convert inner then apply index/dict_get
79
+ base = _ast_to_cel(node.value)
80
+ idx = _ast_to_cel(node.slice)
81
+ if isinstance(idx, int):
82
+ return {"op": "index", "value": base, "position": idx}
83
+ if isinstance(idx, str):
84
+ return {"op": "dict_get", "value": base, "key": idx}
85
+
86
+ # --- Attribute access: repo.method, json.loads, os.path.join ---
87
+ if isinstance(node, ast.Attribute):
88
+ if isinstance(node.value, ast.Name):
89
+ # os.path.join -> path_join
90
+ if node.value.id == 'os' and node.attr == 'path':
91
+ return {"_partial": "os.path"}
92
+ # json.loads -> json_parse
93
+ if node.value.id == 'json' and node.attr == 'loads':
94
+ return {"_partial": "json.loads"}
95
+ # time.sleep -> sleep
96
+ if node.value.id == 'time' and node.attr == 'sleep':
97
+ return {"_partial": "time.sleep"}
98
+ # os.environ -> env
99
+ if node.value.id == 'os' and node.attr == 'environ':
100
+ return {"_partial": "os.environ"}
101
+ # shutil.copy -> copy_file
102
+ if node.value.id == 'shutil' and node.attr == 'copy':
103
+ return {"_partial": "shutil.copy"}
104
+ return {"_partial": f"{node.value.id}.{node.attr}"}
105
+ if isinstance(node.value, ast.Attribute):
106
+ # os.path.join -> path_join
107
+ if node.value.value.id == 'os' and node.value.attr == 'path' and node.attr == 'join':
108
+ return {"_partial": "path_join"}
109
+ # os.path.basename -> path_basename (Sprint 126.1)
110
+ if node.value.value.id == 'os' and node.value.attr == 'path' and node.attr == 'basename':
111
+ return {"_partial": "path_basename"}
112
+ # os.environ.pop -> unset_env
113
+ if node.value.value.id == 'os' and node.value.attr == 'environ' and node.attr == 'pop':
114
+ return {"_partial": "unset_env"}
115
+ # os.environ.__setitem__ -> set_env
116
+ if node.value.value.id == 'os' and node.value.attr == 'environ' and node.attr == '__setitem__':
117
+ return {"_partial": "set_env"}
118
+ return {"_partial": f"{_attr_chain(node.value)}.{node.attr}"}
119
+
120
+ # --- Handle chained .get() calls on dicts ---
121
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == 'get':
122
+ val = _ast_to_cel(node.func.value)
123
+ if val is not None:
124
+ key = _ast_to_cel(node.args[0])
125
+ default = _ast_to_cel(node.args[1]) if len(node.args) > 1 else None
126
+ cel = {"op": "dict_get", "value": val, "key": key}
127
+ if default is not None:
128
+ cel["default"] = default
129
+ return cel
130
+
131
+ # --- Handle chained .replace() calls ---
132
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == 'replace':
133
+ val = _ast_to_cel(node.func.value)
134
+ if val is not None:
135
+ old = _ast_to_cel(node.args[0])
136
+ new = _ast_to_cel(node.args[1])
137
+ return {"op": "replace", "value": val, "old": old, "new": new}
138
+
139
+ # --- Handle str.join: "sep".join(parts) -> string_join (Sprint 126.1) ---
140
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == 'join':
141
+ sep = _ast_to_cel(node.func.value)
142
+ if sep is not None and len(node.args) == 1:
143
+ parts = _ast_to_cel(node.args[0])
144
+ return {"op": "string_join", "separator": sep, "parts": parts}
145
+
146
+ # --- Handle `open(path).read()` ---
147
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == 'read':
148
+ inner = node.func.value
149
+ if isinstance(inner, ast.Call) and isinstance(inner.func, ast.Name) and inner.func.id == 'open':
150
+ path = _ast_to_cel(inner.args[0])
151
+ return {"op": "read_file", "path": path}
152
+
153
+ # --- Handle db.execute(...) patterns (consolidated in Sprint 127.3) ---
154
+ cel = _convert_db_execute_pattern(node)
155
+ if cel is not None:
156
+ return cel
157
+
158
+ # --- Call ---
159
+ if isinstance(node, ast.Call):
160
+ return _ast_call_to_cel(node)
161
+
162
+ # --- BoolOp: and/or ---
163
+ if isinstance(node, ast.BoolOp):
164
+ return _ast_boolop_to_cel(node)
165
+
166
+ # --- Compare: ==, !=, >=, <=, >, <, in ---
167
+ if isinstance(node, ast.Compare):
168
+ return _ast_compare_to_cel(node)
169
+
170
+ # --- BinOp: +, -, *, /, //, % ---
171
+ if isinstance(node, ast.BinOp):
172
+ return _ast_binop_to_cel(node)
173
+
174
+ # --- UnaryOp: not, +, - ---
175
+ if isinstance(node, ast.UnaryOp):
176
+ if isinstance(node.op, ast.Not):
177
+ return {"op": "compare", "left": _ast_to_cel(node.operand), "operator": "eq", "right": False}
178
+ return None
179
+
180
+ return None
181
+
182
+
183
+ def _make_db_query_cel(execute_node: ast.Call) -> dict | None:
184
+ """Build a db_query CEL dict from a db.execute(...) call node."""
185
+ db_obj = execute_node.func.value
186
+ if not (isinstance(db_obj, ast.Name) and db_obj.id == 'db'):
187
+ return None
188
+ args = [_ast_to_cel(a) for a in execute_node.args]
189
+ cel = {"op": "db_query", "pillar": args[0], "sql": args[1]}
190
+ if len(args) > 2:
191
+ cel["params"] = args[2]
192
+ return cel
193
+
194
+
195
+ def _convert_db_execute_pattern(node: ast.AST) -> dict | None:
196
+ """Convert any db.execute(...) call pattern to a db_query CEL dict.
197
+
198
+ Handles three patterns that were previously 5 separate code blocks:
199
+ - ``db.execute(pillar, sql)`` -> ``db_query``
200
+ - ``db.execute(pillar, sql).strip()`` -> ``db_query``
201
+ - ``int(db.execute(pillar, sql).strip())`` -> ``int_cast(db_query)``
202
+ """
203
+ # Pattern: int(db.execute(...).strip())
204
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == 'int':
205
+ arg = node.args[0] if node.args else None
206
+ if isinstance(arg, ast.Call) and isinstance(arg.func, ast.Attribute) and arg.func.attr == 'strip':
207
+ inner = arg.func.value
208
+ if isinstance(inner, ast.Call) and isinstance(inner.func, ast.Attribute) and inner.func.attr == 'execute':
209
+ cel = _make_db_query_cel(inner)
210
+ if cel is not None:
211
+ return {"op": "int_cast", "value": cel}
212
+ return None
213
+
214
+ # Pattern: db.execute(...).strip()
215
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == 'strip':
216
+ inner = node.func.value
217
+ if isinstance(inner, ast.Call) and isinstance(inner.func, ast.Attribute) and inner.func.attr == 'execute':
218
+ return _make_db_query_cel(inner)
219
+ return None
220
+
221
+ # Pattern: db.execute(...)
222
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == 'execute':
223
+ return _make_db_query_cel(node)
224
+
225
+ return None
226
+
227
+
228
+ def _attr_chain(node):
229
+ """Convert chained attribute access to dotted string."""
230
+ if isinstance(node, ast.Name):
231
+ return node.id
232
+ if isinstance(node, ast.Attribute):
233
+ return f"{_attr_chain(node.value)}.{node.attr}"
234
+ return ""
235
+
236
+
237
+ def _partial_db_execute(args: list) -> dict:
238
+ """Convert db.execute partial call to db_query CEL dict."""
239
+ cel = {"op": "db_query", "pillar": args[0], "sql": args[1]}
240
+ if len(args) > 2:
241
+ cel["params"] = args[2]
242
+ return cel
243
+
244
+
245
+ # Dispatch table for partial (attribute-chain) function calls (Sprint 127.3)
246
+ _PARTIAL_CONVERTERS: dict[str, Any] = {
247
+ "json.loads": lambda args: {"op": "json_parse", "value": args[0]},
248
+ "time.sleep": lambda args: {"op": "sleep", "seconds": args[0]},
249
+ "path_join": lambda args: {"op": "path_join", "parts": args},
250
+ "path_basename": lambda args: {"op": "path_basename", "value": args[0]},
251
+ "set_env": lambda args: {"op": "set_env", "var": args[0], "value": args[1]},
252
+ "unset_env": lambda args: {"op": "unset_env", "var": args[0]},
253
+ "shutil.copy": lambda args: {"op": "copy_file", "src": args[0], "dest": args[1]},
254
+ "db.execute": _partial_db_execute,
255
+ }
256
+
257
+ # Dispatch table for simple Name function calls (Sprint 127.3)
258
+ _NAME_CALL_CONVERTERS: dict[str, Any] = {
259
+ "len": lambda args, kw: {"op": "len", "value": args[0]},
260
+ "int": lambda args, kw: {"op": "int_cast", "value": args[0]},
261
+ "callable": lambda args, kw: {"op": "callable_check", "value": args[0]},
262
+ "store": lambda args, kw: {"op": "store", "key": args[0], "value": args[1]},
263
+ "retrieve": lambda args, kw: {"op": "retrieve", "key": args[0]},
264
+ "write_file": lambda args, kw: {"op": "write_file", "path": args[0], "content": args[1]},
265
+ }
266
+
267
+
268
+ def _ast_call_to_cel(node):
269
+ """Convert a function call AST node to CEL."""
270
+ func = _ast_to_cel(node.func)
271
+ if func is None:
272
+ return None
273
+
274
+ # Handle partials (method references like json.loads) via dispatch table
275
+ if isinstance(func, dict) and "_partial" in func:
276
+ partial = func["_partial"]
277
+ args = [_ast_to_cel(a) for a in node.args]
278
+ converter = _PARTIAL_CONVERTERS.get(partial)
279
+ if converter is not None:
280
+ return converter(args)
281
+ return {"op": "call", "function": partial, "args": args}
282
+
283
+ # Handle simple Name calls via dispatch table
284
+ if isinstance(func, dict) and func.get("op") == "value_ref":
285
+ func_name = func["name"]
286
+ args = [_ast_to_cel(a) for a in node.args]
287
+ kwargs = {}
288
+ for kw in node.keywords:
289
+ kwargs[kw.arg] = _ast_to_cel(kw.value)
290
+
291
+ converter = _NAME_CALL_CONVERTERS.get(func_name)
292
+ if converter is not None:
293
+ return converter(args, kwargs)
294
+ if func_name == 'db' and len(args) >= 2:
295
+ return {"op": "db_query", "pillar": args[0], "sql": args[1], "params": args[2] if len(args) > 2 else None}
296
+
297
+ return {"op": "call", "function": func_name, "args": args, "kwargs": kwargs}
298
+
299
+ # Handle Attribute calls: repo.method(...)
300
+ if isinstance(func, dict) and "_partial" in func:
301
+ func_name = func["_partial"]
302
+ args = [_ast_to_cel(a) for a in node.args]
303
+ kwargs = {}
304
+ for kw in node.keywords:
305
+ kwargs[kw.arg] = _ast_to_cel(kw.value)
306
+ return {"op": "call", "function": func_name, "args": args, "kwargs": kwargs}
307
+
308
+ return None
309
+
310
+
311
+ def _ast_boolop_to_cel(node):
312
+ """Convert BoolOp (and/or) to CEL sequence or compare."""
313
+
314
+ if isinstance(node.op, ast.And):
315
+ values = [_ast_to_cel(v) for v in node.values]
316
+ # Filter out None values
317
+ steps = [v for v in values if v is not None]
318
+ if steps:
319
+ return {"op": "sequence", "steps": steps}
320
+ return None
321
+
322
+ if isinstance(node.op, ast.Or):
323
+ # Check for `a or True` pattern
324
+ last = node.values[-1]
325
+ if isinstance(last, ast.Constant) and last.value is True:
326
+ # Convert remaining values, handling os.environ.__setitem__ specially
327
+ steps = []
328
+ for v in node.values[:-1]:
329
+ cel = _ast_to_cel(v)
330
+ if cel is not None:
331
+ steps.append(cel)
332
+ if steps:
333
+ return {"op": "sequence", "steps": steps}
334
+ return None
335
+ # Check for `__setitem__(...) or __setitem__(...)` pattern
336
+ setitem_steps = []
337
+ other_values = []
338
+ for v in node.values:
339
+ if isinstance(v, ast.Call) and isinstance(v.func, ast.Attribute) and v.func.attr == '__setitem__':
340
+ env = v.func.value
341
+ if isinstance(env, ast.Attribute) and env.attr == 'environ' and isinstance(env.value, ast.Name) and env.value.id == 'os':
342
+ args = [_ast_to_cel(a) for a in v.args]
343
+ setitem_steps.append({"op": "set_env", "var": args[0], "value": args[1]})
344
+ else:
345
+ cel = _ast_to_cel(v)
346
+ if cel is not None:
347
+ setitem_steps.append(cel)
348
+ else:
349
+ other_values.append(v)
350
+ if setitem_steps:
351
+ return {"op": "sequence", "steps": setitem_steps}
352
+ # General `a or fallback` pattern -> or_fallback
353
+ if len(other_values) == 2:
354
+ left = _ast_to_cel(other_values[0])
355
+ right = _ast_to_cel(other_values[1])
356
+ if left is not None and right is not None:
357
+ return {"op": "or_fallback", "value": left, "fallback": right}
358
+ return None
359
+
360
+ return None
361
+
362
+
363
+ def _ast_compare_to_cel(node):
364
+ """Convert Compare to CEL compare op.
365
+
366
+ Two pattern-specific shortcuts produce semantically cleaner CEL than the
367
+ generic compare wrapper:
368
+ - `os.environ.pop('VAR', None) is not False` → `unset_env` (drop the tautological compare)
369
+ - `store('key', value) is not None` → `sequence(store, not_null(retrieve(key)))`
370
+ """
371
+ # --- Pattern: os.environ.pop('VAR', None) is not False → unset_env ---
372
+ if len(node.ops) == 1 and isinstance(node.ops[0], ast.IsNot) and \
373
+ isinstance(node.comparators[0], ast.Constant) and node.comparators[0].value is False:
374
+ inner = node.left
375
+ if isinstance(inner, ast.Call) and isinstance(inner.func, ast.Attribute) and inner.func.attr == 'pop':
376
+ env = inner.func.value
377
+ if isinstance(env, ast.Attribute) and env.attr == 'environ' and \
378
+ isinstance(env.value, ast.Name) and env.value.id == 'os':
379
+ args = [_ast_to_cel(a) for a in inner.args]
380
+ return {"op": "unset_env", "var": args[0]}
381
+
382
+ # --- Pattern: store('key', value) is not None → sequence(store, not_null) ---
383
+ if len(node.ops) == 1 and isinstance(node.ops[0], ast.IsNot) and \
384
+ isinstance(node.comparators[0], ast.Constant) and node.comparators[0].value is None:
385
+ inner = _ast_to_cel(node.left)
386
+ if isinstance(inner, dict) and inner.get("op") == "store":
387
+ return {
388
+ "op": "sequence",
389
+ "steps": [
390
+ inner,
391
+ {"op": "not_null", "value": {"op": "retrieve", "key": inner["key"]}}
392
+ ]
393
+ }
394
+
395
+ # --- Generic compare ---
396
+ left = _ast_to_cel(node.left)
397
+ if left is None:
398
+ return None
399
+
400
+ ops_map = {
401
+ ast.Eq: "eq", ast.NotEq: "ne",
402
+ ast.Gt: "gt", ast.GtE: "gte",
403
+ ast.Lt: "lt", ast.LtE: "lte",
404
+ ast.In: "in",
405
+ ast.IsNot: "ne", ast.Is: "eq"
406
+ }
407
+
408
+ comparators = [_ast_to_cel(c) for c in node.comparators]
409
+ if len(node.ops) == 1 and len(comparators) == 1:
410
+ op = node.ops[0]
411
+ if type(op) in ops_map:
412
+ return {"op": "compare", "left": left, "operator": ops_map[type(op)], "right": comparators[0]}
413
+
414
+ # For chained comparisons, we can't easily represent them in CEL
415
+ return None
416
+
417
+
418
+ def _ast_binop_to_cel(node):
419
+ """Convert BinOp to CEL."""
420
+ left = _ast_to_cel(node.left)
421
+ right = _ast_to_cel(node.right)
422
+ if left is None or right is None:
423
+ return None
424
+
425
+ if isinstance(node.op, ast.Add):
426
+ return {"op": "string_concat", "parts": [left, right]}
427
+ if isinstance(node.op, ast.Sub):
428
+ return {"op": "call", "function": "operator.sub", "args": [left, right]}
429
+ if isinstance(node.op, ast.Mult):
430
+ return {"op": "call", "function": "operator.mul", "args": [left, right]}
431
+ if isinstance(node.op, ast.Div):
432
+ return {"op": "call", "function": "operator.truediv", "args": [left, right]}
433
+ if isinstance(node.op, ast.Mod):
434
+ return {"op": "call", "function": "operator.mod", "args": [left, right]}
435
+
436
+ return None
437
+
438
+
439
+ def _strip_or_true(node):
440
+ """If expression is `something or True`, return the something."""
441
+ if isinstance(node, ast.BoolOp) and isinstance(node.op, ast.Or) and len(node.values) == 2:
442
+ last = node.values[-1]
443
+ if isinstance(last, ast.Constant) and last.value is True:
444
+ return node.values[0]
445
+ return None
446
+
447
+
448
+ def _strip_method_chain(node, method_names):
449
+ """Strip a chain of method calls like .strip(), .read(), etc."""
450
+ calls = []
451
+ current = node
452
+ while isinstance(current, ast.Call) and isinstance(current.func, ast.Attribute):
453
+ calls.append((current.func.attr, [_ast_to_cel(a) for a in current.args]))
454
+ current = current.func.value
455
+ calls.reverse()
456
+ return current, calls
457
+
458
+
459
+ def parse_expr_to_cel(expr_str: str):
460
+ """Parse a Python expression string and convert to a CEL dict.
461
+
462
+ Thin wrapper around the generic AST→CEL dispatcher (`_ast_to_cel`).
463
+ Strips the common `X or True` idiom before dispatch. All node-type
464
+ dispatch (calls, comparisons, boolops, subscripts, etc.) is handled
465
+ by `_ast_to_cel` and its helpers — this function does NOT re-implement
466
+ per-pattern logic.
467
+ """
468
+ expr_str = expr_str.strip()
469
+
470
+ try:
471
+ tree = ast.parse(expr_str, mode='eval')
472
+ except SyntaxError:
473
+ return expr_str
474
+
475
+ node = tree.body
476
+
477
+ # --- Strip the common `X or True` wrapper ---
478
+ stripped = _strip_or_true(node)
479
+ if stripped is not None:
480
+ node = stripped
481
+
482
+ result = _ast_to_cel(node)
483
+ return result if result is not None else expr_str
484
+
485
+
486
+ def _tuple_representer(dumper, data):
487
+ """Represent a Python tuple as a YAML sequence (without !!python/tuple tag)."""
488
+ return dumper.represent_list(data)
489
+
490
+
491
+ def convert_yaml_file(filepath: str, dry_run: bool = False):
492
+ """Convert all `actual` fields in a YAML file to CEL."""
493
+ import yaml
494
+
495
+ with open(filepath, 'r', encoding='utf-8') as f:
496
+ content = f.read()
497
+
498
+ data = yaml.safe_load(content)
499
+
500
+ count = 0
501
+ skipped = 0
502
+ skipped_exprs = []
503
+
504
+ def _walk(node, path=''):
505
+ nonlocal count, skipped
506
+ if isinstance(node, dict):
507
+ if 'actual' in node and isinstance(node['actual'], str):
508
+ cel = parse_expr_to_cel(node['actual'])
509
+ if isinstance(cel, dict):
510
+ node['actual'] = cel
511
+ count += 1
512
+ else:
513
+ skipped += 1
514
+ skipped_exprs.append((path, node['actual']))
515
+ for k, v in node.items():
516
+ _walk(v, f"{path}.{k}")
517
+ elif isinstance(node, list):
518
+ for i, item in enumerate(node):
519
+ _walk(item, f"{path}[{i}]")
520
+
521
+ _walk(data)
522
+
523
+ print(f"Converted: {count}, Skipped: {skipped}")
524
+ if skipped > 0 and dry_run:
525
+ for p, e in skipped_exprs:
526
+ print(f" SKIP {p}: {e[:100]}")
527
+
528
+ if not dry_run:
529
+ with open(filepath, 'w', encoding='utf-8') as f:
530
+ yaml.SafeDumper.add_representer(tuple, _tuple_representer)
531
+ yaml.dump(data, f, default_flow_style=False, sort_keys=False, allow_unicode=True, Dumper=yaml.SafeDumper)
532
+
533
+ return count, skipped
534
+
535
+
536
+ # ---------------------------------------------------------------------------
537
+ # Schema validation (Sprint 120 Future Work §7.3)
538
+ # ---------------------------------------------------------------------------
539
+
540
+ _DEFAULT_SCHEMA_RELS = [
541
+ os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "..", "types", "schemas", "cel_schema.yaml"),
542
+ os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "types", "schemas", "cel_schema.yaml"),
543
+ ]
544
+
545
+
546
+ def _find_default_schema() -> str | None:
547
+ """Locate cel_schema.yaml relative to this module or the project root."""
548
+ for rel in _DEFAULT_SCHEMA_RELS:
549
+ p = os.path.normpath(rel)
550
+ if os.path.isfile(p):
551
+ return p
552
+ return None
553
+
554
+
555
+ def load_schema(schema_path: str | None = None) -> dict[str, dict]:
556
+ """Load cel_schema.yaml and return ``{op_name: {required: [...], optional: [...]}}``.
557
+
558
+ Args:
559
+ schema_path: Path to ``cel_schema.yaml``. If None, auto-discovers via
560
+ ``_find_default_schema()``.
561
+
562
+ Returns:
563
+ Dict mapping operation name to its schema definition (``required``,
564
+ ``optional``, ``description``, ``defaults``).
565
+ """
566
+ import yaml
567
+
568
+ if schema_path is None:
569
+ schema_path = _find_default_schema()
570
+ if schema_path is None or not os.path.isfile(schema_path):
571
+ raise FileNotFoundError(f"CEL schema not found: {schema_path}")
572
+
573
+ with open(schema_path, "r", encoding="utf-8") as f:
574
+ data = yaml.safe_load(f)
575
+
576
+ operations = data.get("operations", {}) if isinstance(data, dict) else {}
577
+ return operations
578
+
579
+
580
+ def validate_cel(cel: Any, schema: dict[str, dict] | None = None, schema_path: str | None = None) -> list[str]:
581
+ """Validate a CEL dict against ``cel_schema.yaml``.
582
+
583
+ Checks:
584
+ - ``cel`` must be a dict with an ``op`` field.
585
+ - ``op`` must be a known operation in the schema.
586
+ - All ``required`` fields listed in the schema must be present.
587
+ - No unknown top-level fields (beyond ``required`` + ``optional`` + ``op``).
588
+
589
+ Args:
590
+ cel: The CEL dict to validate.
591
+ schema: Pre-loaded schema dict (from ``load_schema``). If None, loads it.
592
+ schema_path: Path to schema file (used if ``schema`` is None).
593
+
594
+ Returns:
595
+ List of validation error strings. Empty list = valid.
596
+ """
597
+ if schema is None:
598
+ schema = load_schema(schema_path)
599
+
600
+ errors: list[str] = []
601
+
602
+ if not isinstance(cel, dict):
603
+ return [f"CEL value is not a dict (got {type(cel).__name__}: {cel!r})"]
604
+
605
+ op = cel.get("op")
606
+ if op is None:
607
+ return ["CEL dict missing 'op' field"]
608
+
609
+ op_def = schema.get(op)
610
+ if op_def is None:
611
+ return [f"Unknown CEL operation: '{op}'"]
612
+
613
+ required = op_def.get("required", [])
614
+ optional = op_def.get("optional", [])
615
+ allowed_keys = {"op"} | set(required) | set(optional)
616
+
617
+ for field in required:
618
+ if field not in cel:
619
+ errors.append(f"Operation '{op}' missing required field '{field}'")
620
+
621
+ for key in cel:
622
+ if key not in allowed_keys:
623
+ errors.append(f"Operation '{op}' has unknown field '{key}'")
624
+
625
+ return errors
626
+
627
+
628
+ def validate_yaml_file(filepath: str, schema_path: str | None = None) -> tuple[int, list[str]]:
629
+ """Validate all CEL ``actual`` fields in a YAML file against the schema.
630
+
631
+ Walks the YAML tree, finds every dict with an ``actual`` key that is itself
632
+ a dict (i.e., already CEL-converted), and validates it.
633
+
634
+ Args:
635
+ filepath: Path to the YAML file.
636
+ schema_path: Path to ``cel_schema.yaml``. If None, auto-discovers.
637
+
638
+ Returns:
639
+ ``(validated_count, errors)`` where errors is a list of strings.
640
+ """
641
+ import yaml
642
+
643
+ with open(filepath, "r", encoding="utf-8") as f:
644
+ data = yaml.safe_load(f)
645
+
646
+ schema = load_schema(schema_path)
647
+ errors: list[str] = []
648
+ count = 0
649
+
650
+ def _walk(node, path=""):
651
+ nonlocal count
652
+ if isinstance(node, dict):
653
+ if "actual" in node and isinstance(node["actual"], dict):
654
+ count += 1
655
+ cel = node["actual"]
656
+ errs = validate_cel(cel, schema)
657
+ for e in errs:
658
+ errors.append(f"{path}.actual: {e}")
659
+ for k, v in node.items():
660
+ _walk(v, f"{path}.{k}")
661
+ elif isinstance(node, list):
662
+ for i, item in enumerate(node):
663
+ _walk(item, f"{path}[{i}]")
664
+
665
+ if data is not None:
666
+ _walk(data)
667
+
668
+ return count, errors
669
+
670
+
671
+ if __name__ == "__main__":
672
+ import argparse
673
+
674
+ parser = argparse.ArgumentParser(description="CLM CEL Converter — convert and validate YAML actual fields")
675
+ parser.add_argument("file", help="YAML file to process")
676
+ parser.add_argument("--dry-run", action="store_true", help="Preview changes without writing")
677
+ parser.add_argument("--backup", action="store_true", help="Create .bak backup")
678
+ parser.add_argument("--validate", action="store_true", help="Validate CEL dicts against schema (no conversion)")
679
+ args = parser.parse_args()
680
+
681
+ if args.validate:
682
+ count, errors = validate_yaml_file(args.file)
683
+ if errors:
684
+ print(f"Validation FAILED: {len(errors)} errors in {count} CEL dicts:")
685
+ for e in errors:
686
+ print(f" {e}")
687
+ sys.exit(1)
688
+ else:
689
+ print(f"Validation PASSED: {count} CEL dicts are valid.")
690
+ sys.exit(0)
691
+
692
+ if args.backup:
693
+ import shutil
694
+ shutil.copy(args.file, args.file + ".bak")
695
+
696
+ count, skipped = convert_yaml_file(args.file, dry_run=args.dry_run)
697
+ print(f"Done. Converted {count} expressions, skipped {skipped}.")