diff --git a/README.md b/README.md index 6ac74634..75f6e92c 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [What's new (2026-06-21) — Layered Configuration Resolver](#whats-new-2026-06-21--layered-configuration-resolver) - [What's new (2026-06-21) — Server-Sent Events (SSE) Client Parser](#whats-new-2026-06-21--server-sent-events-sse-client-parser) - [What's new (2026-06-21) — Dotenv (.env) Parsing](#whats-new-2026-06-21--dotenv-env-parsing) - [What's new (2026-06-21) — RFC 9457 Problem Details Parsing](#whats-new-2026-06-21--rfc-9457-problem-details-parsing) @@ -133,6 +134,12 @@ --- +## What's new (2026-06-21) — Layered Configuration Resolver + +Compose config with `defaults < file < env < CLI` precedence. Full reference: [`docs/source/Eng/doc/new_features/v81_features_doc.rst`](docs/source/Eng/doc/new_features/v81_features_doc.rst). + +- **`LayeredConfig` / `deep_merge` / `SourceTrace`** (`AC_resolve_config`, `AC_explain_config`): `json_patch.merge_patch` merges two docs, `config_sync` is last-write-wins, `AssetStore` is flat-per-env — none compose an ordered precedence stack with deep merge or report which layer won each key. `add_layer(name, mapping, priority)` then `resolve()` deep-merges (nested dicts recursively, scalars/lists replaced); `explain("db.host")` names the winning layer. Layers are caller-supplied (env passed in, never `os.environ` implicitly). Pure-stdlib, deterministic. + ## What's new (2026-06-21) — Server-Sent Events (SSE) Client Parser Consume `text/event-stream` responses. Full reference: [`docs/source/Eng/doc/new_features/v80_features_doc.rst`](docs/source/Eng/doc/new_features/v80_features_doc.rst). diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 947da61f..38c8b18e 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -12,6 +12,7 @@ ## 目录 +- [本次更新 (2026-06-21) — 分层配置解析器](#本次更新-2026-06-21--分层配置解析器) - [本次更新 (2026-06-21) — Server-Sent Events (SSE) 客户端解析器](#本次更新-2026-06-21--server-sent-events-sse-客户端解析器) - [本次更新 (2026-06-21) — Dotenv (.env) 解析](#本次更新-2026-06-21--dotenv-env-解析) - [本次更新 (2026-06-21) — RFC 9457 Problem Details 解析](#本次更新-2026-06-21--rfc-9457-problem-details-解析) @@ -132,6 +133,12 @@ --- +## 本次更新 (2026-06-21) — 分层配置解析器 + +以 `defaults < file < env < CLI` 优先级组合配置。完整参考:[`docs/source/Zh/doc/new_features/v81_features_doc.rst`](../docs/source/Zh/doc/new_features/v81_features_doc.rst)。 + +- **`LayeredConfig` / `deep_merge` / `SourceTrace`**(`AC_resolve_config`、`AC_explain_config`):`json_patch.merge_patch` 只合并两份文档,`config_sync` 是 last-write-wins,`AssetStore` 是每环境扁平 —— 都无法组成有序优先级堆叠并深度合并,也无法报告每个键由哪层胜出。`add_layer(name, mapping, priority)` 后 `resolve()` 深度合并(嵌套 dict 递归、标量/list 替换);`explain("db.host")` 标明胜出层。各层由调用端提供(env 由外部传入,绝不隐含 `os.environ`)。纯标准库、确定。 + ## 本次更新 (2026-06-21) — Server-Sent Events (SSE) 客户端解析器 消费 `text/event-stream` 响应。完整参考:[`docs/source/Zh/doc/new_features/v80_features_doc.rst`](../docs/source/Zh/doc/new_features/v80_features_doc.rst)。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 89cedf63..f0e82ea9 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -12,6 +12,7 @@ ## 目錄 +- [本次更新 (2026-06-21) — 分層設定解析器](#本次更新-2026-06-21--分層設定解析器) - [本次更新 (2026-06-21) — Server-Sent Events (SSE) 用戶端解析器](#本次更新-2026-06-21--server-sent-events-sse-用戶端解析器) - [本次更新 (2026-06-21) — Dotenv (.env) 解析](#本次更新-2026-06-21--dotenv-env-解析) - [本次更新 (2026-06-21) — RFC 9457 Problem Details 解析](#本次更新-2026-06-21--rfc-9457-problem-details-解析) @@ -132,6 +133,12 @@ --- +## 本次更新 (2026-06-21) — 分層設定解析器 + +以 `defaults < file < env < CLI` 優先序組合設定。完整參考:[`docs/source/Zh/doc/new_features/v81_features_doc.rst`](../docs/source/Zh/doc/new_features/v81_features_doc.rst)。 + +- **`LayeredConfig` / `deep_merge` / `SourceTrace`**(`AC_resolve_config`、`AC_explain_config`):`json_patch.merge_patch` 只合併兩份文件,`config_sync` 是 last-write-wins,`AssetStore` 是每環境扁平 —— 都無法組成有序優先序堆疊並深度合併,也無法回報每個鍵由哪層勝出。`add_layer(name, mapping, priority)` 後 `resolve()` 深度合併(巢狀 dict 遞迴、純量/list 取代);`explain("db.host")` 標明勝出層。各層由呼叫端提供(env 由外部傳入,絕不隱含 `os.environ`)。純標準函式庫、具決定性。 + ## 本次更新 (2026-06-21) — Server-Sent Events (SSE) 用戶端解析器 消費 `text/event-stream` 回應。完整參考:[`docs/source/Zh/doc/new_features/v80_features_doc.rst`](../docs/source/Zh/doc/new_features/v80_features_doc.rst)。 diff --git a/docs/source/Eng/doc/new_features/v81_features_doc.rst b/docs/source/Eng/doc/new_features/v81_features_doc.rst new file mode 100644 index 00000000..28ae0945 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v81_features_doc.rst @@ -0,0 +1,45 @@ +Layered Configuration Resolver +============================== + +``json_patch.merge_patch`` merges exactly two documents, ``config_sync`` +resolves by last-write-wins timestamp, and ``AssetStore`` is flat per +environment. None of them compose an ordered ``defaults < file < env < CLI`` +precedence stack with a deep dict merge, nor report *which layer won each key*. +This adds that 12-factor resolver. + +Pure standard library (``copy``); imports no ``PySide6``. Layers are plain +mappings supplied by the caller (the env layer is passed in, never read from +``os.environ`` implicitly), so resolution is deterministic in CI. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import LayeredConfig, deep_merge + + cfg = (LayeredConfig() + .add_layer("defaults", {"db": {"host": "local", "port": 5432}}) + .add_layer("file", file_values, priority=10) + .add_layer("env", env_values, priority=20)) + + settings = cfg.resolve() # {"db": {"host": ..., "port": ...}} + host = cfg.get("db.host") + trace = cfg.explain("db.host") # SourceTrace(value=..., layer="env") + +``add_layer`` registers a named layer; higher ``priority`` wins (priority +defaults to insertion order, so later layers override earlier ones). +``resolve`` deep-merges every layer in ascending priority — nested dicts are +merged recursively while scalars and lists are replaced. ``get`` reads a dotted +key from the resolved config with a default; ``explain`` returns a +``SourceTrace`` naming the winning layer for a dotted key (raising ``KeyError`` +when absent). ``deep_merge`` is exposed as a standalone two-mapping helper. + +Executor commands +----------------- + +``AC_resolve_config`` deep-merges a ``layers`` list (each ``{name, mapping, +priority?}``) into ``{config}``. ``AC_explain_config`` returns ``{trace}`` (the +value and winning layer) for a dotted ``key``. Both are exposed as MCP tools +(``ac_resolve_config`` / ``ac_explain_config``) and as Script Builder commands +under **Data**. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index bcf6a107..305de5b0 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -103,6 +103,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v78_features_doc doc/new_features/v79_features_doc doc/new_features/v80_features_doc + doc/new_features/v81_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/docs/source/Zh/doc/new_features/v81_features_doc.rst b/docs/source/Zh/doc/new_features/v81_features_doc.rst new file mode 100644 index 00000000..4c159b7b --- /dev/null +++ b/docs/source/Zh/doc/new_features/v81_features_doc.rst @@ -0,0 +1,37 @@ +分層設定解析器 +============ + +``json_patch.merge_patch`` 只合併兩份文件,``config_sync`` 以 last-write-wins 時間戳解析, +``AssetStore`` 則是每環境的扁平結構。它們都無法組成一個有序的 ``defaults < file < env < CLI`` 優先序 +堆疊並做深度 dict 合併,也無法回報*每個鍵由哪一層勝出*。本功能補上這個 12-factor 解析器。 + +純標準函式庫(``copy``);不匯入 ``PySide6``。各層為呼叫端提供的純 mapping(env 層由外部傳入,絕不 +隱含讀取 ``os.environ``),因此解析在 CI 中具決定性。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import LayeredConfig, deep_merge + + cfg = (LayeredConfig() + .add_layer("defaults", {"db": {"host": "local", "port": 5432}}) + .add_layer("file", file_values, priority=10) + .add_layer("env", env_values, priority=20)) + + settings = cfg.resolve() # {"db": {"host": ..., "port": ...}} + host = cfg.get("db.host") + trace = cfg.explain("db.host") # SourceTrace(value=..., layer="env") + +``add_layer`` 註冊一個具名層;``priority`` 越高越勝出(預設為插入順序,因此後加的層覆蓋先前的)。 +``resolve`` 依優先序由低到高深度合併每一層 —— 巢狀 dict 遞迴合併,而純量與 list 直接取代。``get`` 以 +點分鍵從解析後設定讀取並帶預設值;``explain`` 回傳 ``SourceTrace``,標明點分鍵的勝出層(不存在時拋 +``KeyError``)。``deep_merge`` 另以獨立的雙 mapping 輔助函式提供。 + +執行器命令 +---------- + +``AC_resolve_config`` 把 ``layers`` 清單(每筆 ``{name, mapping, priority?}``)深度合併成 +``{config}``。``AC_explain_config`` 對點分 ``key`` 回傳 ``{trace}``(值與勝出層)。兩者皆以 MCP 工具 +(``ac_resolve_config`` / ``ac_explain_config``)以及 Script Builder 中 **Data** 分類下的命令提供。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index c2639eb3..400c8f97 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -103,6 +103,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v78_features_doc doc/new_features/v79_features_doc doc/new_features/v80_features_doc + doc/new_features/v81_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index 1db607ec..7e31f7b5 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -274,6 +274,10 @@ from je_auto_control.utils.dotenv import ( dotenv_values, dump_dotenv, load_dotenv, parse_dotenv, ) +# Layered config resolver (defaults < file < env < CLI, with provenance) +from je_auto_control.utils.layered_config import ( + LayeredConfig, SourceTrace, deep_merge, +) # Outbound CloudEvents emitter from je_auto_control.utils.events import ( EventEmitter, post_cloudevent, to_cloudevent, @@ -860,6 +864,7 @@ def start_autocontrol_gui(*args, **kwargs): "rank_automation_candidates", "Asset", "AssetStore", "AssetValue", "active_environment", "dotenv_values", "dump_dotenv", "load_dotenv", "parse_dotenv", + "LayeredConfig", "SourceTrace", "deep_merge", "EventEmitter", "post_cloudevent", "to_cloudevent", "WebhookChannel", "WebhookResult", "notify_webhook", "set_default_poster", "json_extract", "json_query", "json_query_one", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index 3a201869..18fff497 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -1678,6 +1678,25 @@ def _add_resilience_specs(specs: List[CommandSpec]) -> None: ), description="Parse a text/event-stream blob into events.", )) + specs.append(CommandSpec( + "AC_resolve_config", "Data", "Layered Config: Resolve", + fields=( + FieldSpec("layers", FieldType.STRING, + placeholder='[{"name": "defaults", "mapping": {}}, ' + '{"name": "env", "mapping": {}, ' + '"priority": 10}]'), + ), + description="Deep-merge ordered config layers into one config.", + )) + specs.append(CommandSpec( + "AC_explain_config", "Data", "Layered Config: Explain Key", + fields=( + FieldSpec("layers", FieldType.STRING, + placeholder='[{"name": "defaults", "mapping": {}}]'), + FieldSpec("key", FieldType.STRING, placeholder="db.host"), + ), + description="Show the value and winning layer for a dotted config key.", + )) specs.append(CommandSpec( "AC_rate_limit", "Flow", "Rate Limit (Token Bucket)", fields=( diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 0059ea4e..8ddbf1e5 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -3050,6 +3050,31 @@ def _parse_sse(text: str) -> Dict[str, Any]: return {"events": [event.to_dict() for event in parse_event_stream(text)]} +def _build_layered_config(layers: Any): + """Build a LayeredConfig from a list of {name, mapping, priority?} dicts.""" + import json + from je_auto_control.utils.layered_config import LayeredConfig + if isinstance(layers, str): + layers = json.loads(layers) + config = LayeredConfig() + for layer in layers: + config.add_layer(layer["name"], layer.get("mapping", {}), + layer.get("priority")) + return config + + +def _resolve_config(layers: Any) -> Dict[str, Any]: + """Adapter: deep-merge config layers into a resolved {config}.""" + return {"config": _build_layered_config(layers).resolve()} + + +def _explain_config(layers: Any, key: str) -> Dict[str, Any]: + """Adapter: report the value and winning layer for a dotted config key.""" + trace = _build_layered_config(layers).explain(key) + return {"trace": {"key": trace.key, "value": trace.value, + "layer": trace.layer}} + + def _percentiles(samples: Any, qs: Any = None) -> Dict[str, Any]: """Adapter: exact percentiles of a numeric sample list (or JSON string).""" import json @@ -4134,6 +4159,8 @@ def __init__(self): "AC_parse_dotenv": _parse_dotenv, "AC_load_dotenv": _load_dotenv, "AC_parse_sse": _parse_sse, + "AC_resolve_config": _resolve_config, + "AC_explain_config": _explain_config, "AC_unified_diff": _unified_diff, "AC_apply_unified": _apply_unified, "AC_three_way_merge": _three_way_merge, diff --git a/je_auto_control/utils/layered_config/__init__.py b/je_auto_control/utils/layered_config/__init__.py new file mode 100644 index 00000000..64ae1fa2 --- /dev/null +++ b/je_auto_control/utils/layered_config/__init__.py @@ -0,0 +1,6 @@ +"""Layered configuration resolution for AutoControl.""" +from je_auto_control.utils.layered_config.layered_config import ( + LayeredConfig, SourceTrace, deep_merge, +) + +__all__ = ["LayeredConfig", "SourceTrace", "deep_merge"] diff --git a/je_auto_control/utils/layered_config/layered_config.py b/je_auto_control/utils/layered_config/layered_config.py new file mode 100644 index 00000000..68d1643c --- /dev/null +++ b/je_auto_control/utils/layered_config/layered_config.py @@ -0,0 +1,104 @@ +"""Ordered, deep-merging configuration resolver with per-key provenance. + +``json_patch.merge_patch`` merges exactly two documents and ``config_sync`` +resolves by last-write-wins timestamp; ``AssetStore`` is flat-per-environment. +None of them compose an ordered ``defaults < file < env < CLI`` precedence +stack with a deep dict merge, nor report *which layer won each key*. This adds +that 12-factor resolver. + +Pure standard library (``copy``); imports no ``PySide6``. Layers are plain +mappings supplied by the caller (the env layer is passed in, never read from +``os.environ`` implicitly), so resolution is deterministic in CI. +""" +import copy +from dataclasses import dataclass +from typing import Any, Dict, List, Mapping, Optional, Tuple + + +@dataclass(frozen=True) +class SourceTrace: + """Where a resolved key's value came from.""" + + key: str + value: Any + layer: Optional[str] + + +@dataclass +class _Layer: + name: str + mapping: Dict[str, Any] + priority: int + index: int + + +def deep_merge(base: Mapping[str, Any], + override: Mapping[str, Any]) -> Dict[str, Any]: + """Recursively merge ``override`` onto ``base``; ``override`` wins leaves.""" + result: Dict[str, Any] = dict(base) + for key, value in override.items(): + current = result.get(key) + if isinstance(value, Mapping) and isinstance(current, Mapping): + result[key] = deep_merge(current, value) + else: + result[key] = copy.deepcopy(value) + return result + + +def _get_path(mapping: Mapping[str, Any], + parts: List[str]) -> Tuple[bool, Any]: + current: Any = mapping + for part in parts: + if not isinstance(current, Mapping) or part not in current: + return False, None + current = current[part] + return True, current + + +class LayeredConfig: + """Compose configuration layers by priority with deep merging.""" + + def __init__(self) -> None: + self._layers: List[_Layer] = [] + + def add_layer(self, name: str, mapping: Mapping[str, Any], + priority: Optional[int] = None) -> "LayeredConfig": + """Add a layer; higher ``priority`` wins (default: insertion order).""" + index = len(self._layers) + resolved_priority = index if priority is None else priority + self._layers.append( + _Layer(name, dict(mapping or {}), resolved_priority, index)) + return self + + def layer_names(self) -> List[str]: + """The layer names in insertion order.""" + return [layer.name for layer in self._layers] + + def _ascending(self) -> List[_Layer]: + return sorted(self._layers, key=lambda layer: (layer.priority, + layer.index)) + + def resolve(self) -> Dict[str, Any]: + """Deep-merge all layers in ascending priority into one config dict.""" + merged: Dict[str, Any] = {} + for layer in self._ascending(): + merged = deep_merge(merged, layer.mapping) + return merged + + def get(self, dotted_key: str, default: Any = None) -> Any: + """Return the resolved value for a dotted key, or ``default``.""" + found, value = _get_path(self.resolve(), dotted_key.split(".")) + return value if found else default + + def explain(self, dotted_key: str) -> SourceTrace: + """Return the value and the winning layer name for a dotted key.""" + parts = dotted_key.split(".") + found, value = _get_path(self.resolve(), parts) + if not found: + raise KeyError(dotted_key) + for layer in sorted(self._layers, reverse=True, + key=lambda item: (item.priority, item.index)): + layer_found, _ = _get_path(layer.mapping, parts) + if layer_found: + return SourceTrace(dotted_key, value, layer.name) + return SourceTrace(dotted_key, value, None) diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index de7f74e4..8b4db33d 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3343,6 +3343,31 @@ def rate_limit_tools() -> List[MCPTool]: ] +def layered_config_tools() -> List[MCPTool]: + layer_schema = {"type": "array", "items": {"type": "object"}} + return [ + MCPTool( + name="ac_resolve_config", + description=("Deep-merge ordered config 'layers' (each {name, " + "mapping, priority?}; higher priority wins) into a " + "single {config}."), + input_schema=schema({"layers": layer_schema}, ["layers"]), + handler=h.resolve_config, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_explain_config", + description=("Report the value and winning layer name for a dotted " + "'key' across config 'layers'. Returns {trace}."), + input_schema=schema( + {"layers": layer_schema, "key": {"type": "string"}}, + ["layers", "key"]), + handler=h.explain_config, + annotations=READ_ONLY, + ), + ] + + def sse_client_tools() -> List[MCPTool]: return [ MCPTool( @@ -4985,7 +5010,7 @@ def media_assert_tools() -> List[MCPTool]: feature_flag_tools, provenance_tools, json_contract_tools, chaos_tools, slo_tools, percentiles_tools, bulkhead_tools, http_cassette_tools, trace_context_tools, data_profile_tools, http_problem_tools, dotenv_tools, - sse_client_tools, + sse_client_tools, layered_config_tools, saga_tools, decision_table_tools, locator_repair_tools, pii_text_tools, sarif_tools, screen_record_tools, diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 437e27a2..103da836 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1756,6 +1756,16 @@ def parse_sse(text): return _parse_sse(text) +def resolve_config(layers): + from je_auto_control.utils.executor.action_executor import _resolve_config + return _resolve_config(layers) + + +def explain_config(layers, key): + from je_auto_control.utils.executor.action_executor import _explain_config + return _explain_config(layers, key) + + def build_provenance(paths, builder_id="je_auto_control"): from je_auto_control.utils.provenance import build_provenance, subject_for subjects = [subject_for(path) for path in paths] diff --git a/test/unit_test/headless/test_layered_config_batch.py b/test/unit_test/headless/test_layered_config_batch.py new file mode 100644 index 00000000..a1afba6b --- /dev/null +++ b/test/unit_test/headless/test_layered_config_batch.py @@ -0,0 +1,89 @@ +"""Headless tests for the layered config resolver. Pure stdlib, no Qt.""" +import json + +import pytest + +import je_auto_control as ac +from je_auto_control.utils.layered_config import ( + LayeredConfig, deep_merge, +) + + +def test_deep_merge_recurses_and_overrides(): + base = {"db": {"host": "local", "port": 5432}, "debug": False} + override = {"db": {"host": "prod"}, "debug": True} + merged = deep_merge(base, override) + assert merged == {"db": {"host": "prod", "port": 5432}, "debug": True} + assert base["db"]["host"] == "local" # inputs untouched + + +def test_priority_order_wins(): + cfg = (LayeredConfig() + .add_layer("defaults", {"x": 1, "y": 1}, priority=0) + .add_layer("env", {"x": 2}, priority=10)) + assert cfg.resolve() == {"x": 2, "y": 1} + + +def test_insertion_order_is_default_priority(): + cfg = (LayeredConfig() + .add_layer("a", {"k": "first"}) + .add_layer("b", {"k": "second"})) + assert cfg.resolve()["k"] == "second" # later layer wins + + +def test_explain_reports_winning_layer(): + cfg = (LayeredConfig() + .add_layer("defaults", {"db": {"host": "local", "port": 5432}}) + .add_layer("env", {"db": {"host": "prod"}}, priority=10)) + host = cfg.explain("db.host") + assert host.value == "prod" and host.layer == "env" + port = cfg.explain("db.port") + assert port.value == 5432 and port.layer == "defaults" + + +def test_explain_unknown_key_raises(): + cfg = LayeredConfig().add_layer("a", {"x": 1}) + with pytest.raises(KeyError): + cfg.explain("missing") + + +def test_get_with_default_and_layer_names(): + cfg = LayeredConfig().add_layer("a", {"x": {"y": 7}}) + assert cfg.get("x.y") == 7 + assert cfg.get("x.z", "fallback") == "fallback" + assert cfg.layer_names() == ["a"] + + +# --- wiring --------------------------------------------------------------- + +_LAYERS = [ + {"name": "defaults", "mapping": {"db": {"host": "local", "port": 5432}}}, + {"name": "env", "mapping": {"db": {"host": "prod"}}, "priority": 10}, +] + + +def test_executor_resolve_and_explain(): + rec = ac.execute_action([[ + "AC_resolve_config", {"layers": json.dumps(_LAYERS)}]]) + config = next(v for v in rec.values() if isinstance(v, dict))["config"] + assert config["db"]["host"] == "prod" and config["db"]["port"] == 5432 + rec2 = ac.execute_action([[ + "AC_explain_config", {"layers": json.dumps(_LAYERS), "key": "db.host"}]]) + trace = next(v for v in rec2.values() if isinstance(v, dict))["trace"] + assert trace == {"key": "db.host", "value": "prod", "layer": "env"} + + +def test_wiring(): + known = ac.executor.known_commands() + assert {"AC_resolve_config", "AC_explain_config"} <= set(known) + from je_auto_control.utils.mcp_server.tools import build_default_tool_registry + names = {t.name for t in build_default_tool_registry()} + assert {"ac_resolve_config", "ac_explain_config"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert {"AC_resolve_config", "AC_explain_config"} <= specs + + +def test_facade_exports(): + for attr in ("LayeredConfig", "SourceTrace", "deep_merge"): + assert hasattr(ac, attr) and attr in ac.__all__