From 9027dc86d9260d9cf78dbe8dd10693b1c0f05e0d Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Sun, 21 Jun 2026 08:53:35 +0800 Subject: [PATCH] Add text diff apply and three-way merge difflib generates a unified diff but the stdlib cannot apply one, and there was no three-way merge. Add apply_unified (walks @@ hunks, verifies context, raises on mismatch) and a line-based three_way_merge (clean for non-overlapping edits, conflict markers otherwise). Complements json_patch for line-based text. Wired through the facade, AC_unified_diff/ AC_apply_unified/AC_three_way_merge 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/v67_features_doc.rst | 42 ++++++ docs/source/Eng/eng_index.rst | 1 + .../Zh/doc/new_features/v67_features_doc.rst | 37 +++++ docs/source/Zh/zh_index.rst | 1 + je_auto_control/__init__.py | 6 + .../gui/script_builder/command_schema.py | 25 ++++ .../utils/executor/action_executor.py | 23 +++ .../utils/mcp_server/tools/_factories.py | 36 ++++- .../utils/mcp_server/tools/_handlers.py | 17 +++ je_auto_control/utils/text_diff/__init__.py | 9 ++ je_auto_control/utils/text_diff/text_diff.py | 139 ++++++++++++++++++ .../headless/test_text_diff_batch.py | 90 ++++++++++++ 15 files changed, 446 insertions(+), 1 deletion(-) create mode 100644 docs/source/Eng/doc/new_features/v67_features_doc.rst create mode 100644 docs/source/Zh/doc/new_features/v67_features_doc.rst create mode 100644 je_auto_control/utils/text_diff/__init__.py create mode 100644 je_auto_control/utils/text_diff/text_diff.py create mode 100644 test/unit_test/headless/test_text_diff_batch.py diff --git a/README.md b/README.md index 90dca585..37b76c6b 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ ## Table of Contents +- [What's new (2026-06-21) — Text Diff, Patch & Three-Way Merge](#whats-new-2026-06-21--text-diff-patch--three-way-merge) - [What's new (2026-06-21) — Calendar Recurrence Rules (RRULE)](#whats-new-2026-06-21--calendar-recurrence-rules-rrule) - [What's new (2026-06-21) — Statistics & A/B Significance](#whats-new-2026-06-21--statistics--ab-significance) - [What's new (2026-06-21) — Full-Text Search (BM25)](#whats-new-2026-06-21--full-text-search-bm25) @@ -119,6 +120,12 @@ --- +## What's new (2026-06-21) — Text Diff, Patch & Three-Way Merge + +Apply and merge text diffs. Full reference: [`docs/source/Eng/doc/new_features/v67_features_doc.rst`](docs/source/Eng/doc/new_features/v67_features_doc.rst). + +- **`unified_diff` / `apply_unified` / `three_way_merge`** (`AC_unified_diff`, `AC_apply_unified`, `AC_three_way_merge`): `difflib` *generates* a unified diff but the stdlib can't *apply* one, and there was no three-way merge. This adds the missing applier (walks `@@` hunks, verifies context, raises on mismatch) and a line-based three-way merge (non-overlapping edits combine cleanly; overlapping ones emit `<<<<<<<` conflict markers). Complements `json_patch` (structured JSON); pure-stdlib `difflib`. + ## What's new (2026-06-21) — Calendar Recurrence Rules (RRULE) Schedule "every 2nd Tuesday". Full reference: [`docs/source/Eng/doc/new_features/v66_features_doc.rst`](docs/source/Eng/doc/new_features/v66_features_doc.rst). diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index f5a7bc83..c102275f 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -12,6 +12,7 @@ ## 目录 +- [本次更新 (2026-06-21) — 文本 Diff、应用与三方合并](#本次更新-2026-06-21--文本-diff应用与三方合并) - [本次更新 (2026-06-21) — 日历周期规则(RRULE)](#本次更新-2026-06-21--日历周期规则rrule) - [本次更新 (2026-06-21) — 统计与 A/B 显著性](#本次更新-2026-06-21--统计与-ab-显著性) - [本次更新 (2026-06-21) — 全文搜索(BM25)](#本次更新-2026-06-21--全文搜索bm25) @@ -118,6 +119,12 @@ --- +## 本次更新 (2026-06-21) — 文本 Diff、应用与三方合并 + +应用并合并文本 diff。完整参考:[`docs/source/Zh/doc/new_features/v67_features_doc.rst`](../docs/source/Zh/doc/new_features/v67_features_doc.rst)。 + +- **`unified_diff` / `apply_unified` / `three_way_merge`**(`AC_unified_diff`、`AC_apply_unified`、`AC_three_way_merge`):`difflib` 会*生成* unified diff,但标准库无法*应用*,也没有三方合并。本功能补上缺少的应用器(走访 `@@` 块、验证 context、不符即抛出)与以行为单位的三方合并(不重叠编辑干净合并;重叠则产生 `<<<<<<<` 冲突标记)。与 `json_patch`(结构化 JSON)互补;纯标准库 `difflib`。 + ## 本次更新 (2026-06-21) — 日历周期规则(RRULE) 排程「每月第 2 个星期二」。完整参考:[`docs/source/Zh/doc/new_features/v66_features_doc.rst`](../docs/source/Zh/doc/new_features/v66_features_doc.rst)。 diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 06bf9d46..89b8dade 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -12,6 +12,7 @@ ## 目錄 +- [本次更新 (2026-06-21) — 文字 Diff、套用與三方合併](#本次更新-2026-06-21--文字-diff套用與三方合併) - [本次更新 (2026-06-21) — 行事曆週期規則(RRULE)](#本次更新-2026-06-21--行事曆週期規則rrule) - [本次更新 (2026-06-21) — 統計與 A/B 顯著性](#本次更新-2026-06-21--統計與-ab-顯著性) - [本次更新 (2026-06-21) — 全文搜尋(BM25)](#本次更新-2026-06-21--全文搜尋bm25) @@ -118,6 +119,12 @@ --- +## 本次更新 (2026-06-21) — 文字 Diff、套用與三方合併 + +套用並合併文字 diff。完整參考:[`docs/source/Zh/doc/new_features/v67_features_doc.rst`](../docs/source/Zh/doc/new_features/v67_features_doc.rst)。 + +- **`unified_diff` / `apply_unified` / `three_way_merge`**(`AC_unified_diff`、`AC_apply_unified`、`AC_three_way_merge`):`difflib` 會*產生* unified diff,但標準函式庫無法*套用*,也沒有三方合併。本功能補上缺少的套用器(走訪 `@@` 區塊、驗證 context、不符即拋出)與以行為單位的三方合併(不重疊編輯乾淨合併;重疊則產生 `<<<<<<<` 衝突標記)。與 `json_patch`(結構化 JSON)互補;純標準函式庫 `difflib`。 + ## 本次更新 (2026-06-21) — 行事曆週期規則(RRULE) 排程「每月第 2 個星期二」。完整參考:[`docs/source/Zh/doc/new_features/v66_features_doc.rst`](../docs/source/Zh/doc/new_features/v66_features_doc.rst)。 diff --git a/docs/source/Eng/doc/new_features/v67_features_doc.rst b/docs/source/Eng/doc/new_features/v67_features_doc.rst new file mode 100644 index 00000000..4796ff15 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v67_features_doc.rst @@ -0,0 +1,42 @@ +Text Diff, Patch & Three-Way Merge +================================== + +``difflib`` *generates* a unified diff but the standard library cannot *apply* +one, and there was no three-way merge anywhere — so updating a ``.received`` +artifact, replaying a recorded text edit, or merging two edits of a base file +had no headless primitive. This adds the missing pieces. It complements +``utils/json_patch`` (structured JSON); this is line-based text. + +Pure standard library (``difflib`` + ``re``); imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import unified_diff, apply_unified, three_way_merge + + diff = unified_diff(original, edited) + restored = apply_unified(original, diff) # == edited + + merge = three_way_merge(base, ours, theirs) + if merge.clean: + save(merge.text) + else: + print(merge.conflicts, "conflict(s)") # text has <<<<<<< markers + +``unified_diff`` wraps ``difflib``; ``apply_unified`` is the missing applier — +it walks each ``@@`` hunk, verifies the context/removed lines match, and raises +``PatchApplyError`` on mismatch. ``three_way_merge`` merges line-based: +non-overlapping edits from each side combine cleanly; if both sides edit the +same region (and differ), it emits a conflict block with +``<<<<<<< / ======= / >>>>>>>`` markers and reports ``clean=False``. Trivial +cases (one side unchanged, or identical edits) resolve automatically. + +Executor commands +----------------- + +``AC_unified_diff`` (``{diff}``), ``AC_apply_unified`` (``{result}``) and +``AC_three_way_merge`` (``{text, clean, conflicts}``). Each is also exposed as +an MCP tool (``ac_unified_diff`` / ``ac_apply_unified`` / ``ac_three_way_merge``) +and as a Script Builder command under **Data**. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index 887c6887..7a29c2ba 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -89,6 +89,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v64_features_doc doc/new_features/v65_features_doc doc/new_features/v66_features_doc + doc/new_features/v67_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/v67_features_doc.rst b/docs/source/Zh/doc/new_features/v67_features_doc.rst new file mode 100644 index 00000000..f2a57f65 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v67_features_doc.rst @@ -0,0 +1,37 @@ +文字 Diff、套用與三方合併 +======================== + +``difflib`` 會*產生* unified diff,但標準函式庫無法*套用*它,而且各處都沒有三方合併 —— 因此更新 +``.received`` 產物、重播錄製的文字編輯,或合併對同一基底檔案的兩份編輯,都缺少無頭原語。本功能補上 +缺少的部分,與 ``utils/json_patch``(結構化 JSON)互補;這裡處理以行為單位的文字。 + +純標準函式庫(``difflib`` + ``re``);不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import unified_diff, apply_unified, three_way_merge + + diff = unified_diff(original, edited) + restored = apply_unified(original, diff) # == edited + + merge = three_way_merge(base, ours, theirs) + if merge.clean: + save(merge.text) + else: + print(merge.conflicts, "個衝突") # text 含 <<<<<<< 標記 + +``unified_diff`` 包裝 ``difflib``;``apply_unified`` 是缺少的套用器 —— 它逐一走訪每個 ``@@`` +區塊,驗證 context/移除行是否相符,不符時拋出 ``PatchApplyError``。``three_way_merge`` 以行為單位 +合併:兩側不重疊的編輯會乾淨合併;若兩側編輯同一區域(且不同),則產生帶有 +``<<<<<<< / ======= / >>>>>>>`` 標記的衝突區塊並回報 ``clean=False``。瑣碎情況(一側未變動,或兩側 +相同編輯)會自動解決。 + +執行器命令 +---------- + +``AC_unified_diff``(``{diff}``)、``AC_apply_unified``(``{result}``)與 +``AC_three_way_merge``(``{text, clean, conflicts}``)。每個亦以 MCP 工具(``ac_unified_diff`` / +``ac_apply_unified`` / ``ac_three_way_merge``)以及 Script Builder 中 **Data** 分類下的命令提供。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index cb2de039..e569ef4c 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -89,6 +89,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v64_features_doc doc/new_features/v65_features_doc doc/new_features/v66_features_doc + doc/new_features/v67_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 11eb757b..dba30819 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -342,6 +342,10 @@ from je_auto_control.utils.recurrence import ( Recurrence, next_occurrence, occurrences, parse_rrule, ) +# Unified-diff generate/apply + three-way text merge +from je_auto_control.utils.text_diff import ( + MergeResult, PatchApplyError, apply_unified, three_way_merge, unified_diff, +) # Background popup/interrupt watchdog (unattended automation) from je_auto_control.utils.watchdog import ( PopupWatchdog, WatchdogRule, default_popup_watchdog, @@ -828,6 +832,8 @@ def start_autocontrol_gui(*args, **kwargs): "chi_square_2x2", "cohens_d", "describe", "normal_cdf", "percentile", "two_proportion_z_test", "welch_t_test", "Recurrence", "next_occurrence", "occurrences", "parse_rrule", + "MergeResult", "PatchApplyError", "apply_unified", "three_way_merge", + "unified_diff", # 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 c0c92ec6..2255ef70 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -1155,6 +1155,31 @@ def _add_misc_specs(specs: List[CommandSpec]) -> None: ), description="Validate JSON against a JSON Schema; returns {ok, errors}.", )) + specs.append(CommandSpec( + "AC_unified_diff", "Data", "Text: Unified Diff", + fields=( + FieldSpec("a", FieldType.STRING, placeholder="original text"), + FieldSpec("b", FieldType.STRING, placeholder="changed text"), + ), + description="Unified diff transforming a into b; returns {diff}.", + )) + specs.append(CommandSpec( + "AC_apply_unified", "Data", "Text: Apply Diff", + fields=( + FieldSpec("text", FieldType.STRING, placeholder="original text"), + FieldSpec("diff", FieldType.STRING, placeholder="@@ -1 +1 @@ ..."), + ), + description="Apply a unified diff to text; returns {result}.", + )) + specs.append(CommandSpec( + "AC_three_way_merge", "Data", "Text: Three-Way Merge", + fields=( + FieldSpec("base", FieldType.STRING, placeholder="base text"), + FieldSpec("ours", FieldType.STRING, placeholder="our text"), + FieldSpec("theirs", FieldType.STRING, placeholder="their text"), + ), + description="Merge ours/theirs against base; returns {text, clean, conflicts}.", + )) specs.append(CommandSpec( "AC_rrule_occurrences", "Flow", "Recurrence: Expand (RRULE)", fields=( diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 52153a71..abd78074 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -2928,6 +2928,26 @@ def _rate_limit(name: str, rate: float = 1.0, capacity: float = 1.0, "wait": round(bucket.time_until_available(float(n)), 4)} +def _unified_diff(a: str, b: str) -> Dict[str, Any]: + """Adapter: unified diff transforming text a into b.""" + from je_auto_control.utils.text_diff import unified_diff + return {"diff": unified_diff(a, b)} + + +def _apply_unified(text: str, diff: str) -> Dict[str, Any]: + """Adapter: apply a unified diff to text.""" + from je_auto_control.utils.text_diff import apply_unified + return {"result": apply_unified(text, diff)} + + +def _three_way_merge(base: str, ours: str, theirs: str) -> Dict[str, Any]: + """Adapter: three-way merge ours/theirs against base.""" + from je_auto_control.utils.text_diff import three_way_merge + outcome = three_way_merge(base, ours, theirs) + return {"text": outcome.text, "clean": outcome.clean, + "conflicts": outcome.conflicts} + + def _rrule_occurrences(rule: str, dtstart: str, count: int = 10) -> Dict[str, Any]: """Adapter: expand an RRULE from an ISO dtstart into ISO datetimes.""" @@ -3834,6 +3854,9 @@ def __init__(self): "AC_ab_significance": _ab_significance, "AC_rrule_occurrences": _rrule_occurrences, "AC_rrule_next": _rrule_next, + "AC_unified_diff": _unified_diff, + "AC_apply_unified": _apply_unified, + "AC_three_way_merge": _three_way_merge, "AC_resolve_pointer": _resolve_pointer, "AC_apply_json_patch": _apply_json_patch, "AC_make_json_patch": _make_json_patch, diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index c1629936..0cd504a9 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3343,6 +3343,40 @@ def rate_limit_tools() -> List[MCPTool]: ] +def text_diff_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_unified_diff", + description="Unified diff transforming text 'a' into 'b'. " + "Returns {diff}.", + input_schema=schema( + {"a": {"type": "string"}, "b": {"type": "string"}}, ["a", "b"]), + handler=h.unified_diff, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_apply_unified", + description="Apply a unified 'diff' to 'text' (raises on context " + "mismatch). Returns {result}.", + input_schema=schema( + {"text": {"type": "string"}, "diff": {"type": "string"}}, + ["text", "diff"]), + handler=h.apply_unified, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_three_way_merge", + description="Three-way merge 'ours' and 'theirs' against 'base' " + "(line-based). Returns {text, clean, conflicts}.", + input_schema=schema( + {"base": {"type": "string"}, "ours": {"type": "string"}, + "theirs": {"type": "string"}}, ["base", "ours", "theirs"]), + handler=h.three_way_merge, + annotations=READ_ONLY, + ), + ] + + def recurrence_tools() -> List[MCPTool]: return [ MCPTool( @@ -4656,7 +4690,7 @@ def media_assert_tools() -> List[MCPTool]: process_mining_tools, asset_tools, events_tools, notify_channel_tools, 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, + search_index_tools, stats_tools, recurrence_tools, text_diff_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 10aeb4c1..1aa6dc5d 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -1664,6 +1664,23 @@ def rrule_next(rule, dtstart, now=None): return {"next": moment.isoformat() if moment else None} +def unified_diff(a, b): + from je_auto_control.utils.text_diff import unified_diff as _diff + return {"diff": _diff(a, b)} + + +def apply_unified(text, diff): + from je_auto_control.utils.text_diff import apply_unified as _apply + return {"result": _apply(text, diff)} + + +def three_way_merge(base, ours, theirs): + from je_auto_control.utils.text_diff import three_way_merge as _merge + outcome = _merge(base, ours, theirs) + return {"text": outcome.text, "clean": outcome.clean, + "conflicts": outcome.conflicts} + + def run_saga(steps): from je_auto_control.utils.saga import run_saga as _run result = _run(steps) diff --git a/je_auto_control/utils/text_diff/__init__.py b/je_auto_control/utils/text_diff/__init__.py new file mode 100644 index 00000000..3a582645 --- /dev/null +++ b/je_auto_control/utils/text_diff/__init__.py @@ -0,0 +1,9 @@ +"""Unified-diff generation, application and three-way text merge.""" +from je_auto_control.utils.text_diff.text_diff import ( + MergeResult, PatchApplyError, apply_unified, three_way_merge, unified_diff, +) + +__all__ = [ + "MergeResult", "PatchApplyError", "apply_unified", "three_way_merge", + "unified_diff", +] diff --git a/je_auto_control/utils/text_diff/text_diff.py b/je_auto_control/utils/text_diff/text_diff.py new file mode 100644 index 00000000..5868e074 --- /dev/null +++ b/je_auto_control/utils/text_diff/text_diff.py @@ -0,0 +1,139 @@ +"""Generate, apply and three-way-merge unified text diffs. + +``difflib`` *generates* a unified diff but the standard library cannot *apply* +one, and there is no three-way merge anywhere — so updating a ``.received`` +artifact, replaying a recorded text edit, or merging two edits of a base file +had no headless primitive. This adds the missing pieces. The complement, +``utils/json_patch``, covers structured JSON; this covers line-based text. + +Operates on lines split without keep-ends and rejoined with ``\\n`` (a trailing +newline is preserved); ``\\r\\n`` and missing-final-newline nuances are out of +scope. Pure standard library (``difflib`` + ``re``); imports no ``PySide6``. +""" +import difflib +import re +from dataclasses import dataclass +from typing import List, Tuple + +from je_auto_control.utils.exception.exceptions import AutoControlException + +_HUNK_RE = re.compile(r"^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@") + + +class PatchApplyError(AutoControlException): + """A unified diff could not be applied to the given text.""" + + +@dataclass(frozen=True) +class MergeResult: + """Outcome of a three-way merge.""" + + text: str + conflicts: int + clean: bool + + +def unified_diff(a: str, b: str, *, a_name: str = "a", b_name: str = "b", + context: int = 3) -> str: + """Return a unified diff transforming ``a`` into ``b``.""" + lines = difflib.unified_diff( + a.splitlines(), b.splitlines(), fromfile=a_name, tofile=b_name, + lineterm="", n=context) + return "\n".join(lines) + + +def _verify(source: List[str], index: int, expected: str) -> None: + if index >= len(source) or source[index] != expected: + raise PatchApplyError( + f"context mismatch at source line {index + 1}") + + +def _apply_hunk(source: List[str], out: List[str], cursor: int, + body: List[str]) -> int: + for line in body: + tag, content = line[:1], line[1:] + if tag == "+": + out.append(content) + elif tag == "-": + _verify(source, cursor, content) + cursor += 1 + else: # context line (leading space) + _verify(source, cursor, content) + out.append(source[cursor]) + cursor += 1 + return cursor + + +def apply_unified(text: str, diff: str) -> str: + """Apply a unified ``diff`` to ``text``; raise on context mismatch.""" + source = text.splitlines() + out: List[str] = [] + cursor = 0 + lines = diff.splitlines() + index = 0 + while index < len(lines): + match = _HUNK_RE.match(lines[index]) + if match is None: + index += 1 + continue + start = int(match.group(1)) - 1 + out.extend(source[cursor:start]) + cursor = max(cursor, start) + index += 1 + body = [] + while index < len(lines) and not lines[index].startswith("@@"): + if lines[index][:3] not in ("---", "+++"): + body.append(lines[index]) + index += 1 + cursor = _apply_hunk(source, out, cursor, body) + out.extend(source[cursor:]) + trailing = "\n" if text.endswith("\n") else "" + return "\n".join(out) + (trailing if out else "") + + +# --- three-way merge ------------------------------------------------------- + +def _changes(base: List[str], side: List[str]) -> List[Tuple[int, int, List[str]]]: + matcher = difflib.SequenceMatcher(None, base, side, autojunk=False) + return [(i1, i2, side[j1:j2]) + for tag, i1, i2, j1, j2 in matcher.get_opcodes() if tag != "equal"] + + +def _overlap(ours: List[Tuple], theirs: List[Tuple]) -> bool: + for o_lo, o_hi, _ in ours: + for t_lo, t_hi, _ in theirs: + if o_lo < t_hi and t_lo < o_hi: + return True + return False + + +def _conflict_block(ours: str, theirs: str, size: int) -> str: + head, mid, tail = "<" * size, "=" * size, ">" * size + return f"{head} ours\n{ours}\n{mid}\n{theirs}\n{tail} theirs\n" + + +def three_way_merge(base: str, ours: str, theirs: str, *, + marker_size: int = 7) -> MergeResult: + """Merge ``ours`` and ``theirs`` against ``base`` (line-based).""" + if ours == theirs: + return MergeResult(ours, conflicts=0, clean=True) + if ours == base: + return MergeResult(theirs, conflicts=0, clean=True) + if theirs == base: + return MergeResult(ours, conflicts=0, clean=True) + base_lines = base.splitlines() + ours_changes = _changes(base_lines, ours.splitlines()) + theirs_changes = _changes(base_lines, theirs.splitlines()) + if _overlap(ours_changes, theirs_changes): + return MergeResult( + _conflict_block(ours, theirs, marker_size), conflicts=1, clean=False) + merged: List[str] = [] + cursor = 0 + for low, high, replacement in sorted(ours_changes + theirs_changes): + merged.extend(base_lines[cursor:low]) + merged.extend(replacement) + cursor = high + merged.extend(base_lines[cursor:]) + trailing = "\n" if base.endswith("\n") else "" + return MergeResult("\n".join(merged) + (trailing if merged else ""), + conflicts=0, clean=True) diff --git a/test/unit_test/headless/test_text_diff_batch.py b/test/unit_test/headless/test_text_diff_batch.py new file mode 100644 index 00000000..6381fb6f --- /dev/null +++ b/test/unit_test/headless/test_text_diff_batch.py @@ -0,0 +1,90 @@ +"""Headless tests for unified-diff apply + three-way merge. Pure stdlib.""" +import je_auto_control as ac +from je_auto_control.utils.text_diff import ( + MergeResult, PatchApplyError, apply_unified, three_way_merge, unified_diff) + +import pytest + +A = "line1\nline2\nline3\n" +B = "line1\nCHANGED\nline3\nline4\n" + + +def test_unified_diff_format(): + diff = unified_diff(A, B) + assert "@@" in diff + assert "-line2" in diff and "+CHANGED" in diff and "+line4" in diff + + +def test_apply_unified_round_trip(): + assert apply_unified(A, unified_diff(A, B)) == B + + +def test_apply_unified_context_mismatch(): + with pytest.raises(PatchApplyError): + apply_unified("totally\ndifferent\ncontent\n", unified_diff(A, B)) + + +def test_apply_unified_no_op(): + assert apply_unified(A, unified_diff(A, A)) == A + + +BASE = "a\nb\nc\nd\ne\n" + + +def test_three_way_clean_non_overlapping(): + result = three_way_merge(BASE, "A\nb\nc\nd\ne\n", "a\nb\nc\nd\nE\n") + assert isinstance(result, MergeResult) + assert result.clean is True + assert result.text == "A\nb\nc\nd\nE\n" + + +def test_three_way_one_side_unchanged(): + assert three_way_merge(BASE, BASE, "a\nb\nC\nd\ne\n").text == "a\nb\nC\nd\ne\n" + assert three_way_merge(BASE, "A\nb\nc\nd\ne\n", BASE).text == "A\nb\nc\nd\ne\n" + + +def test_three_way_identical_changes(): + same = "A\nb\nc\nd\ne\n" + result = three_way_merge(BASE, same, same) + assert result.clean is True and result.text == same + + +def test_three_way_conflict(): + result = three_way_merge(BASE, "X\nb\nc\nd\ne\n", "Y\nb\nc\nd\ne\n") + assert result.clean is False + assert result.conflicts == 1 + assert "<<<<<<<" in result.text and ">>>>>>>" in result.text + + +# --- wiring --------------------------------------------------------------- + +def test_executor_round_trip(): + enc = ac.execute_action([["AC_unified_diff", {"a": A, "b": B}]]) + diff = next(v for v in enc.values() if isinstance(v, dict))["diff"] + dec = ac.execute_action([["AC_apply_unified", {"text": A, "diff": diff}]]) + assert next(v for v in dec.values() if isinstance(v, dict))["result"] == B + + merged = ac.execute_action([[ + "AC_three_way_merge", + {"base": BASE, "ours": "A\nb\nc\nd\ne\n", "theirs": "a\nb\nc\nd\nE\n"}, + ]]) + payload = next(v for v in merged.values() if isinstance(v, dict)) + assert payload["clean"] is True + + +def test_wiring(): + known = ac.executor.known_commands() + assert {"AC_unified_diff", "AC_apply_unified", "AC_three_way_merge"} <= 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_unified_diff", "ac_apply_unified", "ac_three_way_merge"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + cmds = {s.command for s in _build_specs()} + assert {"AC_unified_diff", "AC_apply_unified", "AC_three_way_merge"} <= cmds + + +def test_facade_exports(): + for attr in ("unified_diff", "apply_unified", "three_way_merge", + "MergeResult", "PatchApplyError"): + assert hasattr(ac, attr) + assert attr in ac.__all__