Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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_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

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).
Expand Down
7 changes: 7 additions & 0 deletions README/README_zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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应用与三方合并)
Expand Down Expand Up @@ -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_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 构建来源证明

证明构建产生了什么。完整参考:[`docs/source/Zh/doc/new_features/v69_features_doc.rst`](../docs/source/Zh/doc/new_features/v69_features_doc.rst)。
Expand Down
7 changes: 7 additions & 0 deletions README/README_zh-TW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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套用與三方合併)
Expand Down Expand Up @@ -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_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 建置來源證明

證明建置產生了什麼。完整參考:[`docs/source/Zh/doc/new_features/v69_features_doc.rst`](../docs/source/Zh/doc/new_features/v69_features_doc.rst)。
Expand Down
51 changes: 51 additions & 0 deletions docs/source/Eng/doc/new_features/v70_features_doc.rst
Original file line number Diff line number Diff line change
@@ -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_json

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_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
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_json`` 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**.
1 change: 1 addition & 0 deletions docs/source/Eng/eng_index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions docs/source/Zh/doc/new_features/v70_features_doc.rst
Original file line number Diff line number Diff line change
@@ -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_json

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_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_json`` 是 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** 分類下的命令提供。
1 change: 1 addition & 0 deletions docs/source/Zh/zh_index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions je_auto_control/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_json,
)
# Background popup/interrupt watchdog (unattended automation)
from je_auto_control.utils.watchdog import (
PopupWatchdog, WatchdogRule, default_popup_watchdog,
Expand Down Expand Up @@ -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_json",
# MCP server
"AuditLogger", "HttpMCPServer", "MCPContent", "MCPPrompt",
"MCPPromptArgument", "MCPResource", "MCPServer", "MCPTool",
Expand Down
21 changes: 21 additions & 0 deletions je_auto_control/gui/script_builder/command_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -1155,6 +1155,27 @@ 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='{"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),
),
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='[1, 2, 3]'),
FieldSpec("expected", FieldType.STRING, placeholder='[1, 2]'),
),
description="Path-tagged diff between two JSON payloads; {diffs}.",
))
specs.append(CommandSpec(
"AC_evaluate_flag", "Flow", "Feature Flag: Evaluate",
fields=(
Expand Down
26 changes: 26 additions & 0 deletions je_auto_control/utils/executor/action_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions je_auto_control/utils/json_contract/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""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_json,
)

__all__ = [
"MatchReport", "diff_json", "match_json", "normalize_json", "snapshot_json",
]
127 changes: 127 additions & 0 deletions je_auto_control/utils/json_contract/json_contract.py
Original file line number Diff line number Diff line change
@@ -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_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():
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
Loading
Loading