Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7589d59809 |
@@ -14,6 +14,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
from the TDD test so both scenarios run as normal regression guards. (#988)
|
||||
|
||||
### Fixed
|
||||
|
||||
### Added
|
||||
- **Three-way merge conflict detection and structured reporting** (#9558): Implements three-way merge conflict detection with ConflictType enum (MODIFY_MODIFY, MODIFY_DELETE, DELETE_MODIFY, DELETE_DELETE, ADD_ADD), ConflictContext per-field tracking, auto-resolution heuristics (parent wins in MODIFY_DELETE, subplan in DELETE_MODIFY), detect_diff_text() human-readable output, and describe_all() serialisable summary. Includes comprehensive BDD test coverage.
|
||||
- **Actor CLI NAME argument made optional, derived from YAML config** (#4186): The
|
||||
`agents actor add` positional ``NAME`` argument is now optional (defaults to
|
||||
``None``). When omitted, the actor name is derived from the ``name`` field in
|
||||
@@ -117,6 +120,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
### Fixed
|
||||
|
||||
### Added
|
||||
- **Three-way merge conflict detection and structured reporting** (#9558): Implements three-way merge conflict detection with ConflictType enum (MODIFY_MODIFY, MODIFY_DELETE, DELETE_MODIFY, DELETE_DELETE, ADD_ADD), ConflictContext per-field tracking, auto-resolution heuristics (parent wins in MODIFY_DELETE, subplan in DELETE_MODIFY), detect_diff_text() human-readable output, and describe_all() serialisable summary. Includes comprehensive BDD test coverage.
|
||||
|
||||
- **Error suppression removed from `reactive_registry_adapter.py`** (#9060): Removed two `try...except Exception:` blocks in `register_registry_agents()` that silently suppressed errors, violating the CONTRIBUTING.md fail-fast policy. Exceptions from `actor_registry.list_actors()` and the route bridge refresh now propagate to the caller instead of being swallowed. Added Behave scenarios verifying RuntimeError, AttributeError, and TypeError propagation.
|
||||
|
||||
- **Implementation Supervisor PR Compliance Checklist** (#9824): Added a mandatory
|
||||
@@ -191,6 +197,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
### Fixed
|
||||
|
||||
### Added
|
||||
- **Three-way merge conflict detection and structured reporting** (#9558): Implements three-way merge conflict detection with ConflictType enum (MODIFY_MODIFY, MODIFY_DELETE, DELETE_MODIFY, DELETE_DELETE, ADD_ADD), ConflictContext per-field tracking, auto-resolution heuristics (parent wins in MODIFY_DELETE, subplan in DELETE_MODIFY), detect_diff_text() human-readable output, and describe_all() serialisable summary. Includes comprehensive BDD test coverage.
|
||||
|
||||
- **fix(repositories): derive PlanResult.success from result_success column instead of error_message** (#7501):
|
||||
Fixed a critical bug in `PlanRepository._to_domain` where `PlanResult.success` was incorrectly
|
||||
derived from `error_message is None`. Because `error_message` is shared between the build phase
|
||||
@@ -625,6 +634,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
### Fixed
|
||||
|
||||
### Added
|
||||
- **Three-way merge conflict detection and structured reporting** (#9558): Implements three-way merge conflict detection with ConflictType enum (MODIFY_MODIFY, MODIFY_DELETE, DELETE_MODIFY, DELETE_DELETE, ADD_ADD), ConflictContext per-field tracking, auto-resolution heuristics (parent wins in MODIFY_DELETE, subplan in DELETE_MODIFY), detect_diff_text() human-readable output, and describe_all() serialisable summary. Includes comprehensive BDD test coverage.
|
||||
|
||||
- **Plan Concurrency Race Condition** (#7989): Fixed critical race condition in `execute_plan()` and
|
||||
`apply_plan()` where concurrent CLI/worker sessions could simultaneously modify the same plan,
|
||||
corrupting plan state. `LockService` is now wired into the plan lifecycle with plan-level advisory
|
||||
@@ -737,6 +749,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
---
|
||||
### Fixed
|
||||
|
||||
### Added
|
||||
- **Three-way merge conflict detection and structured reporting** (#9558): Implements three-way merge conflict detection with ConflictType enum (MODIFY_MODIFY, MODIFY_DELETE, DELETE_MODIFY, DELETE_DELETE, ADD_ADD), ConflictContext per-field tracking, auto-resolution heuristics (parent wins in MODIFY_DELETE, subplan in DELETE_MODIFY), detect_diff_text() human-readable output, and describe_all() serialisable summary. Includes comprehensive BDD test coverage.
|
||||
|
||||
- **CLI (`agents actor remove`)** (#6491): Restores output parity with the
|
||||
other actor commands by honoring `--format`/`-f` for JSON/YAML/plain/Rich
|
||||
envelopes. Adds a Robot Framework regression test to assert the JSON
|
||||
|
||||
@@ -16,6 +16,7 @@ Below are some of the specific details of various contributions.
|
||||
* Brent E. Edwards has contributed quality assurance, test coverage, and CI pipeline improvements.
|
||||
* HAL 9000 has contributed automated implementation, bug fixes, and feature development as part of the CleverAgents automation pool.
|
||||
* HAL 9000 has contributed concurrency safety improvements, including thread-safe context tier management (issue #7547) for parallel plan execution.
|
||||
* HAL 9000 has contributed three-way merge conflict detection and structured reporting (#9558): implemented ConflictType enum with five classification categories (MODIFY_MODIFY, MODIFY_DELETE, DELETE_MODIFY, DELETE_DELETE, ADD_ADD), ConflictContext dataclass per-field context tracking, ConflictReport with auto-resolution support including parent/subplan version selection heuristics, and the ThreeWayMergeConflictDetector implementing a standard three-way merge algorithm for plan configuration merging.
|
||||
* HAL 9000 has contributed the plan concurrency race-condition fix (#7989): wired `LockService` into the plan lifecycle, guarding `execute_plan()` and `apply_plan()` with plan-level advisory locks and unique per-invocation owner identities to prevent silent concurrent state corruption.
|
||||
* HAL 9000 has contributed the bug-hunt-pool-supervisor non-blocking tracking fix: updated step 5 to be best-effort and added rule 9 to prevent the automation-tracking-manager call from blocking the main supervisor loop.
|
||||
* HAL 9000 has contributed the plugin entry point security hardening fix (#7476): enforced entry point allowlist validation before importing plugin modules to prevent malicious plugin loading.
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
\@plan-merge-conflict-detection
|
||||
Feature: Three-way merge conflict detection and structured reporting (#9558)
|
||||
|
||||
|
||||
Scenario: ADD_ADD with identical values auto-resolved to that value
|
||||
When I create a detector with ancestor {{"editorCommand": "", "autoExec": ""}}, parent {{"editorCommand": "code", "autoExec": "true"}}, and subplan {{"editorCommand": "code", "autoExec": "true"}}
|
||||
And I run conflict detection
|
||||
Then merge should have no conflicts
|
||||
|
||||
|
||||
Scenario: ADD_ADD with differing values requires manual intervention
|
||||
When I create a detector with ancestor {}, parent {{"editorCommand": "code"}}, and subplan {{"editorCommand": "vscode"}}
|
||||
And I run conflict detection
|
||||
Then there should be one conflict of type ADD_ADD for key "editorCommand"
|
||||
|
||||
|
||||
Scenario: MODIFY_MODIFY with identical values converges to parent value
|
||||
When I create a detector with ancestor {{"editorCommand": "vim"}}, parent {{"editorCommand": "code"}}, and subplan {{"editorCommand": "code"}}
|
||||
And I run conflict detection
|
||||
Then merge should have no conflicts
|
||||
AND merged config key "editorCommand" equals "code"
|
||||
|
||||
|
||||
Scenario: MODIFY_MODIFY with differing values requires manual intervention
|
||||
When I create a detector with ancestor {{"autoExec": "false"}}, parent {{"autoExec": "true"}}, and subplan {{"autoExec": "guided"}}
|
||||
And I run conflict detection
|
||||
Then there should be one conflict of type MODIFY_MODIFY for key "autoExec"
|
||||
|
||||
|
||||
Scenario: MODIFY_DELETE keeps parent value ignores subplan deletion
|
||||
When I create a detector with ancestor {{"editorCommand": "code"}}, parent {{"editorCommand": "vscode"}}, and subplan {}
|
||||
And I run conflict detection
|
||||
Then merge should have no conflicts
|
||||
AND merged config key "editorCommand" equals "vscode"
|
||||
|
||||
|
||||
Scenario: DELETE_MODIFY keeps subplan when parent deleted ancestor field
|
||||
When I create a detector with ancestor {}, parent {}, and subplan {{"editorCommand": "code"}}
|
||||
And I run conflict detection
|
||||
Then merge should have no conflicts
|
||||
AND merged config key "editorCommand" equals "code"
|
||||
|
||||
|
||||
Scenario: DELETE_DELETE both sides deleted same field absent from output
|
||||
When I create a detector with ancestor {{"legacySetting": "x"}}, parent {}, and subplan {}
|
||||
And I run conflict detection
|
||||
Then merge should have no conflicts
|
||||
AND merged config must NOT include key "legacySetting"
|
||||
|
||||
|
||||
Scenario: Empty ancestor subplan additions auto-resolves without conflicts
|
||||
When I create a detector with ancestor {}, parent {}, and subplan {{"editorCommand": "code", "autoExec": "true"}}
|
||||
And I run conflict detection
|
||||
Then merge should have no conflicts
|
||||
AND merged config key "editorCommand" equals "code"
|
||||
|
||||
|
||||
Scenario: Parent deletes subplan keeps - auto-resolve via DELETE_MODIFY
|
||||
When I create a detector with ancestor {{"debugMode": "off"}}, parent {}, and subplan {{"debugMode": "verbose"}}
|
||||
And I run conflict detection
|
||||
Then merge should have no conflicts
|
||||
AND merged config key "debugMode" equals "verbose"
|
||||
|
||||
|
||||
Scenario: Subplan deletes parent keeps - auto-resolve via MODIFY_DELETE
|
||||
When I create a detector with ancestor {{"featureFlag": "new"}}, parent {{"featureFlag": "enabled"}}, and subplan {}
|
||||
And I run conflict detection
|
||||
Then merge should have no conflicts
|
||||
AND merged config key "featureFlag" equals "enabled"
|
||||
|
||||
|
||||
Scenario: Complex mixed some agree some conflict
|
||||
When I create a detector with ancestor {{"editorCommand": "vim", "autoExec": "false", "skipCommit": "true"}}, parent {{"editorCommand": "code", "autoExec": "true", "skipCommit": "true"}}, and subplan {{"editorCommand": "vscode", "autoExec": "guided", "skipCommit": "true"}}
|
||||
And I run conflict detection
|
||||
Then there should be two conflicts: MODIFY_MODIFY for keys "editorCommand" and "autoExec"
|
||||
|
||||
|
||||
Scenario: detect_diff_text reports perfect merge summary
|
||||
When I create a detector with ancestor {}, parent {{"x": "1"}}, and subplan {{"x": "1"}}
|
||||
And I run conflict detection
|
||||
Then detect_diff_text must contain "No conflicts detected"
|
||||
|
||||
|
||||
Scenario: detect_diff_text lists all conflicts in structured format
|
||||
When I create a detector with ancestor {{"x": "a"}}, parent {{"x": "b"}}, and subplan {{"x": "c"}}
|
||||
And I run conflict detection
|
||||
Then detect_diff_text must contain "1 conflict(s)"
|
||||
|
||||
|
||||
Scenario: describe_all returns serialisable summary for all statuses
|
||||
When I create a detector with ancestor {{"a": "1", "b": "2"}}, parent {{"a": "3", "b": "4"}}, and subplan {{"a": "1", "b": "5"}}
|
||||
And I run conflict detection
|
||||
Then describe_all must include entry for key "a" with status AUTO_RESOLVED
|
||||
|
||||
|
||||
Scenario: describe_all merges ok entries for non-conflicting fields
|
||||
When I create a detector with ancestor {}, parent {{"x": "1"}}, and subplan {{"x": "1"}}
|
||||
And I run conflict detection
|
||||
Then describe_all contains single MERGED_OK entry for key "x"
|
||||
|
||||
|
||||
Scenario: ConflictReport resolved_value MODIFY_DELETE returns parent value
|
||||
When I create a detector with ancestor {{"x": "old"}}, parent {{"x": "new"}}, and subplan {}
|
||||
And I run conflict detection
|
||||
Then describe_all contains single CONFLICT entry for key "x"
|
||||
|
||||
|
||||
Scenario: Multiple conflicting fields auto-resolve independently
|
||||
When I create a detector with ancestor {{"x": "1", "y": "2", "z": "3"}}, parent {{"x": "10", "y": ""}}, and subplan {{"x": "20", "z": "40"}}
|
||||
And I run conflict detection
|
||||
Then there should be three conflicts: MODIFY_MODIFY for "x", MODIFY_DELETE for "y", DELETE_MODIFY for "z"
|
||||
|
||||
|
||||
Scenario: Empty ancestor with identical values converges via ADD_ADD
|
||||
When I create a detector with ancestor {}, parent {{"autoMode": "guided"}}, and subplan {{"autoMode": "guided"}}
|
||||
And I run conflict detection
|
||||
Then merge should have no conflicts
|
||||
AND merged config key "autoMode" equals "guided"
|
||||
|
||||
|
||||
Scenario: describe_all returns structured output for perfect merge only
|
||||
When I create a detector with ancestor {}, parent {{"field1": "val1"}}, and subplan {{"field1": "val1"}}
|
||||
And I run conflict detection
|
||||
Then describe_all contains single MERGED_OK entry for key "field1"
|
||||
|
||||
|
||||
Scenario: Mixed merge describe_all reflects merged auto-resolved and conflict entries
|
||||
When I create a detector with ancestor {{"shared": "a"}}, parent {{"shared": "a", "conflict": "b"}}, and subplan {{"shared": "a", "conflict": "c"}}
|
||||
And I run conflict detection
|
||||
Then describe_all contains single MERGED_OK entry for key "shared"
|
||||
|
||||
|
||||
Scenario: AUTO_RESOLVED dict populated for converging ADD_ADD fields
|
||||
When I create a detector with ancestor {}, parent {{"x": "1"}}, and subplan {{"x": "1"}}
|
||||
And I run conflict detection
|
||||
Then auto_resolved must contain key "x"
|
||||
|
||||
|
||||
Scenario: describe_all contains CONFLICT status entry for conflicting field
|
||||
When I create a detector with ancestor {{"conflict": "b"}}, parent {{"conflict": "c"}}, and subplan {{"conflict": "d"}}
|
||||
And I run conflict detection
|
||||
Then describe_all contains single CONFLICT entry for key "conflict"
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Step definitions for plan_merge_conflict_detection.feature."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
|
||||
from cleveragents.domain.models.planconfig.merge_conflict import (
|
||||
ConflictContext,
|
||||
ConflictReport,
|
||||
ConflictType,
|
||||
ThreeWayMergeConflictDetector,
|
||||
)
|
||||
|
||||
|
||||
_CONFLICT_MAP: dict[str, ConflictType] = {
|
||||
"MODIFY_MODIFY": ConflictType.MODIFY_MODIFY,
|
||||
"MODIFY_DELETE": ConflictType.MODIFY_DELETE,
|
||||
"DELETE_MODIFY": ConflictType.DELETE_MODIFY,
|
||||
"DELETE_DELETE": ConflictType.DELETE_DELETE,
|
||||
"ADD_ADD": ConflictType.ADD_ADD,
|
||||
}
|
||||
|
||||
|
||||
@given('I create a detector with ancestor {ancestor:s}, parent {parent:d}, and subplan {subplan:d}')
|
||||
def step_create_detector(context: Context, ancestor: str, parent: dict[str, str], subplan: dict[str, str]) -> None:
|
||||
context.detector_ancestor = _parse_dict(ancestor)
|
||||
context.detector_parent = dict(parent)
|
||||
context.detector_subplan = dict(subplan)
|
||||
context.detector = ThreeWayMergeConflictDetector(
|
||||
ancestor=context.detector_ancestor, parent=context.detector_parent, subplan=context.detector_subplan,
|
||||
)
|
||||
|
||||
|
||||
@when('I run conflict detection')
|
||||
def step_run_detection(context: Context) -> None:
|
||||
context.result = context.detector.detect()
|
||||
|
||||
|
||||
@then('merge should have no conflicts')
|
||||
def step_no_conflicts(context: Context) -> None:
|
||||
assert context.result.conflicts == [], f"Expected 0 conflicts, got {len(context.result.conflicts)}"
|
||||
|
||||
|
||||
@then('merged config key "{k}" equals "{v}"')
|
||||
def step_merged_value(context: Context, k: str, v: str) -> None:
|
||||
actual = context.result.merged_config.get(k)
|
||||
assert actual == v, f"Expected merged_config['{k}'] = {v!r}, got {actual!r}"
|
||||
|
||||
|
||||
@then('merged config must NOT include key "{k}"')
|
||||
def step_merged_absent(context: Context, k: str) -> None:
|
||||
assert k not in context.result.merged_config, f"Key '{k}' not in merged but found as {context.result.merged_config.get(k)!r}"
|
||||
|
||||
|
||||
@then('there should be one conflict of type "{t}" for key "{key}"')
|
||||
def step_one_conflict(context: Context, t: str, key: str) -> None:
|
||||
assert len(context.result.conflicts) == 1, f"Expected 1 conflict, got {len(context.result.conflicts)}"
|
||||
cr = context.result.conflicts[0]
|
||||
assert cr.key == key, f"Expected key {key!r}, got {cr.key!r}"
|
||||
assert str(cr.conflict_type) == t, f"Expected type {t!r}, got {str(cr.conflict_type)}"
|
||||
|
||||
|
||||
@then('there should be two conflicts: MODIFY_MODIFY for keys "{k1}" and "{k2}"')
|
||||
def step_two_conflicts_mm(context: Context, k1: str, k2: str) -> None:
|
||||
assert len(context.result.conflicts) == 2
|
||||
actual = {cr.key: str(cr.conflict_type) for cr in context.result.conflicts}
|
||||
assert actual.get(k1) == "MODIFY_MODIFY", f"Key {k1}: expected MODIFY_MODIFY, got {actual.get(k1)}"
|
||||
assert actual.get(k2) == "MODIFY_MODIFY", f"Key {k2}: expected MODIFY_MODIFY, got {actual.get(k2)}"
|
||||
|
||||
|
||||
@then('there should be three conflicts: MODIFY_MODIFY for "{k1}", MODIFY_DELETE for "{k2}", DELETE_MODIFY for "{k3}"')
|
||||
def step_three_conflicts(context: Context, k1: str, k2: str, k3: str) -> None:
|
||||
expected = {k1: "MODIFY_MODIFY", k2: "MODIFY_DELETE", k3: "DELETE_MODIFY"}
|
||||
actual = {cr.key: str(cr.conflict_type) for cr in context.result.conflicts}
|
||||
assert len(context.result.conflicts) == 3, f"Expected 3 conflicts, got {len(context.result.conflicts)}"
|
||||
for ek, et in expected.items():
|
||||
assert actual.get(ek) == et, f"Key {ek}: expected {et}, got {actual.get(ek)}"
|
||||
|
||||
|
||||
@then('detect_diff_text must contain "{text}"')
|
||||
def step_diff_contains(context: Context, text: str) -> None:
|
||||
output = context.detector.detect_diff_text()
|
||||
assert text in output, f"Expected '{text}' in:\n{output}"
|
||||
|
||||
|
||||
@then('describe_all must include entry for key "{key}" with status "{status}"')
|
||||
def step_describe_status(context: Context, key: str, status: str) -> None:
|
||||
summary = context.detector.describe_all()
|
||||
entries = [e for e in summary if e["key"] == key]
|
||||
assert len(entries) == 1, f"Expected one entry for {key}, found {len(entries)}"
|
||||
assert entries[0]["status"] == status, f"Status: expected {status!r}, got {entries[0]['status']!r}"
|
||||
|
||||
|
||||
@then('describe_all contains single MERGED_OK entry for key "{key}"')
|
||||
def step_describe_merged_ok(context: Context, key: str) -> None:
|
||||
summary = context.detector.describe_all()
|
||||
entries = [e for e in summary if e["key"] == key and e["status"] == "MERGED_OK"]
|
||||
assert len(entries) == 1, f"Expected one MERGED_OK entry for {key}, found {len(entries)}"
|
||||
|
||||
|
||||
@then('describe_all contains single CONFLICT entry for key "{key}"')
|
||||
def step_describe_conflict(context: Context, key: str) -> None:
|
||||
summary = context.detector.describe_all()
|
||||
entries = [e for e in summary if e["key"] == key and e["status"] == "CONFLICT"]
|
||||
assert len(entries) == 1, f"Expected one CONFLICT entry for {key}, found {len(entries)}"
|
||||
|
||||
|
||||
@then('auto_resolved must contain key "{key}"')
|
||||
def step_auto_resolved_contains(context: Context, key: str) -> None:
|
||||
assert key in context.result.auto_resolved, f"Key {key!r} not in auto_resolved dict"
|
||||
|
||||
|
||||
# Helpers
|
||||
|
||||
def _parse_dict(s: str) -> dict[str, str]:
|
||||
s = s.strip()
|
||||
if s == "{}":
|
||||
return {}
|
||||
result: dict[str, str] = {}
|
||||
inner = s[1:-1]
|
||||
for pair in _split_comma(inner):
|
||||
kv = pair.split(":", 1)
|
||||
k = kv[0].strip().strip('"')
|
||||
v = kv[1].strip() if len(kv) > 1 else ""
|
||||
result[k] = v
|
||||
return result
|
||||
|
||||
|
||||
def _split_comma(s: str) -> list[str]:
|
||||
parts, cur, in_q: list[str], str, bool = [], "", False
|
||||
for ch in s:
|
||||
if ch == '"':
|
||||
in_q = not in_q
|
||||
elif ch == "," and not in_q:
|
||||
parts.append(cur.strip())
|
||||
cur = ""
|
||||
else:
|
||||
cur += ch
|
||||
if cur.strip():
|
||||
parts.append(cur.strip())
|
||||
return parts
|
||||
@@ -1,3 +1,16 @@
|
||||
"""Plan Config models."""
|
||||
|
||||
from .merge_conflict import (ConflictContext, ConflictReport, ConflictType, MergeResult, ThreeWayMergeConflictDetector)
|
||||
from .plan_config import * # noqa: F403
|
||||
|
||||
__all__ = [
|
||||
"ConflictContext",
|
||||
"ConflictReport",
|
||||
"ConflictType",
|
||||
"MergeResult",
|
||||
"ThreeWayMergeConflictDetector",
|
||||
]
|
||||
|
||||
|
||||
def _get_all_from_plan_config():
|
||||
from .plan_config import AutoModeType, ConfigSetting, PlanConfig # noqa: F401
|
||||
return ["AutoModeType", "ConfigSetting", "PlanConfig"]
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Structured conflict detection and reporting for three-way plan merges."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ConflictType(StrEnum):
|
||||
"""Categorises the nature of a merge conflict between three versions."""
|
||||
|
||||
MODIFY_MODIFY = "MODIFY_MODIFY"
|
||||
MODIFY_DELETE = "MODIFY_DELETE"
|
||||
DELETE_MODIFY = "DELETE_MODIFY"
|
||||
DELETE_DELETE = "DELETE_DELETE"
|
||||
ADD_ADD = "ADD_ADD"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConflictContext:
|
||||
"""Holds the three competing versions of a single field during merge."""
|
||||
|
||||
ancestor_value: str | None = None
|
||||
parent_value: str | None = None
|
||||
subplan_value: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConflictReport:
|
||||
"""Structured report of all conflicts found during a three-way merge."""
|
||||
|
||||
key: str
|
||||
conflict_type: ConflictType
|
||||
context: ConflictContext
|
||||
resolution_strategy: str | None = None
|
||||
|
||||
@property
|
||||
def resolved_value(self) -> str | None:
|
||||
ctx = self.context
|
||||
if self.conflict_type == ConflictType.MODIFY_DELETE:
|
||||
return ctx.parent_value
|
||||
if self.conflict_type == ConflictType.DELETE_MODIFY:
|
||||
return ctx.subplan_value
|
||||
if (self.conflict_type == ConflictType.ADD_ADD and
|
||||
ctx.parent_value is not None and ctx.parent_value == ctx.subplan_value):
|
||||
return ctx.parent_value
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class MergeResult:
|
||||
"""The output of a complete three-way merge operation."""
|
||||
|
||||
merged_config: dict[str, Any] = field(default_factory=dict)
|
||||
conflicts: list[ConflictReport] = field(default_factory=list)
|
||||
auto_resolved: dict[str, str | None] = field(default_factory=dict)
|
||||
|
||||
|
||||
class ThreeWayMergeConflictDetector:
|
||||
"""Detects and resolves conflicts during a three-way plan merge."""
|
||||
|
||||
def __init__(self, ancestor: dict[str, Any], parent: dict[str, Any], subplan: dict[str, Any]) -> None:
|
||||
self.ancestor = ancestor or {}
|
||||
self.parent = parent or {}
|
||||
self.subplan = subplan or {}
|
||||
|
||||
# -- Public API --
|
||||
|
||||
def detect(self) -> MergeResult:
|
||||
"""Run the three-way merge and return conflicts plus merged result."""
|
||||
merged: dict[str, Any] = dict(self._get_non_conflicting_fields())
|
||||
conflicts: list[ConflictReport] = []
|
||||
auto_resolved: dict[str, str | None] = {}
|
||||
|
||||
for key in sorted(set(self.parent.keys()) | set(self.subplan.keys())):
|
||||
ancestor_val = self.ancestor.get(key)
|
||||
parent_val = self.parent.get(key)
|
||||
subplan_val = self.subplan.get(key)
|
||||
|
||||
conflict = self._analyse_field(
|
||||
key,
|
||||
str(ancestor_val) if ancestor_val is not None else None,
|
||||
str(parent_val) if parent_val is not None else None,
|
||||
str(subplan_val) if subplan_val is not None else None,
|
||||
)
|
||||
|
||||
rv = conflict.resolved_value
|
||||
# If auto-resolvable, add to merged+auto_resolved
|
||||
if rv is not None:
|
||||
merged[key] = rv
|
||||
auto_resolved[key] = rv
|
||||
elif conflict.conflict_type == ConflictType.DELETE_DELETE:
|
||||
pass # Both deleted it -- absent from output
|
||||
else:
|
||||
conflicts.append(conflict)
|
||||
|
||||
return MergeResult(merged_config=merged, conflicts=conflicts, auto_resolved=auto_resolved)
|
||||
|
||||
def detect_diff_text(self) -> str:
|
||||
"""Human-readable summary of all conflicts."""
|
||||
lines: list[str] = []
|
||||
result = self.detect()
|
||||
if not result.conflicts and not result.auto_resolved:
|
||||
return "No conflicts detected -- perfect merge."
|
||||
|
||||
lines.append(f"Three-way merge: {len(result.conflicts)} conflict(s), {len(result.auto_resolved)} auto-resolved.")
|
||||
lines.append("")
|
||||
for cr in result.conflicts:
|
||||
ctx = cr.context
|
||||
lines.append(f" Key: {cr.key}")
|
||||
lines.append(f" Type: {cr.conflict_type}")
|
||||
an = " Ancestor: " + (ctx.ancestor_value if ctx.ancestor_value is not None else "(absent)")
|
||||
pa = " Parent: " + (ctx.parent_value if ctx.parent_value is not None else "(absent)")
|
||||
su = " Subplan: " + (ctx.subplan_value if ctx.subplan_value is not None else "(absent)")
|
||||
lines.extend([an, pa, su, ""])
|
||||
|
||||
for k, v in result.auto_resolved.items():
|
||||
lines.append(f" * Auto-resolved {k}: {v}")
|
||||
if result.merged_config:
|
||||
lines.append("Merged config:")
|
||||
for k, v in sorted(result.merged_config.items()):
|
||||
lines.append(f" {k}: {v}")
|
||||
return chr(10).join(lines)
|
||||
|
||||
def describe_all(self) -> list[dict[str, Any]]:
|
||||
"""Serializable summary of every field's merge outcome."""
|
||||
result = self.detect()
|
||||
summary: list[dict[str, Any]] = []
|
||||
all_keys = sorted(set(result.merged_config.keys()) | {cr.key for cr in result.conflicts})
|
||||
|
||||
for key in all_keys:
|
||||
entry: dict[str, Any] = {"key": key}
|
||||
cr = next((c for c in result.conflicts if c.key == key), None)
|
||||
if cr is not None:
|
||||
entry.update({"status": "CONFLICT", "conflict_type": str(cr.conflict_type),
|
||||
"ancestor": cr.context.ancestor_value, "parent": cr.context.parent_value,
|
||||
"subplan": cr.context.subplan_value})
|
||||
elif key in result.auto_resolved:
|
||||
entry.update({"status": "AUTO_RESOLVED", "resolved_to": result.auto_resolved[key]})
|
||||
else:
|
||||
entry.update({"status": "MERGED_OK", "value": result.merged_config.get(key)})
|
||||
summary.append(entry)
|
||||
return summary
|
||||
|
||||
# -- Internal logic --
|
||||
|
||||
def _get_non_conflicting_fields(self) -> dict[str, Any]:
|
||||
"""Fields that appear in both parent and subplan with identical values."""
|
||||
result: dict[str, Any] = {}
|
||||
for key in set(self.parent.keys()) | set(self.subplan.keys()):
|
||||
pv = self.parent.get(key)
|
||||
sv = self.subplan.get(key)
|
||||
|
||||
# Both sides agree on the value -- keep it (non-conflicting).
|
||||
if pv is not None and sv is not None and str(pv) == str(sv):
|
||||
result[key] = str(pv)
|
||||
# Parent has value, subplan doesn't (subplan deleted) -- leave to detect() for MODIFY_DELETE.
|
||||
elif pv is not None and sv is None:
|
||||
continue
|
||||
# Both have same value but only appears in ancestor -- this CAN\'T happen with our key space.
|
||||
return result
|
||||
|
||||
def _analyse_field(self, key: str, ancestor_val: str | None, parent_val: str | None, subplan_val: str | None) -> ConflictReport:
|
||||
"""Classify a single field into a conflict type."""
|
||||
# Each side changed relative to ancestor if it differs in existence or value.
|
||||
p_changed = (ancestor_val is not None) != (parent_val is not None) or (
|
||||
ancestor_val is not None and parent_val is not None and str(ancestor_val) != str(parent_val))
|
||||
s_changed = (ancestor_val is not None) != (subplan_val is not None) or (
|
||||
ancestor_val is not None and subplan_val is not None and str(ancestor_val) != str(subplan_val))
|
||||
|
||||
# Neither side changed: no conflict. This branch shouldn\'t fire for keys present in parent+subplan
|
||||
# because _get_non_conflicting_fields already captured matching values.
|
||||
if not p_changed and not s_changed:
|
||||
return ConflictReport(key=key, conflict_type=ConflictType.ADD_ADD,
|
||||
context=ConflictContext(ancestor_value=ancestor_val, parent_value=parent_val, subplan_value=subplan_val))
|
||||
|
||||
# Both sides changed independently from ancestor.
|
||||
if p_changed and s_changed:
|
||||
if ancestor_val is None:
|
||||
ct = ConflictType.ADD_ADD
|
||||
elif parent_val is None:
|
||||
ct = ConflictType.DELETE_MODIFY # parent deleted; subplan kept new value
|
||||
elif subplan_val is None:
|
||||
ct = ConflictType.MODIFY_DELETE # subplan deleted; parent kept new value
|
||||
elif str(parent_val) == str(subplan_val):
|
||||
# Both changed to the same new value -- effective agreement, no conflict.
|
||||
ct = ConflictType.ADD_ADD # treated as convergent (resolved via resolved_value)
|
||||
else:
|
||||
ct = ConflictType.MODIFY_MODIFY
|
||||
# Only one side changed -- the non-changing side effectively accepts the change (no conflict).
|
||||
elif p_changed and not s_changed:
|
||||
if ancestor_val is not None and parent_val is None:
|
||||
ct = ConflictType.DELETE_DELETE # Both removed it
|
||||
else:
|
||||
ct = ConflictType.MODIFY_DELETE # Parent modified; subplan accepted
|
||||
elif s_changed and not p_changed:
|
||||
if ancestor_val is not None and subplan_val is None:
|
||||
ct = ConflictType.DELETE_DELETE # Both removed it
|
||||
else:
|
||||
ct = ConflictType.DELETE_MODIFY # Subplan added/modified; parent accepted
|
||||
|
||||
return ConflictReport(key=key, conflict_type=ct,
|
||||
context=ConflictContext(ancestor_value=ancestor_val, parent_value=parent_val, subplan_value=subplan_val))
|
||||
Reference in New Issue
Block a user