From 3cab3231a48a438b004abe3c6d37b83939d67737 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 21 Jun 2026 09:29:25 +0800 Subject: [PATCH 1/2] Add JSON contract and snapshot matching json_schema validates against an authored schema and jsonpath extracts, but nothing matched two JSON payloads with relaxed rules or diffed them path-by-path for contract/snapshot tests. Add match_json (partial, match_type, ignore), diff_json (path-tagged missing/extra/changed), normalize_json and golden-master snapshot. Composes with json_schema and json_patch. Wired through the facade, AC_match_json/AC_diff_json executor commands, MCP tools and the Script Builder. --- README.md | 7 + README/README_zh-CN.md | 7 + README/README_zh-TW.md | 7 + .../Eng/doc/new_features/v70_features_doc.rst | 51 +++++++ docs/source/Eng/eng_index.rst | 1 + .../Zh/doc/new_features/v70_features_doc.rst | 44 ++++++ docs/source/Zh/zh_index.rst | 1 + je_auto_control/__init__.py | 5 + .../gui/script_builder/command_schema.py | 19 +++ .../utils/executor/action_executor.py | 26 ++++ .../utils/json_contract/__init__.py | 8 ++ .../utils/json_contract/json_contract.py | 127 ++++++++++++++++++ .../utils/mcp_server/tools/_factories.py | 30 ++++- .../utils/mcp_server/tools/_handlers.py | 11 ++ .../headless/test_json_contract_batch.py | 104 ++++++++++++++ 15 files changed, 447 insertions(+), 1 deletion(-) create mode 100644 docs/source/Eng/doc/new_features/v70_features_doc.rst create mode 100644 docs/source/Zh/doc/new_features/v70_features_doc.rst create mode 100644 je_auto_control/utils/json_contract/__init__.py create mode 100644 je_auto_control/utils/json_contract/json_contract.py create mode 100644 test/unit_test/headless/test_json_contract_batch.py diff --git a/README.md b/README.md index caa73f07..7c27b295 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [What's new (2026-06-21) — JSON Contract & Snapshot Matching](#whats-new-2026-06-21--json-contract--snapshot-matching) - [What's new (2026-06-21) — SLSA Build Provenance](#whats-new-2026-06-21--slsa-build-provenance) - [What's new (2026-06-21) — Feature Flags](#whats-new-2026-06-21--feature-flags) - [What's new (2026-06-21) — Text Diff, Patch & Three-Way Merge](#whats-new-2026-06-21--text-diff-patch--three-way-merge) @@ -122,6 +123,12 @@ --- +## What's new (2026-06-21) — JSON Contract & Snapshot Matching + +Match, diff and snapshot JSON payloads. Full reference: [`docs/source/Eng/doc/new_features/v70_features_doc.rst`](docs/source/Eng/doc/new_features/v70_features_doc.rst). + +- **`match_json` / `diff_json` / `normalize_json` / `snapshot`** (`AC_match_json`, `AC_diff_json`): `json_schema` validates against an authored schema and `jsonpath` extracts, but nothing matched two payloads with relaxed rules or diffed them path-by-path. This adds contract/snapshot matching — `partial` (subset), `match_type` (Pact-style `like`), `ignore` volatile paths — returning `{path, kind}` mismatches (`missing`/`extra`/`changed`), plus golden-master `snapshot`. Composes with `json_schema` + `json_patch`; pure-stdlib. + ## What's new (2026-06-21) — SLSA Build Provenance Attest what was built. Full reference: [`docs/source/Eng/doc/new_features/v69_features_doc.rst`](docs/source/Eng/doc/new_features/v69_features_doc.rst). diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index 6f7484a3..f1a7023c 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -12,6 +12,7 @@ ## 目录 +- [本次更新 (2026-06-21) — JSON 合约与快照比对](#本次更新-2026-06-21--json-合约与快照比对) - [本次更新 (2026-06-21) — SLSA 构建来源证明](#本次更新-2026-06-21--slsa-构建来源证明) - [本次更新 (2026-06-21) — 功能旗标](#本次更新-2026-06-21--功能旗标) - [本次更新 (2026-06-21) — 文本 Diff、应用与三方合并](#本次更新-2026-06-21--文本-diff应用与三方合并) @@ -121,6 +122,12 @@ --- +## 本次更新 (2026-06-21) — JSON 合约与快照比对 + +比对、取差异与快照 JSON 内容。完整参考:[`docs/source/Zh/doc/new_features/v70_features_doc.rst`](../docs/source/Zh/doc/new_features/v70_features_doc.rst)。 + +- **`match_json` / `diff_json` / `normalize_json` / `snapshot`**(`AC_match_json`、`AC_diff_json`):`json_schema` 以撰写的 schema 验证、`jsonpath` 提取,但没有任何东西能以宽松规则比对两份内容或逐路径取差异。本功能补上合约/快照比对 —— `partial`(子集)、`match_type`(Pact 风格 `like`)、`ignore` 易变路径 —— 返回 `{path, kind}` 不符(`missing`/`extra`/`changed`),外加 golden-master `snapshot`。与 `json_schema` + `json_patch` 互补;纯标准库。 + ## 本次更新 (2026-06-21) — SLSA 构建来源证明 证明构建产生了什么。完整参考:[`docs/source/Zh/doc/new_features/v69_features_doc.rst`](../docs/source/Zh/doc/new_features/v69_features_doc.rst)。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 48d48299..b8ce3a62 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -12,6 +12,7 @@ ## 目錄 +- [本次更新 (2026-06-21) — JSON 合約與快照比對](#本次更新-2026-06-21--json-合約與快照比對) - [本次更新 (2026-06-21) — SLSA 建置來源證明](#本次更新-2026-06-21--slsa-建置來源證明) - [本次更新 (2026-06-21) — 功能旗標](#本次更新-2026-06-21--功能旗標) - [本次更新 (2026-06-21) — 文字 Diff、套用與三方合併](#本次更新-2026-06-21--文字-diff套用與三方合併) @@ -121,6 +122,12 @@ --- +## 本次更新 (2026-06-21) — JSON 合約與快照比對 + +比對、取差異與快照 JSON 內容。完整參考:[`docs/source/Zh/doc/new_features/v70_features_doc.rst`](../docs/source/Zh/doc/new_features/v70_features_doc.rst)。 + +- **`match_json` / `diff_json` / `normalize_json` / `snapshot`**(`AC_match_json`、`AC_diff_json`):`json_schema` 以撰寫的 schema 驗證、`jsonpath` 擷取,但沒有任何東西能以寬鬆規則比對兩份內容或逐路徑取差異。本功能補上合約/快照比對 —— `partial`(子集)、`match_type`(Pact 風格 `like`)、`ignore` 易變路徑 —— 回傳 `{path, kind}` 不符(`missing`/`extra`/`changed`),外加 golden-master `snapshot`。與 `json_schema` + `json_patch` 互補;純標準函式庫。 + ## 本次更新 (2026-06-21) — SLSA 建置來源證明 證明建置產生了什麼。完整參考:[`docs/source/Zh/doc/new_features/v69_features_doc.rst`](../docs/source/Zh/doc/new_features/v69_features_doc.rst)。 diff --git a/docs/source/Eng/doc/new_features/v70_features_doc.rst b/docs/source/Eng/doc/new_features/v70_features_doc.rst new file mode 100644 index 00000000..8cdea021 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v70_features_doc.rst @@ -0,0 +1,51 @@ +JSON Contract & Snapshot Matching +================================= + +``json_schema`` validates a value against an authored schema and ``jsonpath`` +extracts values, but nothing matched two JSON *payloads* with relaxed rules +(type-only, partial, ignore volatile paths) or diffed them path-by-path for +contract / snapshot tests. This adds that layer; it composes with +``json_schema`` (shape) and ``json_patch`` (structured edits). + +Pure standard library (``json``); deterministic; imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import match_json, diff_json, snapshot + + report = match_json(actual, {"id": 1, "name": "Ada"}) + if not report.ok: + for m in report.mismatches: + print(m["path"], m["kind"]) # e.g. "$.name" "changed" + + # Pact-style "like": values may differ, types must match + match_json(response, template, match_type=True) + # subset match: extra keys in `actual` are allowed + match_json(response, template, partial=True) + # ignore volatile fields + match_json(response, template, ignore=["$.created_at", "$.id"]) + + diff_json(actual, expected) # [{path, kind, ...}] + snapshot(actual, "golden/checkout.json") # write-if-absent, else compare + +``match_json`` returns a ``MatchReport(ok, mismatches)`` where each mismatch is +``{path, kind}`` with ``kind`` one of ``missing`` (in expected, absent from +actual), ``extra`` (in actual, absent from expected), or ``changed``. Options: +``partial`` drops ``extra`` mismatches (subset match), ``match_type`` accepts a +``changed`` leaf whose types match (Pact ``like``), and ``ignore`` skips listed +paths. ``diff_json`` is the raw path-tagged diff; ``normalize_json`` returns a +canonical copy (sorted keys, ``drop`` keys removed) for stable comparison; +``snapshot`` is golden-master testing (writes the file on first run, then +matches against it). ``true`` stays distinct from ``1``. + +Executor commands +----------------- + +``AC_match_json`` takes ``actual`` / ``expected`` (objects or JSON strings) plus +optional ``partial`` / ``match_type`` and returns ``{ok, mismatches}``. +``AC_diff_json`` returns ``{diffs}``. Both are exposed as MCP tools +(``ac_match_json`` / ``ac_diff_json``) and as Script Builder commands under +**Data**. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index 1a5d8d49..26a7e787 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -92,6 +92,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v67_features_doc doc/new_features/v68_features_doc doc/new_features/v69_features_doc + doc/new_features/v70_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/v70_features_doc.rst b/docs/source/Zh/doc/new_features/v70_features_doc.rst new file mode 100644 index 00000000..eb55bfef --- /dev/null +++ b/docs/source/Zh/doc/new_features/v70_features_doc.rst @@ -0,0 +1,44 @@ +JSON 合約與快照比對 +=================== + +``json_schema`` 以撰寫的 schema 驗證一個值,``jsonpath`` 擷取值,但沒有任何東西能以寬鬆規則 +(僅型別、部分、忽略易變路徑)比對兩份 JSON *內容*,或逐路徑對它們取差異以做合約 / 快照測試。 +本功能補上這個層;它與 ``json_schema``(形狀)及 ``json_patch``(結構化編輯)互補。 + +純標準函式庫(``json``);具決定性;不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import match_json, diff_json, snapshot + + report = match_json(actual, {"id": 1, "name": "Ada"}) + if not report.ok: + for m in report.mismatches: + print(m["path"], m["kind"]) # 例如 "$.name" "changed" + + # Pact 風格的 "like":值可不同,但型別必須相符 + match_json(response, template, match_type=True) + # 子集比對:允許 actual 有額外的鍵 + match_json(response, template, partial=True) + # 忽略易變欄位 + match_json(response, template, ignore=["$.created_at", "$.id"]) + + diff_json(actual, expected) # [{path, kind, ...}] + snapshot(actual, "golden/checkout.json") # 不存在則寫入,否則比對 + +``match_json`` 回傳 ``MatchReport(ok, mismatches)``,每個不符為 ``{path, kind}``,``kind`` 為 +``missing``(在 expected、actual 沒有)、``extra``(在 actual、expected 沒有)或 ``changed`` 之一。 +選項:``partial`` 捨棄 ``extra`` 不符(子集比對),``match_type`` 接受型別相符的 ``changed`` 葉 +(Pact ``like``),``ignore`` 略過列出的路徑。``diff_json`` 是原始的路徑標記差異;``normalize_json`` +回傳正規化副本(鍵排序、移除 ``drop`` 鍵)以利穩定比對;``snapshot`` 是 golden-master 測試 +(首次執行寫檔,之後比對)。``true`` 與 ``1`` 保持相異。 + +執行器命令 +---------- + +``AC_match_json`` 接受 ``actual`` / ``expected``(物件或 JSON 字串)及選用的 ``partial`` / +``match_type``,回傳 ``{ok, mismatches}``。``AC_diff_json`` 回傳 ``{diffs}``。兩者皆以 MCP 工具 +(``ac_match_json`` / ``ac_diff_json``)以及 Script Builder 中 **Data** 分類下的命令提供。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index 2ca8de11..5e8c4d74 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -92,6 +92,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v67_features_doc doc/new_features/v68_features_doc doc/new_features/v69_features_doc + doc/new_features/v70_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 d4f6daa4..6da082c0 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -356,6 +356,10 @@ build_provenance, subject_for, subject_for_bytes, verify_provenance, write_provenance, ) +# JSON contract / snapshot matching +from je_auto_control.utils.json_contract import ( + MatchReport, diff_json, match_json, normalize_json, snapshot, +) # Background popup/interrupt watchdog (unattended automation) from je_auto_control.utils.watchdog import ( PopupWatchdog, WatchdogRule, default_popup_watchdog, @@ -848,6 +852,7 @@ def start_autocontrol_gui(*args, **kwargs): "percentage_bucket", "build_provenance", "subject_for", "subject_for_bytes", "verify_provenance", "write_provenance", + "MatchReport", "diff_json", "match_json", "normalize_json", "snapshot", # MCP server "AuditLogger", "HttpMCPServer", "MCPContent", "MCPPrompt", "MCPPromptArgument", "MCPResource", "MCPServer", "MCPTool", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index afe512e2..780314c3 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -1155,6 +1155,25 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: ), description="Validate JSON against a JSON Schema; returns {ok, errors}.", )) + specs.append(CommandSpec( + "AC_match_json", "Data", "JSON Contract: Match", + fields=( + FieldSpec("actual", FieldType.STRING, placeholder='{"a": 1}'), + FieldSpec("expected", FieldType.STRING, placeholder='{"a": 1}'), + FieldSpec("partial", FieldType.BOOL, optional=True, default=False), + FieldSpec("match_type", FieldType.BOOL, optional=True, + default=False), + ), + description="Match JSON against expected (partial/type); {ok, mismatches}.", + )) + specs.append(CommandSpec( + "AC_diff_json", "Data", "JSON Contract: Diff", + fields=( + FieldSpec("actual", FieldType.STRING, placeholder='{"a": 1}'), + FieldSpec("expected", FieldType.STRING, placeholder='{"a": 2}'), + ), + description="Path-tagged diff between two JSON payloads; {diffs}.", + )) specs.append(CommandSpec( "AC_evaluate_flag", "Flow", "Feature Flag: Evaluate", fields=( diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 7e7d1c56..4defc6c3 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -2928,6 +2928,30 @@ def _rate_limit(name: str, rate: float = 1.0, capacity: float = 1.0, "wait": round(bucket.time_until_available(float(n)), 4)} +def _match_json(actual: Any, expected: Any, partial: bool = False, + match_type: bool = False) -> Dict[str, Any]: + """Adapter: match a JSON payload against an expected one (relaxed rules).""" + import json + from je_auto_control.utils.json_contract import match_json + if isinstance(actual, str): + actual = json.loads(actual) + if isinstance(expected, str): + expected = json.loads(expected) + return match_json(actual, expected, partial=bool(partial), + match_type=bool(match_type)).to_dict() + + +def _diff_json(actual: Any, expected: Any) -> Dict[str, Any]: + """Adapter: path-tagged diff between two JSON payloads.""" + import json + from je_auto_control.utils.json_contract import diff_json + if isinstance(actual, str): + actual = json.loads(actual) + if isinstance(expected, str): + expected = json.loads(expected) + return {"diffs": diff_json(actual, expected)} + + def _build_provenance(paths: Any, builder_id: str = "je_auto_control", build_type: str = "https://je-auto-control/buildtype/v1" ) -> Dict[str, Any]: @@ -3907,6 +3931,8 @@ def __init__(self): "AC_flag_enabled": _flag_enabled, "AC_build_provenance": _build_provenance, "AC_verify_provenance": _verify_provenance, + "AC_match_json": _match_json, + "AC_diff_json": _diff_json, "AC_unified_diff": _unified_diff, "AC_apply_unified": _apply_unified, "AC_three_way_merge": _three_way_merge, diff --git a/je_auto_control/utils/json_contract/__init__.py b/je_auto_control/utils/json_contract/__init__.py new file mode 100644 index 00000000..3ad4af9c --- /dev/null +++ b/je_auto_control/utils/json_contract/__init__.py @@ -0,0 +1,8 @@ +"""JSON contract / snapshot matching: match_json, diff_json, snapshot.""" +from je_auto_control.utils.json_contract.json_contract import ( + MatchReport, diff_json, match_json, normalize_json, snapshot, +) + +__all__ = [ + "MatchReport", "diff_json", "match_json", "normalize_json", "snapshot", +] diff --git a/je_auto_control/utils/json_contract/json_contract.py b/je_auto_control/utils/json_contract/json_contract.py new file mode 100644 index 00000000..8be3cbc8 --- /dev/null +++ b/je_auto_control/utils/json_contract/json_contract.py @@ -0,0 +1,127 @@ +"""Match, diff and snapshot JSON payloads (contract / golden-master testing). + +``json_schema`` validates a value against an authored schema and ``jsonpath`` +extracts values, but nothing matched two JSON payloads with relaxed rules +(type-only, partial, ignore volatile paths) or diffed them path-by-path for +contract / snapshot tests. This adds that layer; it composes with +``json_schema`` (shape) and ``json_patch`` (structured edits). + +Pure standard library (``json`` + ``os``); deterministic; imports no +``PySide6``. +""" +import json +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, Iterable, List + + +@dataclass(frozen=True) +class MatchReport: + """Outcome of matching an actual payload against an expected one.""" + + ok: bool + mismatches: List[Dict[str, Any]] = field(default_factory=list) + + def to_dict(self) -> Dict[str, Any]: + """Return a plain-dict view for executor / MCP responses.""" + return {"ok": self.ok, "mismatches": list(self.mismatches)} + + +def _json_equal(left: Any, right: Any) -> bool: + if isinstance(left, bool) or isinstance(right, bool): + return left is right + return left == right + + +def _diff_dict(actual: Dict, expected: Dict, path: str, + diffs: List[Dict[str, Any]]) -> None: + for key, value in expected.items(): + child = f"{path}.{key}" + if key not in actual: + diffs.append({"path": child, "kind": "missing", "expected": value}) + else: + diffs.extend(diff_json(actual[key], value, path=child)) + for key, value in actual.items(): + if key not in expected: + diffs.append({"path": f"{path}.{key}", "kind": "extra", + "actual": value}) + + +def _diff_list(actual: List, expected: List, path: str, + diffs: List[Dict[str, Any]]) -> None: + common = min(len(actual), len(expected)) + for index in range(common): + diffs.extend(diff_json(actual[index], expected[index], + path=f"{path}[{index}]")) + for index in range(common, len(expected)): + diffs.append({"path": f"{path}[{index}]", "kind": "missing", + "expected": expected[index]}) + for index in range(common, len(actual)): + diffs.append({"path": f"{path}[{index}]", "kind": "extra", + "actual": actual[index]}) + + +def diff_json(actual: Any, expected: Any, *, + path: str = "$") -> List[Dict[str, Any]]: + """Return path-tagged differences between ``actual`` and ``expected``.""" + diffs: List[Dict[str, Any]] = [] + if isinstance(expected, dict) and isinstance(actual, dict): + _diff_dict(actual, expected, path, diffs) + elif isinstance(expected, list) and isinstance(actual, list): + _diff_list(actual, expected, path, diffs) + elif not _json_equal(actual, expected): + diffs.append({"path": path, "kind": "changed", "actual": actual, + "expected": expected}) + return diffs + + +def _type_match(left: Any, right: Any) -> bool: + if isinstance(left, bool) or isinstance(right, bool): + return isinstance(left, bool) and isinstance(right, bool) + if isinstance(left, (int, float)) and isinstance(right, (int, float)): + return True + return type(left) is type(right) + + +def match_json(actual: Any, expected: Any, *, ignore: Iterable[str] = (), + match_type: bool = False, partial: bool = False) -> MatchReport: + """Match ``actual`` against ``expected`` with optional relaxed rules.""" + ignored = set(ignore) + kept: List[Dict[str, Any]] = [] + for diff in diff_json(actual, expected): + if diff["path"] in ignored: + continue + if partial and diff["kind"] == "extra": + continue + if (match_type and diff["kind"] == "changed" + and _type_match(diff["actual"], diff["expected"])): + continue + kept.append(diff) + return MatchReport(ok=not kept, mismatches=kept) + + +def _normalize(value: Any, drop: set) -> Any: + if isinstance(value, dict): + return {key: _normalize(val, drop) + for key, val in sorted(value.items()) if key not in drop} + if isinstance(value, list): + return [_normalize(item, drop) for item in value] + return value + + +def normalize_json(value: Any, *, drop: Iterable[str] = ()) -> Any: + """Return a canonical copy (sorted keys, ``drop`` keys removed anywhere).""" + return _normalize(value, set(drop)) + + +def snapshot(actual: Any, path: str) -> bool: + """Golden-master: write ``actual`` if absent, else match the saved copy.""" + target = Path(os.path.realpath(path)) + if not target.exists(): + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(actual, indent=2, sort_keys=True), + encoding="utf-8") + return True + expected = json.loads(target.read_text(encoding="utf-8")) + return match_json(actual, expected).ok diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 93d00cf5..b8ca94ab 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3343,6 +3343,34 @@ def rate_limit_tools() -> List[MCPTool]: ] +def json_contract_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_match_json", + description=("Match 'actual' JSON against 'expected' with optional " + "'partial' (ignore extra keys) and 'match_type' " + "(type-only). Returns {ok, mismatches}."), + input_schema=schema( + {"actual": {"type": "object"}, "expected": {"type": "object"}, + "partial": {"type": "boolean"}, + "match_type": {"type": "boolean"}}, + ["actual", "expected"]), + handler=h.match_json, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_diff_json", + description=("Path-tagged diff between 'actual' and 'expected' JSON " + "(missing/extra/changed). Returns {diffs}."), + input_schema=schema( + {"actual": {"type": "object"}, "expected": {"type": "object"}}, + ["actual", "expected"]), + handler=h.diff_json, + annotations=READ_ONLY, + ), + ] + + def provenance_tools() -> List[MCPTool]: return [ MCPTool( @@ -4745,7 +4773,7 @@ def media_assert_tools() -> List[MCPTool]: jsonpath_tools, json_schema_tools, vuln_scan_tools, vex_tools, license_policy_tools, jwt_tools, rate_limit_tools, json_patch_tools, search_index_tools, stats_tools, recurrence_tools, text_diff_tools, - feature_flag_tools, provenance_tools, + feature_flag_tools, provenance_tools, json_contract_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 2c5509e5..9fc9dd58 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1664,6 +1664,17 @@ def rrule_next(rule, dtstart, now=None): return {"next": moment.isoformat() if moment else None} +def match_json(actual, expected, partial=False, match_type=False): + from je_auto_control.utils.json_contract import match_json as _match + return _match(actual, expected, partial=bool(partial), + match_type=bool(match_type)).to_dict() + + +def diff_json(actual, expected): + from je_auto_control.utils.json_contract import diff_json as _diff + return {"diffs": _diff(actual, expected)} + + 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_json_contract_batch.py b/test/unit_test/headless/test_json_contract_batch.py new file mode 100644 index 00000000..fc7715a3 --- /dev/null +++ b/test/unit_test/headless/test_json_contract_batch.py @@ -0,0 +1,104 @@ +"""Headless tests for JSON contract / snapshot matching. Pure stdlib, no Qt.""" +import json + +import je_auto_control as ac +from je_auto_control.utils.json_contract import ( + MatchReport, diff_json, match_json, normalize_json, snapshot) + + +def test_exact_match(): + report = match_json({"a": 1, "b": [1, 2]}, {"a": 1, "b": [1, 2]}) + assert isinstance(report, MatchReport) + assert report.ok is True and report.mismatches == [] + + +def test_changed_value(): + report = match_json({"a": 1, "b": 2}, {"a": 1, "b": 3}) + assert report.ok is False + assert report.mismatches[0]["path"] == "$.b" + assert report.mismatches[0]["kind"] == "changed" + + +def test_missing_and_extra(): + report = match_json({"a": 1, "c": 9}, {"a": 1, "b": 2}) + kinds = sorted(d["kind"] for d in report.mismatches) + assert kinds == ["extra", "missing"] + + +def test_partial_ignores_extra(): + assert match_json({"a": 1, "extra": 9}, {"a": 1}, partial=True).ok is True + assert match_json({"a": 1, "extra": 9}, {"a": 1}).ok is False + + +def test_match_type(): + assert match_json({"name": "Bob", "age": 40}, + {"name": "Alice", "age": 30}, match_type=True).ok is True + assert match_json({"age": "x"}, {"age": 30}, match_type=True).ok is False + + +def test_ignore_path(): + assert match_json({"a": 1, "ts": 999}, {"a": 1, "ts": 111}, + ignore=["$.ts"]).ok is True + + +def test_bool_distinct_from_int(): + assert match_json({"a": True}, {"a": 1}).ok is False + + +def test_nested_list_index_path(): + report = match_json({"u": {"tags": ["x", "Y"]}}, + {"u": {"tags": ["x", "y"]}}) + assert report.mismatches[0]["path"] == "$.u.tags[1]" + + +def test_diff_json_list_length(): + diffs = diff_json([1, 2], [1, 2, 3]) + assert diffs == [{"path": "$[2]", "kind": "missing", "expected": 3}] + + +def test_normalize_sorts_and_drops(): + assert normalize_json({"b": 2, "a": 1, "secret": "x"}, drop=["secret"]) == \ + {"a": 1, "b": 2} + + +def test_snapshot_create_then_compare(tmp_path): + path = str(tmp_path / "snap.json") + assert snapshot({"x": 1}, path) is True # created + assert snapshot({"x": 1}, path) is True # matches + assert snapshot({"x": 2}, path) is False # differs + + +# --- wiring --------------------------------------------------------------- + +def test_executor_round_trip(): + rec = ac.execute_action([[ + "AC_match_json", + {"actual": json.dumps({"a": 1, "b": 9}), + "expected": json.dumps({"a": 1, "b": 2})}, + ]]) + payload = next(v for v in rec.values() if isinstance(v, dict)) + assert payload["ok"] is False + + rec2 = ac.execute_action([[ + "AC_diff_json", + {"actual": json.dumps([1]), "expected": json.dumps([1, 2])}, + ]]) + diffs = next(v for v in rec2.values() if isinstance(v, dict))["diffs"] + assert diffs[0]["kind"] == "missing" + + +def test_wiring(): + assert {"AC_match_json", "AC_diff_json"} <= ac.executor.known_commands() + 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_match_json", "ac_diff_json"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + cmds = {s.command for s in _build_specs()} + assert {"AC_match_json", "AC_diff_json"} <= cmds + + +def test_facade_exports(): + for attr in ("match_json", "diff_json", "normalize_json", "snapshot", + "MatchReport"): + assert hasattr(ac, attr) + assert attr in ac.__all__ From 771f08871cfb30aad7dd3d2974ea37eda6fb9acf Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 21 Jun 2026 09:37:26 +0800 Subject: [PATCH 2/2] Rename JSON snapshot to snapshot_json to avoid facade name clash --- README.md | 2 +- README/README_zh-CN.md | 2 +- README/README_zh-TW.md | 2 +- docs/source/Eng/doc/new_features/v70_features_doc.rst | 6 +++--- docs/source/Zh/doc/new_features/v70_features_doc.rst | 6 +++--- je_auto_control/__init__.py | 4 ++-- je_auto_control/gui/script_builder/command_schema.py | 10 ++++++---- je_auto_control/utils/json_contract/__init__.py | 6 +++--- je_auto_control/utils/json_contract/json_contract.py | 2 +- test/unit_test/headless/test_json_contract_batch.py | 10 +++++----- 10 files changed, 26 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 7c27b295..b818fbdb 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ Match, diff and snapshot JSON payloads. Full reference: [`docs/source/Eng/doc/new_features/v70_features_doc.rst`](docs/source/Eng/doc/new_features/v70_features_doc.rst). -- **`match_json` / `diff_json` / `normalize_json` / `snapshot`** (`AC_match_json`, `AC_diff_json`): `json_schema` validates against an authored schema and `jsonpath` extracts, but nothing matched two payloads with relaxed rules or diffed them path-by-path. This adds contract/snapshot matching — `partial` (subset), `match_type` (Pact-style `like`), `ignore` volatile paths — returning `{path, kind}` mismatches (`missing`/`extra`/`changed`), plus golden-master `snapshot`. Composes with `json_schema` + `json_patch`; pure-stdlib. +- **`match_json` / `diff_json` / `normalize_json` / `snapshot_json`** (`AC_match_json`, `AC_diff_json`): `json_schema` validates against an authored schema and `jsonpath` extracts, but nothing matched two payloads with relaxed rules or diffed them path-by-path. This adds contract/snapshot matching — `partial` (subset), `match_type` (Pact-style `like`), `ignore` volatile paths — returning `{path, kind}` mismatches (`missing`/`extra`/`changed`), plus golden-master `snapshot_json`. Composes with `json_schema` + `json_patch`; pure-stdlib. ## What's new (2026-06-21) — SLSA Build Provenance diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index f1a7023c..2c16ab9e 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -126,7 +126,7 @@ 比对、取差异与快照 JSON 内容。完整参考:[`docs/source/Zh/doc/new_features/v70_features_doc.rst`](../docs/source/Zh/doc/new_features/v70_features_doc.rst)。 -- **`match_json` / `diff_json` / `normalize_json` / `snapshot`**(`AC_match_json`、`AC_diff_json`):`json_schema` 以撰写的 schema 验证、`jsonpath` 提取,但没有任何东西能以宽松规则比对两份内容或逐路径取差异。本功能补上合约/快照比对 —— `partial`(子集)、`match_type`(Pact 风格 `like`)、`ignore` 易变路径 —— 返回 `{path, kind}` 不符(`missing`/`extra`/`changed`),外加 golden-master `snapshot`。与 `json_schema` + `json_patch` 互补;纯标准库。 +- **`match_json` / `diff_json` / `normalize_json` / `snapshot_json`**(`AC_match_json`、`AC_diff_json`):`json_schema` 以撰写的 schema 验证、`jsonpath` 提取,但没有任何东西能以宽松规则比对两份内容或逐路径取差异。本功能补上合约/快照比对 —— `partial`(子集)、`match_type`(Pact 风格 `like`)、`ignore` 易变路径 —— 返回 `{path, kind}` 不符(`missing`/`extra`/`changed`),外加 golden-master `snapshot_json`。与 `json_schema` + `json_patch` 互补;纯标准库。 ## 本次更新 (2026-06-21) — SLSA 构建来源证明 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index b8ce3a62..190f9451 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -126,7 +126,7 @@ 比對、取差異與快照 JSON 內容。完整參考:[`docs/source/Zh/doc/new_features/v70_features_doc.rst`](../docs/source/Zh/doc/new_features/v70_features_doc.rst)。 -- **`match_json` / `diff_json` / `normalize_json` / `snapshot`**(`AC_match_json`、`AC_diff_json`):`json_schema` 以撰寫的 schema 驗證、`jsonpath` 擷取,但沒有任何東西能以寬鬆規則比對兩份內容或逐路徑取差異。本功能補上合約/快照比對 —— `partial`(子集)、`match_type`(Pact 風格 `like`)、`ignore` 易變路徑 —— 回傳 `{path, kind}` 不符(`missing`/`extra`/`changed`),外加 golden-master `snapshot`。與 `json_schema` + `json_patch` 互補;純標準函式庫。 +- **`match_json` / `diff_json` / `normalize_json` / `snapshot_json`**(`AC_match_json`、`AC_diff_json`):`json_schema` 以撰寫的 schema 驗證、`jsonpath` 擷取,但沒有任何東西能以寬鬆規則比對兩份內容或逐路徑取差異。本功能補上合約/快照比對 —— `partial`(子集)、`match_type`(Pact 風格 `like`)、`ignore` 易變路徑 —— 回傳 `{path, kind}` 不符(`missing`/`extra`/`changed`),外加 golden-master `snapshot_json`。與 `json_schema` + `json_patch` 互補;純標準函式庫。 ## 本次更新 (2026-06-21) — SLSA 建置來源證明 diff --git a/docs/source/Eng/doc/new_features/v70_features_doc.rst b/docs/source/Eng/doc/new_features/v70_features_doc.rst index 8cdea021..0d06e415 100644 --- a/docs/source/Eng/doc/new_features/v70_features_doc.rst +++ b/docs/source/Eng/doc/new_features/v70_features_doc.rst @@ -14,7 +14,7 @@ Headless API .. code-block:: python - from je_auto_control import match_json, diff_json, snapshot + from je_auto_control import match_json, diff_json, snapshot_json report = match_json(actual, {"id": 1, "name": "Ada"}) if not report.ok: @@ -29,7 +29,7 @@ Headless API match_json(response, template, ignore=["$.created_at", "$.id"]) diff_json(actual, expected) # [{path, kind, ...}] - snapshot(actual, "golden/checkout.json") # write-if-absent, else compare + snapshot_json(actual, "golden/checkout.json") # write-if-absent, else compare ``match_json`` returns a ``MatchReport(ok, mismatches)`` where each mismatch is ``{path, kind}`` with ``kind`` one of ``missing`` (in expected, absent from @@ -38,7 +38,7 @@ actual), ``extra`` (in actual, absent from expected), or ``changed``. Options: ``changed`` leaf whose types match (Pact ``like``), and ``ignore`` skips listed paths. ``diff_json`` is the raw path-tagged diff; ``normalize_json`` returns a canonical copy (sorted keys, ``drop`` keys removed) for stable comparison; -``snapshot`` is golden-master testing (writes the file on first run, then +``snapshot_json`` is golden-master testing (writes the file on first run, then matches against it). ``true`` stays distinct from ``1``. Executor commands diff --git a/docs/source/Zh/doc/new_features/v70_features_doc.rst b/docs/source/Zh/doc/new_features/v70_features_doc.rst index eb55bfef..c33f5ce8 100644 --- a/docs/source/Zh/doc/new_features/v70_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v70_features_doc.rst @@ -12,7 +12,7 @@ JSON 合約與快照比對 .. code-block:: python - from je_auto_control import match_json, diff_json, snapshot + from je_auto_control import match_json, diff_json, snapshot_json report = match_json(actual, {"id": 1, "name": "Ada"}) if not report.ok: @@ -27,13 +27,13 @@ JSON 合約與快照比對 match_json(response, template, ignore=["$.created_at", "$.id"]) diff_json(actual, expected) # [{path, kind, ...}] - snapshot(actual, "golden/checkout.json") # 不存在則寫入,否則比對 + snapshot_json(actual, "golden/checkout.json") # 不存在則寫入,否則比對 ``match_json`` 回傳 ``MatchReport(ok, mismatches)``,每個不符為 ``{path, kind}``,``kind`` 為 ``missing``(在 expected、actual 沒有)、``extra``(在 actual、expected 沒有)或 ``changed`` 之一。 選項:``partial`` 捨棄 ``extra`` 不符(子集比對),``match_type`` 接受型別相符的 ``changed`` 葉 (Pact ``like``),``ignore`` 略過列出的路徑。``diff_json`` 是原始的路徑標記差異;``normalize_json`` -回傳正規化副本(鍵排序、移除 ``drop`` 鍵)以利穩定比對;``snapshot`` 是 golden-master 測試 +回傳正規化副本(鍵排序、移除 ``drop`` 鍵)以利穩定比對;``snapshot_json`` 是 golden-master 測試 (首次執行寫檔,之後比對)。``true`` 與 ``1`` 保持相異。 執行器命令 diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index 6da082c0..0eab1026 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -358,7 +358,7 @@ ) # JSON contract / snapshot matching from je_auto_control.utils.json_contract import ( - MatchReport, diff_json, match_json, normalize_json, snapshot, + MatchReport, diff_json, match_json, normalize_json, snapshot_json, ) # Background popup/interrupt watchdog (unattended automation) from je_auto_control.utils.watchdog import ( @@ -852,7 +852,7 @@ def start_autocontrol_gui(*args, **kwargs): "percentage_bucket", "build_provenance", "subject_for", "subject_for_bytes", "verify_provenance", "write_provenance", - "MatchReport", "diff_json", "match_json", "normalize_json", "snapshot", + "MatchReport", "diff_json", "match_json", "normalize_json", "snapshot_json", # MCP server "AuditLogger", "HttpMCPServer", "MCPContent", "MCPPrompt", "MCPPromptArgument", "MCPResource", "MCPServer", "MCPTool", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index 780314c3..8e1c926b 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -1158,8 +1158,10 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: specs.append(CommandSpec( "AC_match_json", "Data", "JSON Contract: Match", fields=( - FieldSpec("actual", FieldType.STRING, placeholder='{"a": 1}'), - FieldSpec("expected", FieldType.STRING, placeholder='{"a": 1}'), + FieldSpec("actual", FieldType.STRING, + placeholder='{"id": 1, "name": "Ada"}'), + FieldSpec("expected", FieldType.STRING, + placeholder='{"id": 1, "name": "Ada"}'), FieldSpec("partial", FieldType.BOOL, optional=True, default=False), FieldSpec("match_type", FieldType.BOOL, optional=True, default=False), @@ -1169,8 +1171,8 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: specs.append(CommandSpec( "AC_diff_json", "Data", "JSON Contract: Diff", fields=( - FieldSpec("actual", FieldType.STRING, placeholder='{"a": 1}'), - FieldSpec("expected", FieldType.STRING, placeholder='{"a": 2}'), + FieldSpec("actual", FieldType.STRING, placeholder='[1, 2, 3]'), + FieldSpec("expected", FieldType.STRING, placeholder='[1, 2]'), ), description="Path-tagged diff between two JSON payloads; {diffs}.", )) diff --git a/je_auto_control/utils/json_contract/__init__.py b/je_auto_control/utils/json_contract/__init__.py index 3ad4af9c..0eac7362 100644 --- a/je_auto_control/utils/json_contract/__init__.py +++ b/je_auto_control/utils/json_contract/__init__.py @@ -1,8 +1,8 @@ -"""JSON contract / snapshot matching: match_json, diff_json, snapshot.""" +"""JSON contract / snapshot matching: match_json, diff_json, snapshot_json.""" from je_auto_control.utils.json_contract.json_contract import ( - MatchReport, diff_json, match_json, normalize_json, snapshot, + MatchReport, diff_json, match_json, normalize_json, snapshot_json, ) __all__ = [ - "MatchReport", "diff_json", "match_json", "normalize_json", "snapshot", + "MatchReport", "diff_json", "match_json", "normalize_json", "snapshot_json", ] diff --git a/je_auto_control/utils/json_contract/json_contract.py b/je_auto_control/utils/json_contract/json_contract.py index 8be3cbc8..f2ef1088 100644 --- a/je_auto_control/utils/json_contract/json_contract.py +++ b/je_auto_control/utils/json_contract/json_contract.py @@ -115,7 +115,7 @@ def normalize_json(value: Any, *, drop: Iterable[str] = ()) -> Any: return _normalize(value, set(drop)) -def snapshot(actual: Any, path: str) -> bool: +def snapshot_json(actual: Any, path: str) -> bool: """Golden-master: write ``actual`` if absent, else match the saved copy.""" target = Path(os.path.realpath(path)) if not target.exists(): diff --git a/test/unit_test/headless/test_json_contract_batch.py b/test/unit_test/headless/test_json_contract_batch.py index fc7715a3..306f556c 100644 --- a/test/unit_test/headless/test_json_contract_batch.py +++ b/test/unit_test/headless/test_json_contract_batch.py @@ -3,7 +3,7 @@ import je_auto_control as ac from je_auto_control.utils.json_contract import ( - MatchReport, diff_json, match_json, normalize_json, snapshot) + MatchReport, diff_json, match_json, normalize_json, snapshot_json) def test_exact_match(): @@ -63,9 +63,9 @@ def test_normalize_sorts_and_drops(): def test_snapshot_create_then_compare(tmp_path): path = str(tmp_path / "snap.json") - assert snapshot({"x": 1}, path) is True # created - assert snapshot({"x": 1}, path) is True # matches - assert snapshot({"x": 2}, path) is False # differs + assert snapshot_json({"x": 1}, path) is True # created + assert snapshot_json({"x": 1}, path) is True # matches + assert snapshot_json({"x": 2}, path) is False # differs # --- wiring --------------------------------------------------------------- @@ -98,7 +98,7 @@ def test_wiring(): def test_facade_exports(): - for attr in ("match_json", "diff_json", "normalize_json", "snapshot", + for attr in ("match_json", "diff_json", "normalize_json", "snapshot_json", "MatchReport"): assert hasattr(ac, attr) assert attr in ac.__all__