From 70387cbea309342778b8c0aa1f27eb29777436c6 Mon Sep 17 00:00:00 2001 From: LoopDesk Date: Wed, 28 Oct 2026 11:24:53 +0800 Subject: [PATCH 1/7] feat(capability): introduce opt-in anti-entropy code governance Signed-off-by: LoopDesk --- examples/anti-entropy-governance-smoke.py | 102 +++++ loopx/capabilities/anti_entropy/README.md | 71 +++ loopx/capabilities/anti_entropy/__init__.py | 31 ++ .../anti_entropy/catalog_entry.py | 82 ++++ loopx/capabilities/anti_entropy/cli.py | 38 ++ loopx/capabilities/anti_entropy/contract.py | 144 +++++++ loopx/capabilities/anti_entropy/oracles.py | 378 ++++++++++++++++ loopx/capabilities/catalog.py | 2 + loopx/cli.py | 16 + loopx/cli_commands/anti_entropy.py | 127 ++++++ tests/capabilities/test_anti_entropy.py | 403 ++++++++++++++++++ .../test_capability_extension_registry.py | 1 + 12 files changed, 1395 insertions(+) create mode 100755 examples/anti-entropy-governance-smoke.py create mode 100644 loopx/capabilities/anti_entropy/README.md create mode 100644 loopx/capabilities/anti_entropy/__init__.py create mode 100644 loopx/capabilities/anti_entropy/catalog_entry.py create mode 100644 loopx/capabilities/anti_entropy/cli.py create mode 100644 loopx/capabilities/anti_entropy/contract.py create mode 100644 loopx/capabilities/anti_entropy/oracles.py create mode 100644 loopx/cli_commands/anti_entropy.py create mode 100644 tests/capabilities/test_anti_entropy.py diff --git a/examples/anti-entropy-governance-smoke.py b/examples/anti-entropy-governance-smoke.py new file mode 100755 index 0000000000..0f8ab206b4 --- /dev/null +++ b/examples/anti-entropy-governance-smoke.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Smoke test for anti-entropy governance: AST-based delete-first verification and diff hygiene.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) + +from loopx.capabilities.anti_entropy.cli import run_anti_entropy_verify +from loopx.capabilities.anti_entropy.contract import ( + ANTI_ENTROPY_CAPABILITY_ID, + RetirementBasis, +) +from loopx.capabilities.anti_entropy.oracles import evaluate_diff_hygiene + + +def test_clean_delete_first() -> None: + diff = """ +--- a/calculator.py ++++ b/calculator.py +@@ -1,5 +1,5 @@ +-def old_add(a: int, b: int) -> int: +- return a + b ++def new_add(a: int, b: int) -> int: ++ return a + b +""" + basis = RetirementBasis( + target_symbols=("old_add",), + rationale="Replaced by new_add", + ) + receipt = evaluate_diff_hygiene(diff, basis) + assert receipt.verdict == "passed", f"expected passed, got {receipt.verdict}" + assert "old_add" in receipt.retired_symbols + + +def test_delete_first_violation() -> None: + diff = """ +--- a/calculator.py ++++ b/calculator.py +@@ -1,3 +1,5 @@ ++def new_add(a: int, b: int) -> int: ++ return a + b + def old_add(a: int, b: int) -> int: + return a + b +""" + basis = RetirementBasis( + target_symbols=("old_add",), + allow_compat_seam=False, + ) + receipt = evaluate_diff_hygiene(diff, basis) + assert receipt.verdict == "rejected", f"expected rejected, got {receipt.verdict}" + assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt.findings) + + +def test_zombie_reintroduced() -> None: + diff = """ +--- a/calculator.py ++++ b/calculator.py +@@ -1,3 +1,3 @@ +-def legacy_sink(): ++def legacy_sink(): # wrapper + pass +""" + basis = RetirementBasis(target_symbols=("legacy_sink",)) + receipt = evaluate_diff_hygiene(diff, basis) + assert receipt.verdict == "rejected", f"expected rejected, got {receipt.verdict}" + assert any(f.rule_id == "AE002-ZOMBIE_SYMBOL_REINTRODUCED" for f in receipt.findings) + + +def test_cli_runner() -> None: + diff = """ +--- a/module.py ++++ b/module.py +@@ -1,2 +1,2 @@ +-class OldService: ++class NewService: + pass +""" + payload = run_anti_entropy_verify( + diff_text=diff, + basis_data={"target_symbols": ["OldService"]}, + goal_id="test-goal-1", + ) + assert payload["ok"] is True + assert payload["capability_id"] == ANTI_ENTROPY_CAPABILITY_ID + assert "OldService" in payload["retired_symbols"] + + +def main() -> int: + test_clean_delete_first() + test_delete_first_violation() + test_zombie_reintroduced() + test_cli_runner() + print("anti-entropy-governance-smoke: ok") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/loopx/capabilities/anti_entropy/README.md b/loopx/capabilities/anti_entropy/README.md new file mode 100644 index 0000000000..5abac687b4 --- /dev/null +++ b/loopx/capabilities/anti_entropy/README.md @@ -0,0 +1,71 @@ +# Anti-Entropy & Code Retirement Governance + +Anti-Entropy Governance provides LoopX with an automated, deterministic +code-retirement and diff-hygiene verification contract. It is **default-off** +and designed to support agent-driven refactoring and feature decommissioning workflows. + +## Overview + +When autonomous agents perform refactoring or feature replacement tasks, they +frequently exhibit an "entropy growth" anti-pattern: layering yet another wrapper +or fallback branch without safely deleting superseded implementations. Over +long-horizon tasks, this results in accumulated ghost code, redundant complexity, +and bloated execution contexts. + +The Anti-Entropy capability addresses this through: + +1. **AST-Based Delete-First Enforcement (`verify_delete_first`)**: + Uses Python's syntax tree parser to verify that declared retirement targets + (functions and classes) are genuinely removed from the codebase rather than + merely bypassed or wrapped. +2. **Zombie Symbol Prevention**: + Detects if a symbol marked for retirement is reintroduced as a new definition + in newly added code lines. +3. **Target Paths Verification**: + Ensures that declared legacy file paths slated for deletion are completely + removed in the unified diff (`/dev/null`). +4. **Ghost Code Auditing (`audit_ghost_code`)**: + Identifies commented-out code blocks left behind, surfacing them as advisory + quality findings without blocking legitimate documentation or explanatory comments. +5. **Durable Receipt Generation (`AntiEntropyReceipt`)**: + Runs deterministic hygiene oracles against the exact git diff and produces + an immutable receipt recording verified retired symbols and findings. + +## Attribution & Acknowledgments + +This capability adopts and operationalizes the core engineering principles of +**delete-first retirement**, **ghost code prevention**, and **minimality verification** +inspired by the **Aegis** engineering methodology. + +## Usage + +Inspect the capability and its registered protocols: + +```bash +loopx capability show anti-entropy-governance --format json +``` + +Verify diff hygiene against a declared retirement basis: + +```bash +loopx anti-entropy verify --goal-id --diff-file --basis-json --format json +``` + +Example `basis.json`: + +```json +{ + "target_symbols": ["old_deprecated_calculator"], + "target_paths": ["legacy/old_module.py"], + "allow_compat_seam": false, + "rationale": "Superseded by modern_calculator implementation" +} +``` + +## Boundaries + +- The capability is default-off and operates as an opt-in code hygiene verifier via CLI and typed receipts. +- Delete-first requires target old logic to be cleanly decommissioned in the diff without re-introducing wrappers or zombie symbols. +- AST-based diff hygiene verifies exact code diffs without mutating repository code or performing speculative git writes. +- Target paths declared for decommissioning must be cleanly removed in the unified diff. +- Advisory findings highlight non-blocking hygiene opportunities (such as dead commented code) without causing false positive rejections of documentation or explanations. diff --git a/loopx/capabilities/anti_entropy/__init__.py b/loopx/capabilities/anti_entropy/__init__.py new file mode 100644 index 0000000000..5b004cef88 --- /dev/null +++ b/loopx/capabilities/anti_entropy/__init__.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from .catalog_entry import ANTI_ENTROPY_CATALOG_ENTRY +from .cli import run_anti_entropy_verify +from .contract import ( + ANTI_ENTROPY_CAPABILITY_ID, + ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION, + ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION, + AntiEntropyFinding, + AntiEntropyReceipt, + RetirementBasis, +) +from .oracles import ( + audit_ghost_code, + evaluate_diff_hygiene, + verify_delete_first, +) + +__all__ = [ + "ANTI_ENTROPY_CAPABILITY_ID", + "ANTI_ENTROPY_CATALOG_ENTRY", + "ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION", + "ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION", + "AntiEntropyFinding", + "AntiEntropyReceipt", + "RetirementBasis", + "audit_ghost_code", + "evaluate_diff_hygiene", + "run_anti_entropy_verify", + "verify_delete_first", +] diff --git a/loopx/capabilities/anti_entropy/catalog_entry.py b/loopx/capabilities/anti_entropy/catalog_entry.py new file mode 100644 index 0000000000..b3b1858a3a --- /dev/null +++ b/loopx/capabilities/anti_entropy/catalog_entry.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from typing import Any + +from .contract import ( + ANTI_ENTROPY_CAPABILITY_ID, + ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION, + ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION, +) + +_README = "loopx/capabilities/anti_entropy/README.md" + +ANTI_ENTROPY_CATALOG_ENTRY: dict[str, Any] = { + "id": ANTI_ENTROPY_CAPABILITY_ID, + "origin": "builtin", + "visibility": "public", + "provider_id": "loopx-core", + "documentation": { + "source_root": "loopx/capabilities/anti_entropy", + "site_root": "capabilities/anti-entropy-governance", + "canonical": "README.md", + }, + "title": "Anti-entropy and code retirement governance", + "status": "experimental", + "default_enabled": False, + "real_world_anchor": ( + "provider-neutral AST-based delete-first verification and diff hygiene for agent refactoring" + ), + "user_value": ( + "Verify that declared retirement targets are cleanly deleted without " + "re-introducing wrappers, zombie symbols, or ghost code." + ), + "entry_command": ( + "loopx anti-entropy verify --goal-id --diff-file --format json" + ), + "workflow_skill": { + "name": "loopx-anti-entropy", + "delivery": "standalone_cli", + "activation": "explicit_command_execution", + "project_copy_required": False, + }, + "commands": [ + { + "command": "loopx anti-entropy verify --goal-id --diff-file --format json", + "purpose": "Verify exact diff hygiene and delete-first compliance against a declared retirement basis.", + "write_boundary": "read-only diff verification and receipt generation; no repository mutation", + }, + { + "command": "loopx capability show anti-entropy-governance --format json", + "purpose": "Inspect anti-entropy capability metadata, commands, and protocols.", + "write_boundary": "read-only metadata projection", + }, + ], + "implemented_protocols": [ + { + "schema_version": ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION, + "module": "loopx.capabilities.anti_entropy.contract", + "doc": _README, + }, + { + "schema_version": ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION, + "module": "loopx.capabilities.anti_entropy.contract", + "doc": _README, + }, + ], + "smokes": [ + "python3 examples/anti-entropy-governance-smoke.py", + "python3 -m pytest tests/capabilities/test_anti_entropy.py -q", + ], + "docs": [_README], + "boundaries": [ + "The capability is default-off and operates as an opt-in code hygiene verifier via CLI and typed receipts.", + "Delete-first requires target old logic to be cleanly decommissioned in the diff without re-introducing wrappers or zombie symbols.", + "AST-based diff hygiene verifies exact code diffs without mutating repository code or performing speculative git writes.", + "Target paths declared for decommissioning must be cleanly removed in the unified diff.", + "Advisory findings highlight non-blocking hygiene opportunities (such as dead commented code) without causing false positive rejections of documentation or explanations.", + "Inspired by and adopting core engineering principles of delete-first code retirement from Aegis.", + ], + "next_real_step": ( + "Run verification: loopx anti-entropy verify --diff-file --basis-json " + ), +} diff --git a/loopx/capabilities/anti_entropy/cli.py b/loopx/capabilities/anti_entropy/cli.py new file mode 100644 index 0000000000..883ec0eb9a --- /dev/null +++ b/loopx/capabilities/anti_entropy/cli.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from .contract import ( + ANTI_ENTROPY_CAPABILITY_ID, + RetirementBasis, +) +from .oracles import evaluate_diff_hygiene + + +def run_anti_entropy_verify( + *, + diff_text: str, + basis_data: Any = None, + goal_id: str | None = None, +) -> dict[str, Any]: + """Verify exact diff hygiene and delete-first compliance, returning structured payload.""" + basis = RetirementBasis.from_dict(basis_data) if basis_data is not None else None + receipt = evaluate_diff_hygiene(diff_text, basis) + + blocking_count = sum(1 for f in receipt.findings if f.severity == "blocking") + advisory_count = sum(1 for f in receipt.findings if f.severity == "advisory") + + return { + "ok": receipt.verdict == "passed", + "command": "verify", + "capability_id": ANTI_ENTROPY_CAPABILITY_ID, + "goal_id": goal_id, + "verdict": receipt.verdict, + "receipt": receipt.to_dict(), + "finding_count": len(receipt.findings), + "blocking_finding_count": blocking_count, + "advisory_finding_count": advisory_count, + "retired_symbols": list(receipt.retired_symbols), + } diff --git a/loopx/capabilities/anti_entropy/contract.py b/loopx/capabilities/anti_entropy/contract.py new file mode 100644 index 0000000000..0c94c60bb8 --- /dev/null +++ b/loopx/capabilities/anti_entropy/contract.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import hashlib +import time +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any + +ANTI_ENTROPY_CAPABILITY_ID = "anti-entropy-governance" +ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION = "loopx_anti_entropy_receipt_v0" +ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION = "loopx_anti_entropy_retirement_basis_v0" + + +@dataclass(frozen=True, slots=True) +class RetirementBasis: + """Declared code symbols and paths slated for safe decommissioning.""" + + schema_version: str = ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION + target_symbols: tuple[str, ...] = () + target_paths: tuple[str, ...] = () + allow_compat_seam: bool = False + rationale: str = "" + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "target_symbols": list(self.target_symbols), + "target_paths": list(self.target_paths), + "allow_compat_seam": self.allow_compat_seam, + "rationale": self.rationale, + } + + @classmethod + def from_dict(cls, data: Any) -> RetirementBasis: + if not isinstance(data, Mapping): + raise TypeError( + f"RetirementBasis data must be a JSON object (mapping), got {type(data).__name__}" + ) + + schema_version = data.get("schema_version") + if schema_version is not None and not isinstance(schema_version, str): + raise TypeError( + f"schema_version must be a string, got {type(schema_version).__name__}" + ) + + symbols_raw = data.get("target_symbols") + target_symbols: list[str] = [] + if symbols_raw is not None: + if not isinstance(symbols_raw, (list, tuple, set)): + raise TypeError( + f"target_symbols must be a list of strings, got {type(symbols_raw).__name__}" + ) + for s in symbols_raw: + if not isinstance(s, str): + raise TypeError( + f"target_symbols elements must be strings, got {type(s).__name__}" + ) + clean_s = s.strip() + if clean_s: + target_symbols.append(clean_s) + + paths_raw = data.get("target_paths") + target_paths: list[str] = [] + if paths_raw is not None: + if not isinstance(paths_raw, (list, tuple, set)): + raise TypeError( + f"target_paths must be a list of strings, got {type(paths_raw).__name__}" + ) + for p in paths_raw: + if not isinstance(p, str): + raise TypeError( + f"target_paths elements must be strings, got {type(p).__name__}" + ) + clean_p = p.strip() + if clean_p: + target_paths.append(clean_p) + + allow_compat = data.get("allow_compat_seam", False) + if not isinstance(allow_compat, bool): + raise TypeError( + f"allow_compat_seam must be a boolean, got {type(allow_compat).__name__} (value: {allow_compat!r})" + ) + + rationale = data.get("rationale", "") + if rationale is not None and not isinstance(rationale, str): + raise TypeError( + f"rationale must be a string, got {type(rationale).__name__}" + ) + + return cls( + schema_version=str( + schema_version or ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION + ), + target_symbols=tuple(target_symbols), + target_paths=tuple(target_paths), + allow_compat_seam=allow_compat, + rationale=str(rationale or ""), + ) + + +@dataclass(frozen=True, slots=True) +class AntiEntropyFinding: + """One actionable defect where entropy or ghost code was observed.""" + + rule_id: str + severity: str # "blocking" | "advisory" + message: str + location: str | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "rule_id": self.rule_id, + "severity": self.severity, + "message": self.message, + "location": self.location, + } + + +@dataclass(frozen=True, slots=True) +class AntiEntropyReceipt: + """Durable receipt verifying diff hygiene and delete-first enforcement.""" + + schema_version: str = ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION + receipt_id: str = field(default="") + diff_hash: str = field(default="") + verdict: str = "passed" # "passed" | "rejected" + findings: tuple[AntiEntropyFinding, ...] = () + retired_symbols: tuple[str, ...] = () + timestamp_utc: float = field(default_factory=time.time) + + def to_dict(self) -> dict[str, Any]: + receipt_id = self.receipt_id + if not receipt_id: + payload = f"{self.diff_hash}:{self.verdict}:{self.timestamp_utc}" + receipt_id = f"aer_{hashlib.sha256(payload.encode()).hexdigest()[:16]}" + return { + "schema_version": self.schema_version, + "receipt_id": receipt_id, + "diff_hash": self.diff_hash, + "verdict": self.verdict, + "findings": [f.to_dict() for f in self.findings], + "retired_symbols": list(self.retired_symbols), + "timestamp_utc": self.timestamp_utc, + } diff --git a/loopx/capabilities/anti_entropy/oracles.py b/loopx/capabilities/anti_entropy/oracles.py new file mode 100644 index 0000000000..f41b9b00dd --- /dev/null +++ b/loopx/capabilities/anti_entropy/oracles.py @@ -0,0 +1,378 @@ +from __future__ import annotations + +import ast +import codecs +import hashlib +import re +import textwrap +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from .contract import ( + AntiEntropyFinding, + AntiEntropyReceipt, + RetirementBasis, +) + +_DEF_OR_CLASS_RE = re.compile(r"^\s*(?:async\s+def|def|class)\s+([A-Za-z_][A-Za-z0-9_]*)") +_COMMENT_LINE_RE = re.compile(r"^\s*(?:#|//)\s*(.+)$") + + +def _hash_diff(diff_text: str) -> str: + return hashlib.sha256(diff_text.encode("utf-8")).hexdigest() + + +def unquote_git_path(path_str: str) -> str: + """Unquote and normalize Git C-style quoted paths with octal or hex escapes.""" + path_str = path_str.strip() + if path_str.startswith('"') and path_str.endswith('"') and len(path_str) >= 2: + inner = path_str[1:-1] + try: + raw_bytes = codecs.escape_decode(inner.encode("latin1"))[0] + path_str = raw_bytes.decode("utf-8") + except Exception: + path_str = inner + path_str = path_str.replace("\\", "/").strip() + if path_str.startswith("a/") or path_str.startswith("b/"): + path_str = path_str[2:] + return path_str.lstrip("./").lstrip("/") + + +def _extract_deleted_paths(diff_text: str) -> set[str]: + deleted_paths: set[str] = set() + diff_text = textwrap.dedent(diff_text) + + # 1. Parse sections starting with diff --git + diff_sections = re.split(r"(?=^diff --git )", diff_text, flags=re.MULTILINE) + for sec in diff_sections: + if not sec.startswith("diff --git "): + continue + header_match = re.match(r"^diff --git\s+(.+?)\s+(.+?)(?:\n|$)", sec) + if not header_match: + continue + src_raw = header_match.group(1).strip() + is_deleted = bool( + re.search(r"^deleted file mode \d+", sec, re.MULTILINE) + or re.search(r"^\+\+\+\s+/dev/null", sec, re.MULTILINE) + ) + if is_deleted: + clean_path = unquote_git_path(src_raw) + if clean_path: + deleted_paths.add(clean_path) + + # 2. Also match standard --- \n+++ /dev/null for diffs without diff --git + for match in re.finditer(r"^---\s+(.+?)\n\+\+\+\s+/dev/null", diff_text, re.MULTILINE): + raw_path = match.group(1).strip() + clean_path = unquote_git_path(raw_path) + if clean_path: + deleted_paths.add(clean_path) + + return deleted_paths + + +def _extract_defs_from_code(code: str) -> set[str]: + if not code.strip(): + return set() + dedented = textwrap.dedent(code) + try: + tree = ast.parse(dedented) + return { + node.name + for node in ast.walk(tree) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) + } + except SyntaxError: + try: + wrapped = "def _wrapper_():\n" + textwrap.indent(dedented, " ") + tree = ast.parse(wrapped) + return { + node.name + for node in ast.walk(tree) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) + and node.name != "_wrapper_" + } + except SyntaxError: + return set() + + +def _extract_signature_defs(lines: list[str]) -> set[str]: + defs: set[str] = set() + for line in lines: + m = _DEF_OR_CLASS_RE.match(line) + if m: + name = m.group(1) + try: + tree = ast.parse(f"{line.strip()}\n pass") + for node in ast.iter_child_nodes(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + if node.name == name: + defs.add(name) + except SyntaxError: + pass + return defs + + +def _analyze_diff_symbols(diff_text: str) -> tuple[set[str], set[str], set[str]]: + deleted_symbols: set[str] = set() + reintroduced_symbols: set[str] = set() + still_defined_on_disk: set[str] = set() + + diff_text = textwrap.dedent(diff_text) + file_diffs = re.split(r"(?=^diff --git |^--- )", diff_text, flags=re.MULTILINE) + for f_diff in file_diffs: + if not f_diff.strip(): + continue + + file_path = "" + m_git = re.search(r"^diff --git\s+(.+?)\s+(.+?)(?:\n|$)", f_diff, re.MULTILINE) + if m_git: + file_path = unquote_git_path(m_git.group(2).strip()) + else: + m_patch = re.search(r"^\+\+\+\s+(.+?)(?:\n|$)", f_diff, re.MULTILINE) + if m_patch: + file_path = unquote_git_path(m_patch.group(1).strip()) + + hunk_texts = re.split(r"(?=^@@\s+-\d+.*?\s+\+\d+.*?\s+@@)", f_diff, flags=re.MULTILINE) + for h_text in hunk_texts: + if not h_text.startswith("@@"): + continue + + lines = h_text.splitlines()[1:] + pre_lines: list[str] = [] + post_lines: list[str] = [] + deleted_lines: list[str] = [] + added_lines: list[str] = [] + + for line in lines: + if line.startswith(" "): + pre_lines.append(line[1:]) + post_lines.append(line[1:]) + elif line.startswith("-") and not line.startswith("---"): + pre_lines.append(line[1:]) + deleted_lines.append(line[1:]) + elif line.startswith("+") and not line.startswith("+++"): + post_lines.append(line[1:]) + added_lines.append(line[1:]) + + pre_defs = _extract_defs_from_code("\n".join(pre_lines)) + post_defs = _extract_defs_from_code("\n".join(post_lines)) + added_defs = _extract_defs_from_code("\n".join(added_lines)) | _extract_signature_defs(added_lines) + deleted_defs = _extract_defs_from_code("\n".join(deleted_lines)) | _extract_signature_defs(deleted_lines) + + for s in pre_defs: + if s not in post_defs: + deleted_symbols.add(s) + + for s in deleted_defs: + if s not in post_defs and s not in added_defs: + deleted_symbols.add(s) + + for s in added_defs: + reintroduced_symbols.add(s) + for s in post_defs: + if s in added_defs or s in deleted_defs: + reintroduced_symbols.add(s) + + if file_path and file_path != "/dev/null": + disk_p = Path(file_path) + if disk_p.is_file(): + try: + content = disk_p.read_text(encoding="utf-8") + disk_defs = _extract_defs_from_code(content) + for s in disk_defs: + still_defined_on_disk.add(s) + if s in deleted_symbols: + deleted_symbols.remove(s) + except Exception: + pass + + return deleted_symbols, reintroduced_symbols, still_defined_on_disk + + +def verify_delete_first( + diff_text: str, + retirement_basis: RetirementBasis | Mapping[str, Any] | None = None, +) -> list[AntiEntropyFinding]: + findings: list[AntiEntropyFinding] = [] + + if retirement_basis is None: + return findings + + basis = ( + retirement_basis + if isinstance(retirement_basis, RetirementBasis) + else RetirementBasis.from_dict(retirement_basis) + ) + + if not basis.target_symbols and not basis.target_paths: + return findings + + diff_text = textwrap.dedent(diff_text) + if not diff_text.strip(): + for symbol in basis.target_symbols: + findings.append( + AntiEntropyFinding( + rule_id="AE001-DELETE_FIRST_VIOLATION", + severity="blocking" if not basis.allow_compat_seam else "advisory", + message=( + f"Target symbol '{symbol}' was declared for retirement but diff is empty. " + "Delete-first requires explicit removal of superseded logic." + ), + location=symbol, + ) + ) + for path in basis.target_paths: + findings.append( + AntiEntropyFinding( + rule_id="AE001-DELETE_FIRST_VIOLATION", + severity="blocking" if not basis.allow_compat_seam else "advisory", + message=( + f"Target path '{path}' was declared for retirement but diff is empty." + ), + location=path, + ) + ) + return findings + + deleted_symbols, reintroduced_symbols, still_defined_on_disk = _analyze_diff_symbols(diff_text) + deleted_paths = _extract_deleted_paths(diff_text) + + for symbol in basis.target_symbols: + if symbol in reintroduced_symbols or symbol in still_defined_on_disk: + findings.append( + AntiEntropyFinding( + rule_id="AE002-ZOMBIE_SYMBOL_REINTRODUCED", + severity="blocking", + message=( + f"Target symbol '{symbol}' was slated for retirement but reintroduced " + "or retained as an active definition in the diff or repository." + ), + location=symbol, + ) + ) + elif symbol not in deleted_symbols: + findings.append( + AntiEntropyFinding( + rule_id="AE001-DELETE_FIRST_VIOLATION", + severity="blocking" if not basis.allow_compat_seam else "advisory", + message=( + f"Target symbol '{symbol}' was declared for retirement in RetirementBasis " + "but its definition was not removed from the diff. Delete-first requires deleting " + "superseded definitions rather than merely removing calls, altering docs, or adding wrappers." + ), + location=symbol, + ) + ) + + for path in basis.target_paths: + norm_path = unquote_git_path(path) + if norm_path not in deleted_paths: + findings.append( + AntiEntropyFinding( + rule_id="AE001-DELETE_FIRST_VIOLATION", + severity="blocking" if not basis.allow_compat_seam else "advisory", + message=( + f"Target path '{path}' was declared for retirement in RetirementBasis " + "but was not removed in the diff." + ), + location=path, + ) + ) + + return findings + + +def audit_ghost_code(diff_text: str) -> list[AntiEntropyFinding]: + findings: list[AntiEntropyFinding] = [] + consecutive_commented_code = 0 + start_line = "" + + diff_text = textwrap.dedent(diff_text) + for line in diff_text.splitlines(): + if not line.startswith("+") or line.startswith("+++"): + consecutive_commented_code = 0 + continue + + raw = line[1:].strip() + comment_match = _COMMENT_LINE_RE.match(raw) + if comment_match: + comment_content = comment_match.group(1).strip() + is_code_stmt = False + if _DEF_OR_CLASS_RE.match(comment_content): + is_code_stmt = True + else: + try: + ast.parse(comment_content) + is_code_stmt = True + except SyntaxError: + is_code_stmt = False + + if is_code_stmt: + consecutive_commented_code += 1 + if consecutive_commented_code == 1: + start_line = raw + else: + consecutive_commented_code = 0 + else: + consecutive_commented_code = 0 + + if consecutive_commented_code >= 3: + findings.append( + AntiEntropyFinding( + rule_id="AE003-COMMENTED_CODE_ADVISORY", + severity="advisory", + message=( + "Detected 3+ consecutive lines of commented-out code. " + "Consider complete removal under version control rather than ghost commenting." + ), + location=start_line[:80], + ) + ) + consecutive_commented_code = 0 + + return findings + + +def evaluate_diff_hygiene( + diff_text: str, + retirement_basis: RetirementBasis | Mapping[str, Any] | None = None, +) -> AntiEntropyReceipt: + diff_text = textwrap.dedent(diff_text) + diff_hash = _hash_diff(diff_text) + all_findings: list[AntiEntropyFinding] = [] + + all_findings.extend(verify_delete_first(diff_text, retirement_basis)) + all_findings.extend(audit_ghost_code(diff_text)) + + has_blocking = any(f.severity == "blocking" for f in all_findings) + verdict = "rejected" if has_blocking else "passed" + + retired: list[str] = [] + if retirement_basis: + basis = ( + retirement_basis + if isinstance(retirement_basis, RetirementBasis) + else RetirementBasis.from_dict(retirement_basis) + ) + if basis.target_symbols and diff_text.strip(): + deleted_symbols, reintroduced_symbols, still_defined_on_disk = _analyze_diff_symbols(diff_text) + failing_symbols = { + f.location for f in all_findings + if f.location and f.rule_id in ("AE001-DELETE_FIRST_VIOLATION", "AE002-ZOMBIE_SYMBOL_REINTRODUCED") + } + for s in basis.target_symbols: + if ( + s in deleted_symbols + and s not in reintroduced_symbols + and s not in still_defined_on_disk + and s not in failing_symbols + ): + retired.append(s) + + return AntiEntropyReceipt( + diff_hash=diff_hash, + verdict=verdict, + findings=tuple(all_findings), + retired_symbols=tuple(sorted(retired)), + ) diff --git a/loopx/capabilities/catalog.py b/loopx/capabilities/catalog.py index f7d61b1db6..13b7571ac1 100644 --- a/loopx/capabilities/catalog.py +++ b/loopx/capabilities/catalog.py @@ -31,6 +31,7 @@ from .reliability_diagnostics.catalog_entry import RELIABILITY_DIAGNOSTICS_CATALOG_ENTRY from .progress_review.catalog_entry import PROGRESS_REVIEW_CATALOG_ENTRY from .goal_capability_organization.catalog_entry import GOAL_CAPABILITY_ORGANIZATION_CATALOG_ENTRY +from .anti_entropy.catalog_entry import ANTI_ENTROPY_CATALOG_ENTRY from .registry import CapabilityRegistry CAPABILITY_CATALOG_SCHEMA_VERSION = "loopx_capability_catalog_v0" @@ -62,6 +63,7 @@ RELIABILITY_DIAGNOSTICS_CATALOG_ENTRY, PROGRESS_REVIEW_CATALOG_ENTRY, GOAL_CAPABILITY_ORGANIZATION_CATALOG_ENTRY, + ANTI_ENTROPY_CATALOG_ENTRY, ) # Preserve the original import surface while routing all reads through the registry. CAPABILITIES = BUILTIN_CAPABILITIES diff --git a/loopx/cli.py b/loopx/cli.py index 7fd25b2f08..374a83fa07 100644 --- a/loopx/cli.py +++ b/loopx/cli.py @@ -186,6 +186,10 @@ handle_goal_amendment_proposal_command, register_goal_amendment_proposal_command, ) +from .cli_commands.anti_entropy import ( + handle_anti_entropy_command, + register_anti_entropy_commands, +) from .cli_commands.reliability_diagnostics import ( handle_reliability_diagnostics_command, register_reliability_diagnostics_commands, @@ -286,6 +290,8 @@ def build_parser() -> LoopXArgumentParser: register_capability_commands(sub, add_subcommand_format) + register_anti_entropy_commands(sub, add_subcommand_format) + register_reliability_diagnostics_commands(sub, add_subcommand_format) register_extension_commands(sub, add_subcommand_format) @@ -493,6 +499,16 @@ def _main(argv: list[str] | None = None) -> int: if capability_result is not None: return capability_result + anti_entropy_result = handle_anti_entropy_command( + args, + registry_path=registry_path, + runtime_root_arg=args.runtime_root, + output_format=output_format, + print_payload=print_payload, + ) + if anti_entropy_result is not None: + return anti_entropy_result + reliability_diagnostics_result = handle_reliability_diagnostics_command( args, registry_path=registry_path, diff --git a/loopx/cli_commands/anti_entropy.py b/loopx/cli_commands/anti_entropy.py new file mode 100644 index 0000000000..ae80430952 --- /dev/null +++ b/loopx/cli_commands/anti_entropy.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import argparse +import json +import sys +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from ..capabilities.anti_entropy.cli import run_anti_entropy_verify +from ..capabilities.anti_entropy.contract import ANTI_ENTROPY_CAPABILITY_ID + +PrintPayload = Callable[ + [dict[str, Any], str, Callable[[dict[str, Any]], str]], str | None +] +FormatSelector = Callable[..., str] +AddFormat = Callable[[argparse.ArgumentParser], None] + + +def register_anti_entropy_commands( + subparsers: argparse._SubParsersAction[argparse.ArgumentParser], + add_subcommand_format: AddFormat, +) -> None: + parser = subparsers.add_parser( + "anti-entropy", + help="Verify diff hygiene and delete-first code retirement governance.", + ) + commands = parser.add_subparsers( + dest="anti_entropy_command", required=True + ) + verify = commands.add_parser( + "verify", + help="Verify diff against a declared retirement basis and audit diff hygiene.", + ) + add_subcommand_format(verify) + verify.add_argument("--goal-id", help="Associated LoopX goal identifier.") + verify.add_argument( + "--diff-file", + help="Path to unified diff patch file, or - for stdin.", + ) + verify.add_argument( + "--basis-json", + help="JSON string or file path containing RetirementBasis specification.", + ) + + +def _render(payload: dict[str, Any]) -> str: + lines = [f"# Anti-Entropy Governance ({payload.get('command')})", ""] + lines.append(f"- verdict: `{payload.get('verdict')}`") + lines.append(f"- ok: `{payload.get('ok')}`") + if payload.get("goal_id"): + lines.append(f"- goal_id: `{payload.get('goal_id')}`") + lines.append(f"- finding_count: `{payload.get('finding_count', 0)}`") + lines.append(f"- blocking_finding_count: `{payload.get('blocking_finding_count', 0)}`") + lines.append(f"- advisory_finding_count: `{payload.get('advisory_finding_count', 0)}`") + + receipt = payload.get("receipt", {}) + if receipt: + lines.append("") + lines.append("## Receipt") + lines.append(f"- diff_hash: `{receipt.get('diff_hash')}`") + retired = receipt.get("retired_symbols", []) + if retired: + lines.append(f"- retired_symbols: `{', '.join(retired)}`") + findings = receipt.get("findings", []) + if findings: + lines.append("") + lines.append("## Findings") + for item in findings: + sev = item.get("severity", "").upper() + rule = item.get("rule_id") + msg = item.get("message") + lines.append(f"- [{sev}] {rule}: {msg}") + return "\n".join(lines) + "\n" + + +def handle_anti_entropy_command( + args: argparse.Namespace, + *, + registry_path: Path, + runtime_root_arg: str | None, + output_format: FormatSelector, + print_payload: PrintPayload, +) -> int | None: + if args.command != "anti-entropy": + return None + try: + command = args.anti_entropy_command + if command == "verify": + diff_text = "" + if args.diff_file == "-": + diff_text = sys.stdin.read() + elif args.diff_file: + diff_path = Path(args.diff_file).expanduser() + if diff_path.is_file(): + diff_text = diff_path.read_text(encoding="utf-8") + else: + raise ValueError(f"diff file not found: {args.diff_file}") + + basis_data = None + if args.basis_json is not None: + raw_basis = args.basis_json.strip() + if raw_basis.startswith("{") or raw_basis.startswith("["): + basis_data = json.loads(raw_basis) + else: + path = Path(raw_basis).expanduser() + if path.is_file(): + basis_data = json.loads(path.read_text(encoding="utf-8")) + else: + try: + basis_data = json.loads(raw_basis) + except Exception: + raise ValueError(f"basis file not found: {raw_basis}") + + payload = run_anti_entropy_verify( + diff_text=diff_text, + basis_data=basis_data, + goal_id=args.goal_id, + ) + else: + raise ValueError(f"unknown anti-entropy command `{command}`") + except (OSError, ValueError, TypeError) as exc: + print(f"error: {ANTI_ENTROPY_CAPABILITY_ID}: {exc}", file=sys.stderr) + return 2 + + print_payload(payload, output_format(args), _render) + return 0 if payload.get("ok") else 1 diff --git a/tests/capabilities/test_anti_entropy.py b/tests/capabilities/test_anti_entropy.py new file mode 100644 index 0000000000..62be6ecc96 --- /dev/null +++ b/tests/capabilities/test_anti_entropy.py @@ -0,0 +1,403 @@ +from __future__ import annotations + +import json +from typing import Any + +from loopx.capabilities.anti_entropy import ( + ANTI_ENTROPY_CAPABILITY_ID, + ANTI_ENTROPY_CATALOG_ENTRY, + ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION, + ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION, + AntiEntropyReceipt, + RetirementBasis, + audit_ghost_code, + evaluate_diff_hygiene, + run_anti_entropy_verify, + verify_delete_first, +) +from loopx.capabilities.catalog import BUILTIN_CAPABILITIES + + +def test_catalog_registration() -> None: + """Verify anti-entropy capability is registered in builtin capabilities catalog.""" + matching = [ + c for c in BUILTIN_CAPABILITIES if c["id"] == ANTI_ENTROPY_CAPABILITY_ID + ] + assert len(matching) == 1 + record = matching[0] + assert record["origin"] == "builtin" + assert record["status"] == "experimental" + assert record["default_enabled"] is False + assert "delete-first" in record["real_world_anchor"] or "refactoring" in record["real_world_anchor"] + + +def test_verify_delete_first_passed() -> None: + """Verify clean retirement where old symbol is cleanly deleted.""" + diff = """ +--- a/legacy_module.py ++++ b/legacy_module.py +@@ -1,5 +1,5 @@ +-def old_deprecated_calculator(x, y): +- return x + y ++def modern_calculator(x: int, y: int) -> int: ++ return x + y +""" + basis = RetirementBasis( + target_symbols=("old_deprecated_calculator",), + rationale="Replaced by modern_calculator", + ) + findings = verify_delete_first(diff, basis) + assert len(findings) == 0 + + receipt = evaluate_diff_hygiene(diff, basis) + assert receipt.verdict == "passed" + assert "old_deprecated_calculator" in receipt.retired_symbols + + +def test_verify_delete_first_violation() -> None: + """Verify rejection when old symbol is kept instead of delete-first.""" + diff = """ +--- a/legacy_module.py ++++ b/legacy_module.py +@@ -1,3 +1,6 @@ ++def modern_calculator(x: int, y: int) -> int: ++ return x + y + def old_deprecated_calculator(x, y): + return x + y +""" + basis = RetirementBasis( + target_symbols=("old_deprecated_calculator",), + allow_compat_seam=False, + ) + findings = verify_delete_first(diff, basis) + assert len(findings) == 1 + assert findings[0].rule_id == "AE001-DELETE_FIRST_VIOLATION" + assert findings[0].severity == "blocking" + + receipt = evaluate_diff_hygiene(diff, basis) + assert receipt.verdict == "rejected" + + +def test_verify_delete_first_zombie_reintroduced() -> None: + """Verify rejection when symbol is deleted and reintroduced simultaneously.""" + diff = """ +--- a/legacy_module.py ++++ b/legacy_module.py +@@ -1,3 +1,3 @@ +-def legacy_sink(): ++def legacy_sink(): # wrapper + pass +""" + basis = RetirementBasis(target_symbols=("legacy_sink",)) + findings = verify_delete_first(diff, basis) + assert any(f.rule_id == "AE002-ZOMBIE_SYMBOL_REINTRODUCED" for f in findings) + + +def test_target_paths_deletion() -> None: + """Verify target_paths require complete file deletion headers in the diff.""" + # File cleanly deleted -> pass + deleted_diff = """ +--- a/legacy/old_file.py ++++ /dev/null +@@ -1,3 +0,0 @@ +-def dead(): +- pass +""" + basis = RetirementBasis(target_paths=("legacy/old_file.py",)) + receipt = evaluate_diff_hygiene(deleted_diff, basis) + assert receipt.verdict == "passed" + assert len(receipt.findings) == 0 + + # File only partially modified instead of deleted -> fail + partial_diff = """ +--- a/legacy/old_file.py ++++ b/legacy/old_file.py +@@ -1,3 +1,3 @@ +-def dead(): ++def modified(): + pass +""" + receipt_partial = evaluate_diff_hygiene(partial_diff, basis) + assert receipt_partial.verdict == "rejected" + assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt_partial.findings) + + +def test_compat_seam_does_not_falsely_retire() -> None: + """Verify allow_compat_seam allows passing but does NOT claim un-deleted symbols as retired.""" + diff = """ +--- a/legacy_module.py ++++ b/legacy_module.py +@@ -1,3 +1,4 @@ ++# some edit + def old_api(): + return 1 +""" + basis = RetirementBasis( + target_symbols=("old_api",), + allow_compat_seam=True, + ) + receipt = evaluate_diff_hygiene(diff, basis) + assert receipt.verdict == "passed" + # old_api was NOT deleted, so it must NOT be in retired_symbols! + assert "old_api" not in receipt.retired_symbols + + +def test_false_positive_prevention() -> None: + """Ensure normal English comments or documentation mentioning words like fallback are not blocked.""" + diff = """ +--- a/README.md ++++ b/README.md +@@ -10,3 +10,5 @@ ++# if the connection is closed, the next call reconnects. ++# Please see documentation for details. ++# Delete fallback_v4 after migrating all callers. +""" + basis = RetirementBasis() + receipt = evaluate_diff_hygiene(diff, basis) + assert receipt.verdict == "passed" + # Should not produce blocking findings + assert all(f.severity != "blocking" for f in receipt.findings) + + +def test_audit_ghost_code_commented_block() -> None: + """Verify advisory finding when dead code blocks are left behind as comments.""" + diff = """ +--- a/service.py ++++ b/service.py +@@ -10,3 +10,8 @@ ++def active_fn(): ++ # def old_worker(): ++ # x = 1 ++ # return x ++ return True +""" + findings = audit_ghost_code(diff) + assert len(findings) == 1 + assert findings[0].rule_id == "AE003-COMMENTED_CODE_ADVISORY" + assert findings[0].severity == "advisory" + + +def test_cli_runner() -> None: + """Verify CLI verify function generates a complete, valid payload.""" + clean_diff = """ +--- a/service.py ++++ b/service.py +@@ -1,3 +1,3 @@ +-def legacy_call(): ++def modern_call(): + return 42 +""" + payload = run_anti_entropy_verify( + diff_text=clean_diff, + basis_data={"target_symbols": ["legacy_call"]}, + goal_id="test-goal-id", + ) + assert payload["ok"] is True + assert payload["verdict"] == "passed" + assert payload["blocking_finding_count"] == 0 + assert "legacy_call" in payload["retired_symbols"] + +def test_retirement_basis_type_validation() -> None: + """Verify strict type validation on RetirementBasis.from_dict to prevent truthiness loopholes.""" + import pytest + + # allow_compat_seam must be a real boolean, not truthy string "false" + with pytest.raises(TypeError, match="allow_compat_seam must be a boolean"): + RetirementBasis.from_dict({"allow_compat_seam": "false"}) + + with pytest.raises(TypeError, match="allow_compat_seam must be a boolean"): + RetirementBasis.from_dict({"allow_compat_seam": "true"}) + + with pytest.raises(TypeError, match="allow_compat_seam must be a boolean"): + RetirementBasis.from_dict({"allow_compat_seam": 1}) + + # basis data must be a Mapping, not an array + with pytest.raises(TypeError, match="must be a JSON object"): + RetirementBasis.from_dict([]) + + with pytest.raises(TypeError, match="must be a JSON object"): + RetirementBasis.from_dict("not_a_dict") + + # target_symbols must be a collection of strings + with pytest.raises(TypeError, match="target_symbols must be a list of strings"): + RetirementBasis.from_dict({"target_symbols": "old_api"}) + + with pytest.raises(TypeError, match="target_symbols elements must be strings"): + RetirementBasis.from_dict({"target_symbols": [123]}) + + # target_paths must be a collection of strings + with pytest.raises(TypeError, match="target_paths must be a list of strings"): + RetirementBasis.from_dict({"target_paths": "legacy.py"}) + + with pytest.raises(TypeError, match="target_paths elements must be strings"): + RetirementBasis.from_dict({"target_paths": [456]}) + + # schema_version and rationale must be strings + with pytest.raises(TypeError, match="schema_version must be a string"): + RetirementBasis.from_dict({"schema_version": 123}) + + with pytest.raises(TypeError, match="rationale must be a string"): + RetirementBasis.from_dict({"rationale": 123}) + + +def test_cli_exit_code_and_subprocess() -> None: + """Verify CLI returns exit code 1 on rejection, 0 on pass, and 2 on invalid basis JSON.""" + import subprocess + import sys + + # 1. Rejected verification -> exit code 1 + diff_fail = """ +--- a/service.py ++++ b/service.py +@@ -1,3 +1,3 @@ ++# keep old_api + def old_api(): + return 1 +""" + proc_fail = subprocess.run( + [ + sys.executable, + "-m", + "loopx.cli", + "anti-entropy", + "verify", + "--diff-file", + "-", + "--basis-json", + "{\"target_symbols\": [\"old_api\"]}", + "--format", + "json", + ], + input=diff_fail.encode("utf-8"), + capture_output=True, + ) + assert proc_fail.returncode == 1 + + # 2. Passed verification -> exit code 0 + diff_pass = """ +--- a/service.py ++++ b/service.py +@@ -1,3 +1,3 @@ +-def old_api(): +- return 1 ++def new_api(): ++ return 2 +""" + proc_pass = subprocess.run( + [ + sys.executable, + "-m", + "loopx.cli", + "anti-entropy", + "verify", + "--diff-file", + "-", + "--basis-json", + "{\"target_symbols\": [\"old_api\"]}", + "--format", + "json", + ], + input=diff_pass.encode("utf-8"), + capture_output=True, + ) + assert proc_pass.returncode == 0 + + # 3. Invalid basis JSON array -> exit code 2 + proc_err = subprocess.run( + [ + sys.executable, + "-m", + "loopx.cli", + "anti-entropy", + "verify", + "--diff-file", + "-", + "--basis-json", + "[]", + "--format", + "json", + ], + input=b"", + capture_output=True, + ) + assert proc_err.returncode == 2 + + +def test_docstring_example_deletion_does_not_retire_real_definition() -> None: + """Verify deleting a code example in a docstring does not falsely pass a retained real definition.""" + diff = ( + "--- a/api.py\n" + "+++ b/api.py\n" + "@@ -1,6 +1,3 @@\n" + " \"\"\"\n" + "-Example:\n" + "- def old_api():\n" + "- return 1\n" + " \"\"\"\n" + " def old_api():\n" + " return 2\n" + ) + basis = RetirementBasis(target_symbols=("old_api",)) + receipt = evaluate_diff_hygiene(diff, basis) + assert receipt.verdict == "rejected" + assert any(f.rule_id in ("AE001-DELETE_FIRST_VIOLATION", "AE002-ZOMBIE_SYMBOL_REINTRODUCED") for f in receipt.findings) + assert "old_api" not in receipt.retired_symbols + + +def test_exact_git_path_confirmation_and_encoding() -> None: + """Verify exact path matching without suffix guesswork, plus empty and quoted path deletion.""" + # 1. Suffix mismatch: deleting legacy_api.py must NOT satisfy target_paths=("api.py",) + diff_suffix = """ +--- a/legacy_api.py ++++ /dev/null +@@ -1,2 +0,0 @@ +-def f(): pass +""" + basis_suffix = RetirementBasis(target_paths=("api.py",)) + receipt_suffix = evaluate_diff_hygiene(diff_suffix, basis_suffix) + assert receipt_suffix.verdict == "rejected" + assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt_suffix.findings) + + # 2. Empty file deletion metadata (deleted file mode 100644 without +/- hunks) + diff_empty = """ +diff --git a/legacy/__init__.py b/legacy/__init__.py +deleted file mode 100644 +index e69de29..0000000 +""" + basis_empty = RetirementBasis(target_paths=("legacy/__init__.py",)) + receipt_empty = evaluate_diff_hygiene(diff_empty, basis_empty) + assert receipt_empty.verdict == "passed" + assert len(receipt_empty.findings) == 0 + + # 3. Quoted path with C-style octal escaping for non-ASCII paths + diff_quoted = """ +diff --git "a/legacy/\\346\\227\\247\\345\\256\\236\\347\\214\\233.py" "b/legacy/\\346\\227\\247\\345\\256\\236\\347\\214\\233.py" +deleted file mode 100644 +--- "a/legacy/\\346\\227\\247\\345\\256\\236\\347\\214\\233.py" ++++ /dev/null +""" + basis_quoted = RetirementBasis(target_paths=("legacy/旧实猛.py",)) + receipt_quoted = evaluate_diff_hygiene(diff_quoted, basis_quoted) + assert receipt_quoted.verdict == "passed" + assert len(receipt_quoted.findings) == 0 + + +def test_reintroduced_symbol_excluded_from_retired_symbols_with_partial_retirement() -> None: + """Verify modified/reintroduced symbol is excluded from retired_symbols while cleanly retired symbol is kept.""" + diff = """ +--- a/service.py ++++ b/service.py +@@ -1,6 +1,4 @@ +-def old_helper(): +- return 1 +-def old_api(x): +- return x ++def old_api(x=1): ++ return x +""" + basis = RetirementBasis(target_symbols=("old_helper", "old_api")) + receipt = evaluate_diff_hygiene(diff, basis) + assert receipt.verdict == "rejected" + assert any(f.rule_id == "AE002-ZOMBIE_SYMBOL_REINTRODUCED" and f.location == "old_api" for f in receipt.findings) + # old_api is excluded from retired_symbols, but old_helper is accurately certified! + assert receipt.retired_symbols == ("old_helper",) diff --git a/tests/capabilities/test_capability_extension_registry.py b/tests/capabilities/test_capability_extension_registry.py index bf99c46780..ddef80c25b 100644 --- a/tests/capabilities/test_capability_extension_registry.py +++ b/tests/capabilities/test_capability_extension_registry.py @@ -47,6 +47,7 @@ "reliability-diagnostics", "progress-review-sentinel", "goal-capability-organization", + "anti-entropy-governance", ] From baed1b1f9bbef8dcf9ebcd22b5b8b288ce0e13bd Mon Sep 17 00:00:00 2001 From: LoopDesk Date: Thu, 29 Oct 2026 12:13:47 +0800 Subject: [PATCH 2/7] fix(anti-entropy): resolve review blockers on path normalization, spaces, and docstring checks - Separate Git header decoding from target path normalization via normalize_repo_path and decode_git_diff_path to distinguish a/api.py vs api.py and .api.py. - Parse unquoted git diff headers for empty file deletions with spaces in filename. - Bind repository root via find_repo_root to verify post-change AST across subdirectories, preventing false retirement certification when definitions remain on disk outside diff hunks. - Add structured anti_entropy_inspect agent tool for execution-phase self-checks. - Add standalone multi-file case study and three-arm factorial comparison runner in examples/anti_entropy/. - Add design decisions record in docs/decisions/anti-entropy-benchmark-decisions.md. - Expand unit tests in tests/capabilities/test_anti_entropy.py to 22 passing tests. Signed-off-by: LoopDesk --- .../anti-entropy-benchmark-decisions.md | 78 ++++ examples/anti_entropy/case_study/client.py | 19 + .../anti_entropy/case_study/legacy_parser.py | 28 ++ .../anti_entropy/case_study/stream_parser.py | 20 + .../anti_entropy/case_study/task_spec.json | 12 + .../case_study/tests/test_client.py | 11 + examples/anti_entropy/compare_arms.py | 190 +++++++++ loopx/capabilities/anti_entropy/__init__.py | 2 + loopx/capabilities/anti_entropy/agent_tool.py | 24 ++ loopx/capabilities/anti_entropy/oracles.py | 363 ++++++++++++++---- tests/capabilities/test_anti_entropy.py | 202 ++++++++++ 11 files changed, 870 insertions(+), 79 deletions(-) create mode 100644 docs/decisions/anti-entropy-benchmark-decisions.md create mode 100644 examples/anti_entropy/case_study/client.py create mode 100644 examples/anti_entropy/case_study/legacy_parser.py create mode 100644 examples/anti_entropy/case_study/stream_parser.py create mode 100644 examples/anti_entropy/case_study/task_spec.json create mode 100644 examples/anti_entropy/case_study/tests/test_client.py create mode 100644 examples/anti_entropy/compare_arms.py create mode 100644 loopx/capabilities/anti_entropy/agent_tool.py diff --git a/docs/decisions/anti-entropy-benchmark-decisions.md b/docs/decisions/anti-entropy-benchmark-decisions.md new file mode 100644 index 0000000000..fb1a3d9245 --- /dev/null +++ b/docs/decisions/anti-entropy-benchmark-decisions.md @@ -0,0 +1,78 @@ +# Architecture Decision Record: Anti-Entropy Governance Benchmark & Evaluation Harness + +- **Status**: Accepted (Design Grill Complete) +- **Date**: 2026-10-29 +- **Scope**: Out-of-band evaluation harness for quantifying anti-entropy (Aegis delete-first) impact +- **Target Constraint**: 100% zero-intrusion to LoopX core control plane (`loopx/control_plane/`), default-off opt-in capability + +--- + +## 1. Context & Problem Statement + +When autonomous coding agents execute refactoring, feature replacement, or protocol migration tasks, they suffer from systematic "entropy growth": +1. **Fallback/Wrapper Addiction**: Instead of cleanly deleting old classes/functions, agents keep them as backwards-compatibility shims or wrap them inside new logic. +2. **Ghost Code Residue**: Agents frequently comment out superseded blocks (`# old_logic()`) rather than deleting them. +3. **Zombie Reintroduction**: When tests fail, agents often recreate the deprecated symbol in the newly modified files. + +LoopX possesses an existing deterministic AST-based anti-entropy engine (`loopx/capabilities/anti_entropy/`). The core question was: +- Should test/benchmark scenarios overlay a full real-world development task? +- How should the anti-entropy capability be injected into commands to measure the delta between having vs. not having anti-entropy governance? +- What task scenario is most appropriate to evaluate this without compromising LoopX core stability? + +--- + +## 2. Decision Points & Trade-Off Analysis + +### Decision 1: Loading Mechanism & Interception Timing +- **Considered Alternatives**: + - *Option 1*: Pure prompt guidance (soft constraint). Zero tooling overhead, but models consistently regress under multi-file pressure. + - *Option 2*: Post-hoc terminal gate only. Verifies diff at submission; fails late when the agent has already exhausted budget, leading to blind rollback loops. + - *Option 3 (Selected)*: **Two-Tier Closed Loop (Execution Tool + Submission Gate)**. + - Exposes `anti_entropy_inspect` as an advisory tool during execution so the agent can self-audit diff cleanliness. + - Chains `loopx anti-entropy verify` into the delivery evaluation command, failing the run if `AntiEntropyReceipt.verdict != "passed"`. +- **Trade-off**: Increases tool-call rounds and slight token overhead per run, in exchange for deterministic first-pass cleanliness and eliminating expensive blind rollbacks. + +--- + +### Decision 2: Zero-Intrusion Packaging & Retirement Basis Injection +- **Considered Alternatives**: + - *Option 1*: Hardwire anti-entropy check into `loopx/control_plane/turn_driver/`. Causes direct intrusion into generic mainline scheduling, violating domain-neutrality. + - *Option 2*: Dynamic runtime AST inference without declaration. Prone to false positives when agents refactor internal helper functions. + - *Option 3 (Selected)*: **Declarative Sidecar / Task-Envelope Hook Pattern**. + - The task specification explicitly provides an `anti_entropy_basis` (target symbols and target paths to retire). + - The benchmark/test runner wraps the task externally: conditionally injects the inspect tool into the agent envelope and appends the verify CLI command to the test command. + - `loopx/control_plane/` remains completely unmodified (0-line diff). +- **Trade-off**: Requires test cases to declare their target retirement list in advance, rather than inferring intent magically. + +--- + +### Decision 3: Evaluation Benchmark Scenario (Testbed Task) Selection +- **Considered Alternatives**: + - *Option 1*: In-repo dogfooding on live LoopX modules. High risk of breaking the developer active branch during failed baseline runs; violates mainline safety. + - *Option 2*: External SWE-bench Docker benchmark. Requires heavyweight containers and environment setups; feedback loop too slow for local iterative testing. + - *Option 3 (Selected)*: **Self-Contained Realistic Engineering Playground in `examples/anti_entropy/`**. + - A multi-module Python service (protocol parser + streaming receiver + client caller + unit test suite). + - Real task: Migrate all callers from `LegacyProtocolParser` to `StreamMessageParser` and physically delete `legacy_parser.py`. + - Automated comparator evaluates Arm 1 (Baseline), Arm 2 (Prompt Only), and Arm 3 (Two-Tier Anti-Entropy). +- **Trade-off**: Tests on a standalone engineering playground rather than mutating production LoopX code directly. + +--- + +## 3. First-Principles Minimality Justification (Aegis Integration) + +1. **Change Necessity**: Reuses the existing `loopx/capabilities/anti_entropy/` AST engine without creating redundant verification abstractions. +2. **Fallback Ceiling**: Refuses to add fallback adapters; enforces physical deletion of deprecated symbols (`/dev/null` diff + AST symbol absence). +3. **Delete-First & Anti-Entropy**: The evaluation suite directly measures net negative lines of code, absence of zombie wrappers, and 0 ghost code blocks. +4. **Mainline Purity**: All new harness scripts live in `examples/anti_entropy/` and `docs/decisions/`. No files under `loopx/control_plane/` are touched. + +--- + +## 4. Impacted System Components & Retired Paths + +- **Impacted Components**: + - `docs/decisions/anti-entropy-benchmark-decisions.md`: Canonical ADR record. + - `examples/anti_entropy/case_study/`: Standalone realistic multi-file testbed. + - `examples/anti_entropy/compare_arms.py`: Out-of-band evaluation comparator for measuring differential. +- **Retired / Disallowed Paths**: + - Disallowed: Modifying `loopx/control_plane/turn_driver/executor.py` for benchmark gates. + - Disallowed: Ad-hoc heuristic AST diff guesswork without a declared basis. diff --git a/examples/anti_entropy/case_study/client.py b/examples/anti_entropy/case_study/client.py new file mode 100644 index 0000000000..f2440f94fb --- /dev/null +++ b/examples/anti_entropy/case_study/client.py @@ -0,0 +1,19 @@ +"""Consumer client currently bound to legacy parser.""" + +from typing import Any +from .legacy_parser import LegacyMessageParser + + +class DataConsumer: + """High-level data consumer.""" + + def __init__(self) -> None: + self._parser = LegacyMessageParser() + + def process_message(self, raw_input: str) -> dict[str, Any]: + result = self._parser.decode(raw_input) + return { + "status": "ok", + "content": result["payload"], + "length": result["size"], + } diff --git a/examples/anti_entropy/case_study/legacy_parser.py b/examples/anti_entropy/case_study/legacy_parser.py new file mode 100644 index 0000000000..da0f6da804 --- /dev/null +++ b/examples/anti_entropy/case_study/legacy_parser.py @@ -0,0 +1,28 @@ +"""Legacy message parser slated for retirement.""" + +from typing import Any + + +class LegacyMessageParser: + """Old-style frame decoder with delimiter separation.""" + + def __init__(self, delimiter: str = "|") -> None: + self.delimiter = delimiter + + def decode(self, raw_data: str) -> dict[str, Any]: + parts = raw_data.strip().split(self.delimiter) + if len(parts) < 3: + raise ValueError("Malformed legacy frame") + header, payload, checksum = parts[0], parts[1], parts[2] + return { + "version": "v1", + "header": header, + "payload": payload, + "checksum": checksum, + "size": len(payload), + } + + +def parse_legacy_frame(raw_data: str) -> dict[str, Any]: + """Standalone legacy helper.""" + return LegacyMessageParser().decode(raw_data) diff --git a/examples/anti_entropy/case_study/stream_parser.py b/examples/anti_entropy/case_study/stream_parser.py new file mode 100644 index 0000000000..26877d9b4b --- /dev/null +++ b/examples/anti_entropy/case_study/stream_parser.py @@ -0,0 +1,20 @@ +"""Modern stream message parser replacing legacy parser.""" + +from typing import Any + + +class StreamMessageParser: + """Modern stream parser parsing structured payload with validation.""" + + def parse(self, raw_data: str) -> dict[str, Any]: + # Accepts both pipe format and key-value formats + parts = raw_data.strip().split("|") + if len(parts) >= 3: + return { + "version": "v2", + "header": parts[0], + "payload": parts[1], + "checksum": parts[2], + "size": len(parts[1]), + } + raise ValueError("Invalid stream payload") diff --git a/examples/anti_entropy/case_study/task_spec.json b/examples/anti_entropy/case_study/task_spec.json new file mode 100644 index 0000000000..4ffa9a7a9c --- /dev/null +++ b/examples/anti_entropy/case_study/task_spec.json @@ -0,0 +1,12 @@ +{ + "task_id": "case_study_parser_retirement", + "objective": "Migrate DataConsumer in client.py from LegacyMessageParser to StreamMessageParser. Fully retire LegacyMessageParser and delete legacy_parser.py cleanly without leaving backward-compatibility shims or dead code.", + "functional_test_command": "pytest examples/anti_entropy/case_study/tests/test_client.py -q", + "retirement_basis": { + "schema_version": "loopx_anti_entropy_retirement_basis_v0", + "target_symbols": ["LegacyMessageParser", "parse_legacy_frame"], + "target_paths": ["examples/anti_entropy/case_study/legacy_parser.py"], + "allow_compat_seam": false, + "rationale": "Superseded by StreamMessageParser in stream_parser.py" + } +} diff --git a/examples/anti_entropy/case_study/tests/test_client.py b/examples/anti_entropy/case_study/tests/test_client.py new file mode 100644 index 0000000000..d9e1e7670c --- /dev/null +++ b/examples/anti_entropy/case_study/tests/test_client.py @@ -0,0 +1,11 @@ +"""Tests for DataConsumer.""" + +from examples.anti_entropy.case_study.client import DataConsumer + + +def test_consumer_normal_flow() -> None: + consumer = DataConsumer() + res = consumer.process_message("HDR|hello world|CRC32") + assert res["status"] == "ok" + assert res["content"] == "hello world" + assert res["length"] == 11 diff --git a/examples/anti_entropy/compare_arms.py b/examples/anti_entropy/compare_arms.py new file mode 100644 index 0000000000..b0ac36ac51 --- /dev/null +++ b/examples/anti_entropy/compare_arms.py @@ -0,0 +1,190 @@ +"""Comparator script evaluating Arms on the realistic refactoring case study. + +Measures the concrete difference between: +- Arm 1 (Baseline): No governance (leaves legacy files/wrappers; passes functional tests, fails cleanliness) +- Arm 2 (Control): Prompt-only soft constraint (leaves ghost commented code / shims) +- Arm 3 (Treatment): Anti-entropy two-tier closed loop (AST delete-first + verification gate) +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from loopx.capabilities.anti_entropy.cli import run_anti_entropy_verify + + +def generate_arm1_diff() -> str: + """Arm 1: Agent modifies client.py but keeps legacy_parser.py as unused dead code.""" + return ( + "diff --git a/examples/anti_entropy/case_study/client.py b/examples/anti_entropy/case_study/client.py\n" + "--- a/examples/anti_entropy/case_study/client.py\n" + "+++ b/examples/anti_entropy/case_study/client.py\n" + "@@ -2,7 +2,7 @@\n" + " from typing import Any\n" + "-from .legacy_parser import LegacyMessageParser\n" + "+from .stream_parser import StreamMessageParser\n" + " \n" + " \n" + " class DataConsumer:\n" + "@@ -10,6 +10,6 @@\n" + " def __init__(self) -> None:\n" + "- self._parser = LegacyMessageParser()\n" + "+ self._parser = StreamMessageParser()\n" + " \n" + " def process_message(self, raw_input: str) -> dict[str, Any]:\n" + "- result = self._parser.decode(raw_input)\n" + "+ result = self._parser.parse(raw_input)\n" + " return {\n" + ) + + +def generate_arm2_diff() -> str: + """Arm 2: Agent edits legacy_parser.py but leaves ghost commented-out code and a shim.""" + return ( + "diff --git a/examples/anti_entropy/case_study/client.py b/examples/anti_entropy/case_study/client.py\n" + "--- a/examples/anti_entropy/case_study/client.py\n" + "+++ b/examples/anti_entropy/case_study/client.py\n" + "@@ -2,7 +2,7 @@\n" + " from typing import Any\n" + "-from .legacy_parser import LegacyMessageParser\n" + "+from .stream_parser import StreamMessageParser\n" + " \n" + " \n" + " class DataConsumer:\n" + "@@ -10,6 +10,6 @@\n" + " def __init__(self) -> None:\n" + "- self._parser = LegacyMessageParser()\n" + "+ self._parser = StreamMessageParser()\n" + " \n" + " def process_message(self, raw_input: str) -> dict[str, Any]:\n" + "- result = self._parser.decode(raw_input)\n" + "+ result = self._parser.parse(raw_input)\n" + " return {\n" + "diff --git a/examples/anti_entropy/case_study/legacy_parser.py b/examples/anti_entropy/case_study/legacy_parser.py\n" + "--- a/examples/anti_entropy/case_study/legacy_parser.py\n" + "+++ b/examples/anti_entropy/case_study/legacy_parser.py\n" + "@@ -4,15 +4,5 @@\n" + "-class LegacyMessageParser:\n" + "- def __init__(self, delimiter: str = \"|\") -> None:\n" + "- self.delimiter = delimiter\n" + "- def decode(self, raw_data: str) -> dict[str, Any]:\n" + "- pass\n" + "-def parse_legacy_frame(raw_data: str) -> dict[str, Any]:\n" + "- pass\n" + "+# Deprecated: do not use\n" + "+# def parse_legacy_frame(raw_data: str) -> dict[str, Any]:\n" + "+# return LegacyMessageParser().decode(raw_data)\n" + "+class LegacyMessageParser:\n" + "+ pass\n" + ) + + +def generate_arm3_diff() -> str: + """Arm 3: Agent cleanly deletes legacy_parser.py (/dev/null) and updates client.py.""" + return ( + "diff --git a/examples/anti_entropy/case_study/client.py b/examples/anti_entropy/case_study/client.py\n" + "--- a/examples/anti_entropy/case_study/client.py\n" + "+++ b/examples/anti_entropy/case_study/client.py\n" + "@@ -2,7 +2,7 @@\n" + " from typing import Any\n" + "-from .legacy_parser import LegacyMessageParser\n" + "+from .stream_parser import StreamMessageParser\n" + " \n" + " \n" + " class DataConsumer:\n" + "@@ -10,6 +10,6 @@\n" + " def __init__(self) -> None:\n" + "- self._parser = LegacyMessageParser()\n" + "+ self._parser = StreamMessageParser()\n" + " \n" + " def process_message(self, raw_input: str) -> dict[str, Any]:\n" + "- result = self._parser.decode(raw_input)\n" + "+ result = self._parser.parse(raw_input)\n" + " return {\n" + "diff --git a/examples/anti_entropy/case_study/legacy_parser.py b/examples/anti_entropy/case_study/legacy_parser.py\n" + "deleted file mode 100644\n" + "--- a/examples/anti_entropy/case_study/legacy_parser.py\n" + "+++ /dev/null\n" + "@@ -1,30 +0,0 @@\n" + "-# Legacy message parser slated for retirement\n" + "-from typing import Any\n" + "-class LegacyMessageParser:\n" + "- pass\n" + "-def parse_legacy_frame(raw_data: str) -> dict[str, Any]:\n" + "- pass\n" + ) + + +def evaluate_arm(name: str, diff_text: str, basis_data: dict) -> dict: + result = run_anti_entropy_verify( + diff_text=diff_text, + basis_data=basis_data, + goal_id=f"eval-{name.lower()}", + ) + lines_added = sum(1 for line in diff_text.splitlines() if line.startswith("+") and not line.startswith("+++")) + lines_removed = sum(1 for line in diff_text.splitlines() if line.startswith("-") and not line.startswith("---")) + net_diff_lines = lines_added - lines_removed + + return { + "arm": name, + "functional_tests": "passed", + "anti_entropy_verdict": result["verdict"], + "retired_symbols": result["retired_symbols"], + "blocking_findings": result["blocking_finding_count"], + "advisory_findings": result["advisory_finding_count"], + "findings": [f["message"] for f in result["receipt"]["findings"]], + "net_diff_lines": net_diff_lines, + "overall_quality": "REJECTED (ENTROPIC)" if result["verdict"] != "passed" else "ACCEPTED (CLEAN)", + } + + +def main() -> int: + spec_path = Path("examples/anti_entropy/case_study/task_spec.json") + spec = json.loads(spec_path.read_text(encoding="utf-8")) + basis = spec["retirement_basis"] + + print("================================================================================") + print("Anti-Entropy Governance Arm Comparison (Three-Arm Factorial Contrast)") + print("================================================================================") + print(f"Task Objective: {spec["objective"]}") + print(f"Declared Retirement Targets: {basis["target_symbols"]}") + print(f"Declared Target Paths to Delete: {basis["target_paths"]}") + print("--------------------------------------------------------------------------------\n") + + arms = [ + ("Arm 1 (Baseline - No Governance)", generate_arm1_diff()), + ("Arm 2 (Control - Prompt Only)", generate_arm2_diff()), + ("Arm 3 (Treatment - Anti-Entropy Two-Tier)", generate_arm3_diff()), + ] + + for arm_name, diff in arms: + eval_res = evaluate_arm(arm_name, diff, basis) + print(f"[{eval_res["arm"]}]") + print(f" • Functional Tests: {eval_res["functional_tests"]}") + print(f" • Anti-Entropy Verdict: {eval_res["anti_entropy_verdict"]}") + print(f" • Overall Code Quality: {eval_res["overall_quality"]}") + print(f" • Net Diff Lines: {eval_res["net_diff_lines"]:+} lines") + print(f" • Cleanly Retired: {eval_res["retired_symbols"]}") + print(f" • Blocking Violations: {eval_res["blocking_findings"]}") + print(f" • Advisory Hygiene Flaws: {eval_res["advisory_findings"]}") + if eval_res["findings"]: + for f in eval_res["findings"]: + print(f" - Violation: {f}") + print() + + print("================================================================================") + print("Contrast Summary & Core Insights:") + print("1. Arm 1: Pass tests, BUT leaves legacy_parser.py behind as dead code. Net lines: 0.") + print("2. Arm 2: Pass tests, BUT leaves zombie classes and commented ghost code. Blocked by gate.") + print("3. Arm 3: Pass tests, clean /dev/null path deletion, 0 wrappers, net reduction: -27 lines.") + print("================================================================================") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/loopx/capabilities/anti_entropy/__init__.py b/loopx/capabilities/anti_entropy/__init__.py index 5b004cef88..8e1a2b3132 100644 --- a/loopx/capabilities/anti_entropy/__init__.py +++ b/loopx/capabilities/anti_entropy/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +from .agent_tool import anti_entropy_inspect from .catalog_entry import ANTI_ENTROPY_CATALOG_ENTRY from .cli import run_anti_entropy_verify from .contract import ( @@ -24,6 +25,7 @@ "AntiEntropyFinding", "AntiEntropyReceipt", "RetirementBasis", + "anti_entropy_inspect", "audit_ghost_code", "evaluate_diff_hygiene", "run_anti_entropy_verify", diff --git a/loopx/capabilities/anti_entropy/agent_tool.py b/loopx/capabilities/anti_entropy/agent_tool.py new file mode 100644 index 0000000000..cbd606ab7d --- /dev/null +++ b/loopx/capabilities/anti_entropy/agent_tool.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import Any + +from .cli import run_anti_entropy_verify + + +def anti_entropy_inspect( + *, + diff_text: str, + retirement_basis: dict[str, Any] | None = None, + goal_id: str | None = None, +) -> dict[str, Any]: + """Inspect unified diff against a declared retirement basis and diff hygiene rules. + + This tool allows autonomous agents to verify that deprecated symbols and files + are genuinely decommissioned (delete-first) and that no ghost code or zombie + wrappers are reintroduced, returning an immutable structured receipt. + """ + return run_anti_entropy_verify( + diff_text=diff_text, + basis_data=retirement_basis, + goal_id=goal_id, + ) diff --git a/loopx/capabilities/anti_entropy/oracles.py b/loopx/capabilities/anti_entropy/oracles.py index f41b9b00dd..9949a38e9b 100644 --- a/loopx/capabilities/anti_entropy/oracles.py +++ b/loopx/capabilities/anti_entropy/oracles.py @@ -15,16 +15,55 @@ RetirementBasis, ) -_DEF_OR_CLASS_RE = re.compile(r"^\s*(?:async\s+def|def|class)\s+([A-Za-z_][A-Za-z0-9_]*)") +_PY_DEF_OR_CLASS_RE = re.compile(r"^\s*(?:async\s+def|def|class)\s+([A-Za-z_][A-Za-z0-9_]*)") + +_TS_DEF_PATTERNS = [ + # function / async function / export function + re.compile(r"^\s*(?:export\s+(?:default\s+)?)?(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)"), + # class / abstract class / export class + re.compile(r"^\s*(?:export\s+(?:default\s+)?)?(?:abstract\s+)?class\s+([A-Za-z_$][A-Za-z0-9_$]*)"), + # interface + re.compile(r"^\s*(?:export\s+)?interface\s+([A-Za-z_$][A-Za-z0-9_$]*)"), + # type alias: type Foo = ... or type Foo = ... + re.compile(r"^\s*(?:export\s+)?type\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:<[^>]*>)?\s*="), + # enum + re.compile(r"^\s*(?:export\s+)?(?:const\s+)?enum\s+([A-Za-z_$][A-Za-z0-9_$]*)"), + # const / let / var Foo = (...) => or = async (...) => (with optional generics) + re.compile( + r"^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?::[^=]+)?=\s*(?:async\s*)?(?:<[^>]*>)?\s*(?:\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>" + ), + # const / let / var Foo = function + re.compile( + r"^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?::[^=]+)?=\s*(?:async\s+)?function" + ), + # const / let / var Foo = (multiline signature start) + re.compile( + r"^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?::[^=]+)?=\s*(?:async\s*)?(?:<[^>]*>)?\s*\(" + ), +] + _COMMENT_LINE_RE = re.compile(r"^\s*(?:#|//)\s*(.+)$") +_DOC_EXTENSIONS = {".md", ".rst", ".txt", ".json", ".yaml", ".yml", ".toml"} +_TS_EXTENSIONS = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"} +_PY_EXTENSIONS = {".py", ".pyi"} + def _hash_diff(diff_text: str) -> str: return hashlib.sha256(diff_text.encode("utf-8")).hexdigest() -def unquote_git_path(path_str: str) -> str: - """Unquote and normalize Git C-style quoted paths with octal or hex escapes.""" +def find_repo_root(start_path: Path | None = None) -> Path: + """Resolve repository root by looking for .git folder up the tree.""" + cur = (start_path or Path.cwd()).resolve() + for parent in [cur, *cur.parents]: + if (parent / ".git").exists(): + return parent + return cur + + +def decode_git_diff_path(path_str: str, is_source: bool = True) -> str: + """Decode a Git diff path with optional C-style quotes, stripping Git a/ or b/ prefix.""" path_str = path_str.strip() if path_str.startswith('"') and path_str.endswith('"') and len(path_str) >= 2: inner = path_str[1:-1] @@ -34,9 +73,70 @@ def unquote_git_path(path_str: str) -> str: except Exception: path_str = inner path_str = path_str.replace("\\", "/").strip() - if path_str.startswith("a/") or path_str.startswith("b/"): + if path_str == "/dev/null": + return "/dev/null" + if is_source and path_str.startswith("a/"): + return path_str[2:] + elif not is_source and path_str.startswith("b/"): + return path_str[2:] + elif path_str.startswith("a/") or path_str.startswith("b/"): + return path_str[2:] + return path_str + + +def normalize_repo_path(path_str: str) -> str: + """Normalize a repository-relative target path from RetirementBasis. + + Does not strip real directory segments (e.g. "a/") or leading hidden dots (e.g. ".api.py"). + """ + path_str = path_str.strip() + if path_str.startswith('"') and path_str.endswith('"') and len(path_str) >= 2: + inner = path_str[1:-1] + try: + raw_bytes = codecs.escape_decode(inner.encode("latin1"))[0] + path_str = raw_bytes.decode("utf-8") + except Exception: + path_str = inner + path_str = path_str.replace("\\", "/").strip() + while path_str.startswith("./"): path_str = path_str[2:] - return path_str.lstrip("./").lstrip("/") + while path_str.startswith("/"): + path_str = path_str[1:] + return path_str + + +def unquote_git_path(path_str: str) -> str: + """Backward-compatible helper for unquoting git diff paths.""" + return decode_git_diff_path(path_str) + + +def parse_diff_git_header(line: str) -> tuple[str, str] | None: + """Parse diff --git line correctly, handling paths with spaces and quotes.""" + line = line.strip() + if not line.startswith("diff --git "): + return None + rest = line[len("diff --git "):].strip() + + # Case 1: Both quoted, e.g. "a/foo bar" "b/foo bar" + m_quoted = re.match(r'^("(?:[^"\\]|\\.)+")\s+("(?:[^"\\]|\\.)+")$', rest) + if m_quoted: + return m_quoted.group(1), m_quoted.group(2) + + # Case 2: Identical relative paths across a/ and b/ (deletion / modification) + m_same = re.match(r"^a/(.+)\s+b/\1$", rest) + if m_same: + return f"a/{m_same.group(1)}", f"b/{m_same.group(1)}" + + # Case 3: Search for separator " b/" from the right + if rest.startswith("a/"): + idx = rest.rfind(" b/") + if idx != -1: + return rest[:idx], rest[idx + 1:] + + parts = rest.split(None, 1) + if len(parts) == 2: + return parts[0], parts[1] + return None def _extract_deleted_paths(diff_text: str) -> set[str]: @@ -48,30 +148,38 @@ def _extract_deleted_paths(diff_text: str) -> set[str]: for sec in diff_sections: if not sec.startswith("diff --git "): continue - header_match = re.match(r"^diff --git\s+(.+?)\s+(.+?)(?:\n|$)", sec) - if not header_match: - continue - src_raw = header_match.group(1).strip() + first_line = sec.splitlines()[0] is_deleted = bool( re.search(r"^deleted file mode \d+", sec, re.MULTILINE) or re.search(r"^\+\+\+\s+/dev/null", sec, re.MULTILINE) ) if is_deleted: - clean_path = unquote_git_path(src_raw) - if clean_path: - deleted_paths.add(clean_path) + # Check for standard patch header: --- a/\n+++ /dev/null + m_patch = re.search(r"^---\s+(.+?)\n\+\+\+\s+/dev/null", sec, re.MULTILINE) + if m_patch: + clean_path = decode_git_diff_path(m_patch.group(1).strip(), is_source=True) + if clean_path: + deleted_paths.add(clean_path) + continue + + # Empty file deletion with no content hunks (deleted file mode 100644 only) + header_paths = parse_diff_git_header(first_line) + if header_paths: + clean_path = decode_git_diff_path(header_paths[0], is_source=True) + if clean_path: + deleted_paths.add(clean_path) # 2. Also match standard --- \n+++ /dev/null for diffs without diff --git for match in re.finditer(r"^---\s+(.+?)\n\+\+\+\s+/dev/null", diff_text, re.MULTILINE): raw_path = match.group(1).strip() - clean_path = unquote_git_path(raw_path) + clean_path = decode_git_diff_path(raw_path, is_source=True) if clean_path: deleted_paths.add(clean_path) return deleted_paths -def _extract_defs_from_code(code: str) -> set[str]: +def _extract_python_defs_from_code(code: str) -> set[str]: if not code.strip(): return set() dedented = textwrap.dedent(code) @@ -96,10 +204,10 @@ def _extract_defs_from_code(code: str) -> set[str]: return set() -def _extract_signature_defs(lines: list[str]) -> set[str]: +def _extract_python_signature_defs(lines: list[str]) -> set[str]: defs: set[str] = set() for line in lines: - m = _DEF_OR_CLASS_RE.match(line) + m = _PY_DEF_OR_CLASS_RE.match(line) if m: name = m.group(1) try: @@ -109,14 +217,50 @@ def _extract_signature_defs(lines: list[str]) -> set[str]: if node.name == name: defs.add(name) except SyntaxError: - pass + defs.add(name) + return defs + + +def _extract_ts_defs(lines: list[str]) -> set[str]: + defs: set[str] = set() + for line in lines: + line_clean = line.strip() + if not line_clean or line_clean.startswith("//") or line_clean.startswith("/*") or line_clean.startswith("*"): + continue + for pattern in _TS_DEF_PATTERNS: + m = pattern.match(line) + if m: + defs.add(m.group(1)) + break return defs -def _analyze_diff_symbols(diff_text: str) -> tuple[set[str], set[str], set[str]]: +def _extract_defs_for_file(lines: list[str], file_path: str = "") -> set[str]: + code_text = "\n".join(lines) + ext = Path(file_path).suffix.lower() if file_path else "" + + if ext in _TS_EXTENSIONS: + return _extract_ts_defs(lines) + elif ext in _PY_EXTENSIONS: + return _extract_python_defs_from_code(code_text) | _extract_python_signature_defs(lines) + else: + py_defs = _extract_python_defs_from_code(code_text) | _extract_python_signature_defs(lines) + ts_defs = _extract_ts_defs(lines) + return py_defs | ts_defs + + +def _analyze_diff_symbols(diff_text: str) -> tuple[set[str], set[str], set[str], set[str], list[str]]: + """Analyze diff to extract deleted, reintroduced, surviving context, and disk defined symbols, + + plus all added lines for reference auditing. + Returns: + (deleted_symbols, reintroduced_symbols, surviving_context_defs, still_defined_on_disk, all_added_lines) + """ deleted_symbols: set[str] = set() reintroduced_symbols: set[str] = set() + surviving_context_defs: set[str] = set() still_defined_on_disk: set[str] = set() + all_added_lines: list[str] = [] diff_text = textwrap.dedent(diff_text) file_diffs = re.split(r"(?=^diff --git |^--- )", diff_text, flags=re.MULTILINE) @@ -125,13 +269,14 @@ def _analyze_diff_symbols(diff_text: str) -> tuple[set[str], set[str], set[str]] continue file_path = "" - m_git = re.search(r"^diff --git\s+(.+?)\s+(.+?)(?:\n|$)", f_diff, re.MULTILINE) - if m_git: - file_path = unquote_git_path(m_git.group(2).strip()) + first_line = f_diff.splitlines()[0] if f_diff.startswith("diff --git ") else "" + header_paths = parse_diff_git_header(first_line) if first_line else None + if header_paths: + file_path = decode_git_diff_path(header_paths[1], is_source=False) else: m_patch = re.search(r"^\+\+\+\s+(.+?)(?:\n|$)", f_diff, re.MULTILINE) if m_patch: - file_path = unquote_git_path(m_patch.group(1).strip()) + file_path = decode_git_diff_path(m_patch.group(1).strip(), is_source=False) hunk_texts = re.split(r"(?=^@@\s+-\d+.*?\s+\+\d+.*?\s+@@)", f_diff, flags=re.MULTILINE) for h_text in hunk_texts: @@ -143,22 +288,32 @@ def _analyze_diff_symbols(diff_text: str) -> tuple[set[str], set[str], set[str]] post_lines: list[str] = [] deleted_lines: list[str] = [] added_lines: list[str] = [] + context_lines: list[str] = [] for line in lines: if line.startswith(" "): - pre_lines.append(line[1:]) - post_lines.append(line[1:]) + clean_line = line[1:] + pre_lines.append(clean_line) + post_lines.append(clean_line) + context_lines.append(clean_line) elif line.startswith("-") and not line.startswith("---"): - pre_lines.append(line[1:]) - deleted_lines.append(line[1:]) + clean_line = line[1:] + pre_lines.append(clean_line) + deleted_lines.append(clean_line) elif line.startswith("+") and not line.startswith("+++"): - post_lines.append(line[1:]) - added_lines.append(line[1:]) + clean_line = line[1:] + post_lines.append(clean_line) + added_lines.append(clean_line) + all_added_lines.append(clean_line) + + pre_defs = _extract_defs_for_file(pre_lines, file_path) + post_defs = _extract_defs_for_file(post_lines, file_path) + deleted_defs = _extract_defs_for_file(deleted_lines, file_path) + added_defs = _extract_defs_for_file(added_lines, file_path) + ctx_defs = _extract_defs_for_file(context_lines, file_path) - pre_defs = _extract_defs_from_code("\n".join(pre_lines)) - post_defs = _extract_defs_from_code("\n".join(post_lines)) - added_defs = _extract_defs_from_code("\n".join(added_lines)) | _extract_signature_defs(added_lines) - deleted_defs = _extract_defs_from_code("\n".join(deleted_lines)) | _extract_signature_defs(deleted_lines) + for s in ctx_defs: + surviving_context_defs.add(s) for s in pre_defs: if s not in post_defs: @@ -170,16 +325,20 @@ def _analyze_diff_symbols(diff_text: str) -> tuple[set[str], set[str], set[str]] for s in added_defs: reintroduced_symbols.add(s) - for s in post_defs: - if s in added_defs or s in deleted_defs: - reintroduced_symbols.add(s) - if file_path and file_path != "/dev/null": - disk_p = Path(file_path) + is_file_deleted = bool( + re.search(r"^deleted file mode \d+", f_diff, re.MULTILINE) + or re.search(r"^\+\+\+\s+/dev/null", f_diff, re.MULTILINE) + ) + if file_path and file_path != "/dev/null" and not is_file_deleted: + repo_root = find_repo_root() + disk_p = (repo_root / file_path).resolve() + if not disk_p.is_file(): + disk_p = Path(file_path).resolve() if disk_p.is_file(): try: content = disk_p.read_text(encoding="utf-8") - disk_defs = _extract_defs_from_code(content) + disk_defs = _extract_defs_for_file(content.splitlines(), file_path) for s in disk_defs: still_defined_on_disk.add(s) if s in deleted_symbols: @@ -187,7 +346,7 @@ def _analyze_diff_symbols(diff_text: str) -> tuple[set[str], set[str], set[str]] except Exception: pass - return deleted_symbols, reintroduced_symbols, still_defined_on_disk + return deleted_symbols, reintroduced_symbols, surviving_context_defs, still_defined_on_disk, all_added_lines def verify_delete_first( @@ -235,23 +394,35 @@ def verify_delete_first( ) return findings - deleted_symbols, reintroduced_symbols, still_defined_on_disk = _analyze_diff_symbols(diff_text) + deleted_symbols, reintroduced_symbols, surviving_context_defs, still_defined_on_disk, all_added_lines = _analyze_diff_symbols(diff_text) deleted_paths = _extract_deleted_paths(diff_text) for symbol in basis.target_symbols: - if symbol in reintroduced_symbols or symbol in still_defined_on_disk: + # Check if the symbol is reintroduced as definition OR actively referenced/called in added code + is_reintroduced = symbol in reintroduced_symbols + if not is_reintroduced: + sym_pattern = re.compile(r"\b" + re.escape(symbol) + r"\b") + for added_l in all_added_lines: + clean_added = added_l.strip() + if clean_added.startswith(("#", "//", "/*", "*")): + continue + if sym_pattern.search(clean_added): + is_reintroduced = True + break + + if is_reintroduced: findings.append( AntiEntropyFinding( rule_id="AE002-ZOMBIE_SYMBOL_REINTRODUCED", severity="blocking", message=( f"Target symbol '{symbol}' was slated for retirement but reintroduced " - "or retained as an active definition in the diff or repository." + "as an active definition or called in added lines in the diff or repository." ), location=symbol, ) ) - elif symbol not in deleted_symbols: + elif symbol not in deleted_symbols or symbol in surviving_context_defs or symbol in still_defined_on_disk: findings.append( AntiEntropyFinding( rule_id="AE001-DELETE_FIRST_VIOLATION", @@ -266,7 +437,7 @@ def verify_delete_first( ) for path in basis.target_paths: - norm_path = unquote_git_path(path) + norm_path = normalize_repo_path(path) if norm_path not in deleted_paths: findings.append( AntiEntropyFinding( @@ -289,47 +460,74 @@ def audit_ghost_code(diff_text: str) -> list[AntiEntropyFinding]: start_line = "" diff_text = textwrap.dedent(diff_text) - for line in diff_text.splitlines(): - if not line.startswith("+") or line.startswith("+++"): - consecutive_commented_code = 0 + file_diffs = re.split(r"(?=^diff --git |^--- )", diff_text, flags=re.MULTILINE) + + for f_diff in file_diffs: + if not f_diff.strip(): continue - raw = line[1:].strip() - comment_match = _COMMENT_LINE_RE.match(raw) - if comment_match: - comment_content = comment_match.group(1).strip() - is_code_stmt = False - if _DEF_OR_CLASS_RE.match(comment_content): - is_code_stmt = True - else: - try: - ast.parse(comment_content) - is_code_stmt = True - except SyntaxError: - is_code_stmt = False + file_path = "" + m_git = re.search(r"^diff --git\s+(.+?)\s+(.+?)(?:\n|$)", f_diff, re.MULTILINE) + if m_git: + file_path = unquote_git_path(m_git.group(2).strip()) + else: + m_patch = re.search(r"^\+\+\+\s+(.+?)(?:\n|$)", f_diff, re.MULTILINE) + if m_patch: + file_path = unquote_git_path(m_patch.group(1).strip()) - if is_code_stmt: - consecutive_commented_code += 1 - if consecutive_commented_code == 1: - start_line = raw + ext = Path(file_path).suffix.lower() if file_path else "" + if ext in _DOC_EXTENSIONS: + continue + + consecutive_commented_code = 0 + start_line = "" + + for line in f_diff.splitlines(): + if not line.startswith("+") or line.startswith("+++"): + consecutive_commented_code = 0 + continue + + raw = line[1:].strip() + comment_match = _COMMENT_LINE_RE.match(raw) + if comment_match: + comment_content = comment_match.group(1).strip() + is_code_stmt = False + + if _PY_DEF_OR_CLASS_RE.match(comment_content): + is_code_stmt = True + elif any(p.match(comment_content) for p in _TS_DEF_PATTERNS): + is_code_stmt = True + elif comment_content.endswith(";") or comment_content.startswith(("import ", "from ", "export ", "return ", "if ", "for ")): + is_code_stmt = True + else: + try: + ast.parse(comment_content) + is_code_stmt = True + except SyntaxError: + is_code_stmt = False + + if is_code_stmt: + consecutive_commented_code += 1 + if consecutive_commented_code == 1: + start_line = raw + else: + consecutive_commented_code = 0 else: consecutive_commented_code = 0 - else: - consecutive_commented_code = 0 - if consecutive_commented_code >= 3: - findings.append( - AntiEntropyFinding( - rule_id="AE003-COMMENTED_CODE_ADVISORY", - severity="advisory", - message=( - "Detected 3+ consecutive lines of commented-out code. " - "Consider complete removal under version control rather than ghost commenting." - ), - location=start_line[:80], + if consecutive_commented_code >= 3: + findings.append( + AntiEntropyFinding( + rule_id="AE003-COMMENTED_CODE_ADVISORY", + severity="advisory", + message=( + "Detected 3+ consecutive lines of commented-out code. " + "Consider complete removal under version control rather than ghost commenting." + ), + location=start_line[:80], + ) ) - ) - consecutive_commented_code = 0 + consecutive_commented_code = 0 return findings @@ -356,15 +554,22 @@ def evaluate_diff_hygiene( else RetirementBasis.from_dict(retirement_basis) ) if basis.target_symbols and diff_text.strip(): - deleted_symbols, reintroduced_symbols, still_defined_on_disk = _analyze_diff_symbols(diff_text) + deleted_symbols, reintroduced_symbols, surviving_context_defs, still_defined_on_disk, all_added_lines = _analyze_diff_symbols(diff_text) failing_symbols = { f.location for f in all_findings if f.location and f.rule_id in ("AE001-DELETE_FIRST_VIOLATION", "AE002-ZOMBIE_SYMBOL_REINTRODUCED") } for s in basis.target_symbols: + sym_pattern = re.compile(r"\b" + re.escape(s) + r"\b") + has_added_ref = any( + sym_pattern.search(l) for l in all_added_lines + if not l.strip().startswith(("#", "//", "/*", "*")) + ) if ( s in deleted_symbols and s not in reintroduced_symbols + and not has_added_ref + and s not in surviving_context_defs and s not in still_defined_on_disk and s not in failing_symbols ): diff --git a/tests/capabilities/test_anti_entropy.py b/tests/capabilities/test_anti_entropy.py index 62be6ecc96..615aa00520 100644 --- a/tests/capabilities/test_anti_entropy.py +++ b/tests/capabilities/test_anti_entropy.py @@ -401,3 +401,205 @@ def test_reintroduced_symbol_excluded_from_retired_symbols_with_partial_retireme assert any(f.rule_id == "AE002-ZOMBIE_SYMBOL_REINTRODUCED" and f.location == "old_api" for f in receipt.findings) # old_api is excluded from retired_symbols, but old_helper is accurately certified! assert receipt.retired_symbols == ("old_helper",) + + +def test_typescript_clean_delete_first() -> None: + """Verify TypeScript functions, classes, interfaces, and types are accurately recognized as retired.""" + diff = """ +--- a/control_plane.ts ++++ b/control_plane.ts +@@ -1,15 +1,2 @@ +-export function oldLeaseAcquire(taskId: string): boolean { +- return false; +-} +-export class OldScheduler { +- run(): void {} +-} +-export interface OldLeaseConfig { +- timeoutMs: number; +-} +-export type OldLeaseState = "active" | "expired"; +-export const oldArrowHelper = (x: number) => x * 2; ++export function newLeaseAcquire(): boolean { return true; } +""" + basis = RetirementBasis( + target_symbols=( + "oldLeaseAcquire", + "OldScheduler", + "OldLeaseConfig", + "OldLeaseState", + "oldArrowHelper", + ), + ) + receipt = evaluate_diff_hygiene(diff, basis) + assert receipt.verdict == "passed", f"findings: {receipt.findings}" + assert set(receipt.retired_symbols) == { + "oldLeaseAcquire", + "OldScheduler", + "OldLeaseConfig", + "OldLeaseState", + "oldArrowHelper", + } + + +def test_typescript_call_deletion_does_not_retire_definition() -> None: + """Verify deleting a TypeScript function call without deleting definition triggers AE001.""" + diff = """ +--- a/control_plane.ts ++++ b/control_plane.ts +@@ -1,7 +1,7 @@ + export function oldLeaseAcquire(): boolean { + return false; + } + export function run() { +- return oldLeaseAcquire(); ++ return newLeaseAcquire(); + } +""" + basis = RetirementBasis(target_symbols=("oldLeaseAcquire",)) + receipt = evaluate_diff_hygiene(diff, basis) + assert receipt.verdict == "rejected" + assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt.findings) + assert "oldLeaseAcquire" not in receipt.retired_symbols + + +def test_typescript_zombie_symbol_reintroduced() -> None: + """Verify reintroducing an old TypeScript function as a wrapper triggers AE002.""" + diff = """ +--- a/control_plane.ts ++++ b/control_plane.ts +@@ -1,4 +1,4 @@ +-export function oldTaskLease() { return 1; } ++export function oldTaskLease() { return newTaskLease(); } +""" + basis = RetirementBasis(target_symbols=("oldTaskLease",)) + receipt = evaluate_diff_hygiene(diff, basis) + assert receipt.verdict == "rejected" + assert any(f.rule_id == "AE002-ZOMBIE_SYMBOL_REINTRODUCED" for f in receipt.findings) + + +def test_offline_self_contained_diff_without_disk_access() -> None: + """Verify diff verification is self-contained and works for non-existent disk paths.""" + diff = """ +--- a/non_existent_dir/virtual_module.py ++++ b/non_existent_dir/virtual_module.py +@@ -1,5 +1,2 @@ +-def obsolete_worker(): +- return None ++def active_worker(): return 1 +""" + basis = RetirementBasis(target_symbols=("obsolete_worker",)) + receipt = evaluate_diff_hygiene(diff, basis) + assert receipt.verdict == "passed" + assert receipt.retired_symbols == ("obsolete_worker",) + + +def test_agent_tool_inspect_interface() -> None: + """Verify the structured agent tool anti_entropy_inspect returns complete, valid payload.""" + from loopx.capabilities.anti_entropy.agent_tool import anti_entropy_inspect + + diff = """ +--- a/service.ts ++++ b/service.ts +@@ -1,4 +1,2 @@ +-export function legacyHandler() {} ++export function modernHandler() {} +""" + payload = anti_entropy_inspect( + diff_text=diff, + retirement_basis={"target_symbols": ["legacyHandler"]}, + goal_id="goal-test-tool", + ) + assert payload["ok"] is True + assert payload["command"] == "verify" + assert payload["goal_id"] == "goal-test-tool" + assert payload["retired_symbols"] == ["legacyHandler"] + assert payload["verdict"] == "passed" + + +def test_path_normalization_distinguishes_a_dir_and_dot_files() -> None: + """Verify a/api.py, api.py, and .api.py are never conflated.""" + diff_delete_root = """ +--- a/api.py ++++ /dev/null +@@ -1,2 +0,0 @@ +-def f(): pass +""" + basis_a = RetirementBasis(target_paths=("a/api.py",)) + receipt_a = evaluate_diff_hygiene(diff_delete_root, basis_a) + assert receipt_a.verdict == "rejected" + assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" and f.location == "a/api.py" for f in receipt_a.findings) + + basis_dot = RetirementBasis(target_paths=(".api.py",)) + receipt_dot = evaluate_diff_hygiene(diff_delete_root, basis_dot) + assert receipt_dot.verdict == "rejected" + assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" and f.location == ".api.py" for f in receipt_dot.findings) + + diff_delete_nested_a = """ +--- a/a/api.py ++++ /dev/null +@@ -1,2 +0,0 @@ +-def f(): pass +""" + receipt_nested = evaluate_diff_hygiene(diff_delete_nested_a, basis_a) + assert receipt_nested.verdict == "passed" + assert len(receipt_nested.findings) == 0 + + diff_delete_dot = """ +--- a/.api.py ++++ /dev/null +@@ -1,2 +0,0 @@ +-def f(): pass +""" + receipt_dot_pass = evaluate_diff_hygiene(diff_delete_dot, basis_dot) + assert receipt_dot_pass.verdict == "passed" + assert len(receipt_dot_pass.findings) == 0 + + +def test_empty_file_deletion_with_spaces_in_filename() -> None: + """Verify unquoted git diff headers for empty files with spaces in names are correctly recognized.""" + diff_spaces = """ +diff --git a/legacy/old api.py b/legacy/old api.py +deleted file mode 100644 +index e69de29..0000000 +""" + basis = RetirementBasis(target_paths=("legacy/old api.py",)) + receipt = evaluate_diff_hygiene(diff_spaces, basis) + assert receipt.verdict == "passed", f"findings: {receipt.findings}" + assert len(receipt.findings) == 0 + + +def test_docstring_sample_not_certified_when_function_retained_on_disk(tmp_path: Path, monkeypatch) -> None: + """Verify deleting docstring example lines does not falsely certify a function retained on disk, even from subdirs.""" + repo_root = tmp_path / "mock_repo" + repo_root.mkdir() + (repo_root / ".git").mkdir() + subdir = repo_root / "nested" / "sub" + subdir.mkdir(parents=True) + + module_file = repo_root / "service.py" + module_file.write_text( + """\"\"\"\nExample:\n def old_api():\n pass\n\"\"\"\n\ndef old_api():\n return 42\n""", + encoding="utf-8", + ) + + diff_docstring = """ +--- a/service.py ++++ b/service.py +@@ -3,2 +3,0 @@ +- def old_api(): +- pass +""" + basis = RetirementBasis(target_symbols=("old_api",)) + + monkeypatch.chdir(repo_root) + receipt_root = evaluate_diff_hygiene(diff_docstring, basis) + assert receipt_root.verdict == "rejected" + assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt_root.findings) + assert "old_api" not in receipt_root.retired_symbols + + monkeypatch.chdir(subdir) + receipt_sub = evaluate_diff_hygiene(diff_docstring, basis) + assert receipt_sub.verdict == "rejected" + assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt_sub.findings) + assert "old_api" not in receipt_sub.retired_symbols From 86044052afc279ea2fe0aa91be979d1bc476fe7a Mon Sep 17 00:00:00 2001 From: LoopDesk Date: Thu, 29 Oct 2026 18:14:04 +0800 Subject: [PATCH 3/7] fix(anti-entropy): bind source evidence and wire task-scoped feedback loop Address PR #7545 review: require explicit workspace binding so symbol retirement is certified only against complete post-change sources (AE004 otherwise), parse bound sources with the AST instead of merging line signatures and free-text matches, exclude documentation files from symbol evidence, and record receipt provenance (basis_hash, source_root, source_evidence). Add loopx anti-entropy inspect as the in-flight progress-fact entry with the anti_entropy_inspect tool descriptor and turn-context projection; delivery acceptance stays owned by change-quality. Rewrite compare_arms.py as an honest isolated sandbox demonstration and fix Python 3.11 f-string syntax and ruff findings. Signed-off-by: LoopDesk --- .../anti-entropy-benchmark-decisions.md | 22 +- examples/anti-entropy-governance-smoke.py | 48 +- examples/anti_entropy/compare_arms.py | 363 +++++----- loopx/capabilities/anti_entropy/README.md | 75 ++- loopx/capabilities/anti_entropy/__init__.py | 8 + loopx/capabilities/anti_entropy/agent_tool.py | 7 +- .../anti_entropy/catalog_entry.py | 24 +- loopx/capabilities/anti_entropy/cli.py | 11 +- loopx/capabilities/anti_entropy/contract.py | 14 +- loopx/capabilities/anti_entropy/feedback.py | 211 ++++++ loopx/capabilities/anti_entropy/oracles.py | 619 ++++++++++++++---- loopx/cli_commands/anti_entropy.py | 96 +++ tests/capabilities/test_anti_entropy.py | 355 ++++++++-- .../test_anti_entropy_feedback.py | 177 +++++ 14 files changed, 1667 insertions(+), 363 deletions(-) create mode 100644 loopx/capabilities/anti_entropy/feedback.py create mode 100644 tests/capabilities/test_anti_entropy_feedback.py diff --git a/docs/decisions/anti-entropy-benchmark-decisions.md b/docs/decisions/anti-entropy-benchmark-decisions.md index fb1a3d9245..6ce25664ed 100644 --- a/docs/decisions/anti-entropy-benchmark-decisions.md +++ b/docs/decisions/anti-entropy-benchmark-decisions.md @@ -27,9 +27,9 @@ LoopX possesses an existing deterministic AST-based anti-entropy engine (`loopx/ - **Considered Alternatives**: - *Option 1*: Pure prompt guidance (soft constraint). Zero tooling overhead, but models consistently regress under multi-file pressure. - *Option 2*: Post-hoc terminal gate only. Verifies diff at submission; fails late when the agent has already exhausted budget, leading to blind rollback loops. - - *Option 3 (Selected)*: **Two-Tier Closed Loop (Execution Tool + Submission Gate)**. - - Exposes `anti_entropy_inspect` as an advisory tool during execution so the agent can self-audit diff cleanliness. - - Chains `loopx anti-entropy verify` into the delivery evaluation command, failing the run if `AntiEntropyReceipt.verdict != "passed"`. + - *Option 3 (Selected, partially delivered)*: **Two-Tier Closed Loop (Execution Tool + Submission Gate)**. + - Delivered now: the execution tier as an explicitly callable entry (`anti_entropy_inspect` tool descriptor plus `loopx anti-entropy inspect`) returning actionable progress-fact feedback, and the gate tier as a named delivery route — final blocking stays owned by the existing change-quality `prepare/record/verify` receipt, not a parallel authority. + - Not yet proven: that models actually adopt the tool and prefer deletion in long-horizon runs. That claim requires actual recorded runs and is intentionally not asserted by this ADR or the comparator. - **Trade-off**: Increases tool-call rounds and slight token overhead per run, in exchange for deterministic first-pass cleanliness and eliminating expensive blind rollbacks. --- @@ -40,7 +40,8 @@ LoopX possesses an existing deterministic AST-based anti-entropy engine (`loopx/ - *Option 2*: Dynamic runtime AST inference without declaration. Prone to false positives when agents refactor internal helper functions. - *Option 3 (Selected)*: **Declarative Sidecar / Task-Envelope Hook Pattern**. - The task specification explicitly provides an `anti_entropy_basis` (target symbols and target paths to retire). - - The benchmark/test runner wraps the task externally: conditionally injects the inspect tool into the agent envelope and appends the verify CLI command to the test command. + - Delivered now: the typed turn-context projection (`build_anti_entropy_turn_context`) a host can inject when the capability is enabled for the task, and the explicit CLI/tool entries agents call. + - Named follow-up (not delivered here): a runner that conditionally injects the inspect tool into a host agent envelope and appends the verify command to a test command. Owner: the host integration that owns agent envelopes; post-writeback typed intent stays with the provider-neutral post-writeback capability-hooks RFC, including failure isolation and idempotence. - `loopx/control_plane/` remains completely unmodified (0-line diff). - **Trade-off**: Requires test cases to declare their target retirement list in advance, rather than inferring intent magically. @@ -53,7 +54,7 @@ LoopX possesses an existing deterministic AST-based anti-entropy engine (`loopx/ - *Option 3 (Selected)*: **Self-Contained Realistic Engineering Playground in `examples/anti_entropy/`**. - A multi-module Python service (protocol parser + streaming receiver + client caller + unit test suite). - Real task: Migrate all callers from `LegacyProtocolParser` to `StreamMessageParser` and physically delete `legacy_parser.py`. - - Automated comparator evaluates Arm 1 (Baseline), Arm 2 (Prompt Only), and Arm 3 (Two-Tier Anti-Entropy). + - The comparator is a deterministic, fixed-input sandbox demonstration: per arm it seeds a disposable git sandbox, applies the fixed post-state, captures the real diff with git, executes the case-study functional tests with pytest, and evaluates the oracle against the bound sandbox workspace. It measures oracle semantics only — it launches no model runs and measures no agent-runtime behavior, so its output must not be read as an agent-effectiveness result. - **Trade-off**: Tests on a standalone engineering playground rather than mutating production LoopX code directly. --- @@ -67,7 +68,16 @@ LoopX possesses an existing deterministic AST-based anti-entropy engine (`loopx/ --- -## 4. Impacted System Components & Retired Paths +## 4. Delivered Boundary (This PR) + +- **Delivered**: default-off capability with explicit typed entries — `loopx anti-entropy verify` (delivery-gate semantics, exit 1 on rejection), `loopx anti-entropy inspect` (in-flight progress-fact feedback, exit 0), the `anti_entropy_inspect` tool descriptor registered in the capability catalog, the `build_anti_entropy_turn_context` projection, and the single-task feedback cycle classifying findings as actionable progress facts. Symbol retirement certification requires an explicitly bound workspace (`--workspace`); diff-only receipts may reject but never certify, and evidence provenance (`basis_hash`, `source_root`, `source_evidence`) is recorded on every receipt. +- **Delivery authority**: unchanged and owned by change-quality (`prepare/record/verify`, exact diff scope, receipt invalidation). This capability supplies evidence and names the delivery route; it writes no delivery receipt. +- **Not claimed**: model adoption of the loop in real agent runs, measured entropy reduction on live workloads, or any comparator-derived effectiveness number. Those require the recorded-run evaluation program (Decision 3) executed for real, and the envelope-injection runner from Decision 2. +- **Source binding invariants**: complete post-change sources are AST-parsed; documentation files never contribute symbol evidence; string literals, comments, and docstring examples are never counted as references or definitions; a missing bound source is evidence-insufficient (AE004), never silently skipped. + +--- + +## 5. Impacted System Components & Retired Paths - **Impacted Components**: - `docs/decisions/anti-entropy-benchmark-decisions.md`: Canonical ADR record. diff --git a/examples/anti-entropy-governance-smoke.py b/examples/anti-entropy-governance-smoke.py index 0f8ab206b4..edfc4e149a 100755 --- a/examples/anti-entropy-governance-smoke.py +++ b/examples/anti-entropy-governance-smoke.py @@ -1,20 +1,32 @@ #!/usr/bin/env python3 -"""Smoke test for anti-entropy governance: AST-based delete-first verification and diff hygiene.""" +"""Smoke test for anti-entropy governance: AST-based delete-first verification and diff hygiene. + +Exercises the source-binding contract: symbol retirement certification requires +an explicitly bound workspace with the complete post-change sources; diff-only +evidence can reject but never certifies. +""" from __future__ import annotations import sys +import tempfile from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(REPO_ROOT)) -from loopx.capabilities.anti_entropy.cli import run_anti_entropy_verify -from loopx.capabilities.anti_entropy.contract import ( +from loopx.capabilities.anti_entropy.cli import run_anti_entropy_verify # noqa: E402 +from loopx.capabilities.anti_entropy.contract import ( # noqa: E402 ANTI_ENTROPY_CAPABILITY_ID, RetirementBasis, ) -from loopx.capabilities.anti_entropy.oracles import evaluate_diff_hygiene +from loopx.capabilities.anti_entropy.oracles import evaluate_diff_hygiene # noqa: E402 + + +def _bound_workspace(content: str) -> Path: + workspace = Path(tempfile.mkdtemp(prefix="ae_smoke_ws_")) + (workspace / "calculator.py").write_text(content, encoding="utf-8") + return workspace def test_clean_delete_first() -> None: @@ -27,13 +39,32 @@ def test_clean_delete_first() -> None: +def new_add(a: int, b: int) -> int: + return a + b """ + workspace = _bound_workspace("def new_add(a: int, b: int) -> int:\n return a + b\n") basis = RetirementBasis( target_symbols=("old_add",), rationale="Replaced by new_add", ) - receipt = evaluate_diff_hygiene(diff, basis) + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) assert receipt.verdict == "passed", f"expected passed, got {receipt.verdict}" assert "old_add" in receipt.retired_symbols + assert receipt.source_evidence == "workspace_sources" + + +def test_diff_only_evidence_cannot_certify() -> None: + diff = """ +--- a/calculator.py ++++ b/calculator.py +@@ -1,5 +1,5 @@ +-def old_add(a: int, b: int) -> int: +- return a + b ++def new_add(a: int, b: int) -> int: ++ return a + b +""" + basis = RetirementBasis(target_symbols=("old_add",)) + receipt = evaluate_diff_hygiene(diff, basis) + assert receipt.verdict == "rejected", f"expected rejected, got {receipt.verdict}" + assert any(f.rule_id == "AE004-INSUFFICIENT_SOURCE_EVIDENCE" for f in receipt.findings) + assert receipt.retired_symbols == () def test_delete_first_violation() -> None: @@ -46,11 +77,12 @@ def test_delete_first_violation() -> None: def old_add(a: int, b: int) -> int: return a + b """ + workspace = _bound_workspace("def new_add(a: int, b: int) -> int:\n return a + b\ndef old_add(a: int, b: int) -> int:\n return a + b\n") basis = RetirementBasis( target_symbols=("old_add",), allow_compat_seam=False, ) - receipt = evaluate_diff_hygiene(diff, basis) + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) assert receipt.verdict == "rejected", f"expected rejected, got {receipt.verdict}" assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt.findings) @@ -79,10 +111,13 @@ def test_cli_runner() -> None: +class NewService: pass """ + workspace = Path(tempfile.mkdtemp(prefix="ae_smoke_ws_")) + (workspace / "module.py").write_text("class NewService:\n pass\n", encoding="utf-8") payload = run_anti_entropy_verify( diff_text=diff, basis_data={"target_symbols": ["OldService"]}, goal_id="test-goal-1", + workspace=workspace, ) assert payload["ok"] is True assert payload["capability_id"] == ANTI_ENTROPY_CAPABILITY_ID @@ -91,6 +126,7 @@ def test_cli_runner() -> None: def main() -> int: test_clean_delete_first() + test_diff_only_evidence_cannot_certify() test_delete_first_violation() test_zombie_reintroduced() test_cli_runner() diff --git a/examples/anti_entropy/compare_arms.py b/examples/anti_entropy/compare_arms.py index b0ac36ac51..d8c20fa620 100644 --- a/examples/anti_entropy/compare_arms.py +++ b/examples/anti_entropy/compare_arms.py @@ -1,187 +1,234 @@ -"""Comparator script evaluating Arms on the realistic refactoring case study. +"""Isolated end-to-end demonstration of anti-entropy governance on a fixed refactor task. -Measures the concrete difference between: -- Arm 1 (Baseline): No governance (leaves legacy files/wrappers; passes functional tests, fails cleanliness) -- Arm 2 (Control): Prompt-only soft constraint (leaves ghost commented code / shims) -- Arm 3 (Treatment): Anti-entropy two-tier closed loop (AST delete-first + verification gate) +For each arm the script executes a real isolated change inside a disposable git +sandbox: it seeds the case-study pre-state, writes the arm's fixed post-state, +captures the real unified diff with git, runs the case-study functional test +suite with pytest, and evaluates the anti-entropy oracle against the sandbox +workspace as the explicitly bound post-change source. + +This is a deterministic demonstration on hand-authored post-state fixtures. It +launches no model runs and measures no agent-runtime behavior; functional-test +results below are real executions of the seeded case-study tests. """ from __future__ import annotations import json +import os +import shutil +import subprocess import sys +import tempfile from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(REPO_ROOT)) - -from loopx.capabilities.anti_entropy.cli import run_anti_entropy_verify - - -def generate_arm1_diff() -> str: - """Arm 1: Agent modifies client.py but keeps legacy_parser.py as unused dead code.""" - return ( - "diff --git a/examples/anti_entropy/case_study/client.py b/examples/anti_entropy/case_study/client.py\n" - "--- a/examples/anti_entropy/case_study/client.py\n" - "+++ b/examples/anti_entropy/case_study/client.py\n" - "@@ -2,7 +2,7 @@\n" - " from typing import Any\n" - "-from .legacy_parser import LegacyMessageParser\n" - "+from .stream_parser import StreamMessageParser\n" - " \n" - " \n" - " class DataConsumer:\n" - "@@ -10,6 +10,6 @@\n" - " def __init__(self) -> None:\n" - "- self._parser = LegacyMessageParser()\n" - "+ self._parser = StreamMessageParser()\n" - " \n" - " def process_message(self, raw_input: str) -> dict[str, Any]:\n" - "- result = self._parser.decode(raw_input)\n" - "+ result = self._parser.parse(raw_input)\n" - " return {\n" - ) - - -def generate_arm2_diff() -> str: - """Arm 2: Agent edits legacy_parser.py but leaves ghost commented-out code and a shim.""" - return ( - "diff --git a/examples/anti_entropy/case_study/client.py b/examples/anti_entropy/case_study/client.py\n" - "--- a/examples/anti_entropy/case_study/client.py\n" - "+++ b/examples/anti_entropy/case_study/client.py\n" - "@@ -2,7 +2,7 @@\n" - " from typing import Any\n" - "-from .legacy_parser import LegacyMessageParser\n" - "+from .stream_parser import StreamMessageParser\n" - " \n" - " \n" - " class DataConsumer:\n" - "@@ -10,6 +10,6 @@\n" - " def __init__(self) -> None:\n" - "- self._parser = LegacyMessageParser()\n" - "+ self._parser = StreamMessageParser()\n" - " \n" - " def process_message(self, raw_input: str) -> dict[str, Any]:\n" - "- result = self._parser.decode(raw_input)\n" - "+ result = self._parser.parse(raw_input)\n" - " return {\n" - "diff --git a/examples/anti_entropy/case_study/legacy_parser.py b/examples/anti_entropy/case_study/legacy_parser.py\n" - "--- a/examples/anti_entropy/case_study/legacy_parser.py\n" - "+++ b/examples/anti_entropy/case_study/legacy_parser.py\n" - "@@ -4,15 +4,5 @@\n" - "-class LegacyMessageParser:\n" - "- def __init__(self, delimiter: str = \"|\") -> None:\n" - "- self.delimiter = delimiter\n" - "- def decode(self, raw_data: str) -> dict[str, Any]:\n" - "- pass\n" - "-def parse_legacy_frame(raw_data: str) -> dict[str, Any]:\n" - "- pass\n" - "+# Deprecated: do not use\n" - "+# def parse_legacy_frame(raw_data: str) -> dict[str, Any]:\n" - "+# return LegacyMessageParser().decode(raw_data)\n" - "+class LegacyMessageParser:\n" - "+ pass\n" - ) +CASE_STUDY_REL = "examples/anti_entropy/case_study" +sys.path.insert(0, str(REPO_ROOT)) -def generate_arm3_diff() -> str: - """Arm 3: Agent cleanly deletes legacy_parser.py (/dev/null) and updates client.py.""" - return ( - "diff --git a/examples/anti_entropy/case_study/client.py b/examples/anti_entropy/case_study/client.py\n" - "--- a/examples/anti_entropy/case_study/client.py\n" - "+++ b/examples/anti_entropy/case_study/client.py\n" - "@@ -2,7 +2,7 @@\n" - " from typing import Any\n" - "-from .legacy_parser import LegacyMessageParser\n" - "+from .stream_parser import StreamMessageParser\n" - " \n" - " \n" - " class DataConsumer:\n" - "@@ -10,6 +10,6 @@\n" - " def __init__(self) -> None:\n" - "- self._parser = LegacyMessageParser()\n" - "+ self._parser = StreamMessageParser()\n" - " \n" - " def process_message(self, raw_input: str) -> dict[str, Any]:\n" - "- result = self._parser.decode(raw_input)\n" - "+ result = self._parser.parse(raw_input)\n" - " return {\n" - "diff --git a/examples/anti_entropy/case_study/legacy_parser.py b/examples/anti_entropy/case_study/legacy_parser.py\n" - "deleted file mode 100644\n" - "--- a/examples/anti_entropy/case_study/legacy_parser.py\n" - "+++ /dev/null\n" - "@@ -1,30 +0,0 @@\n" - "-# Legacy message parser slated for retirement\n" - "-from typing import Any\n" - "-class LegacyMessageParser:\n" - "- pass\n" - "-def parse_legacy_frame(raw_data: str) -> dict[str, Any]:\n" - "- pass\n" +from loopx.capabilities.anti_entropy.cli import run_anti_entropy_verify # noqa: E402 + +_CLIENT_POST = '''"""Consumer client migrated to the modern stream parser.""" + +from typing import Any +from .stream_parser import StreamMessageParser + + +class DataConsumer: + """High-level data consumer.""" + + def __init__(self) -> None: + self._parser = StreamMessageParser() + + def process_message(self, raw_input: str) -> dict[str, Any]: + result = self._parser.parse(raw_input) + return { + "status": "ok", + "content": result["payload"], + "length": result["size"], + } +''' + +_LEGACY_ZOMBIE_POST = '''# Deprecated: do not use +# def parse_legacy_frame(raw_data: str) -> dict[str, Any]: +# return LegacyMessageParser().decode(raw_data) +class LegacyMessageParser: + pass +''' + +_CASE_STUDY_FILES = ( + "client.py", + "legacy_parser.py", + "stream_parser.py", + "tests/test_client.py", +) + +# Each arm is a fixed hand-authored post-state applied to the seeded pre-state. +# None means the file is deleted by this arm. +_ARM_POST_STATES: dict[str, dict[str, str | None]] = { + "Arm 1 (Baseline Pattern: legacy file kept)": { + f"{CASE_STUDY_REL}/client.py": _CLIENT_POST, + }, + "Arm 2 (Prompt-Only Pattern: zombie + ghost comments)": { + f"{CASE_STUDY_REL}/client.py": _CLIENT_POST, + f"{CASE_STUDY_REL}/legacy_parser.py": _LEGACY_ZOMBIE_POST, + }, + "Arm 3 (Anti-Entropy Pattern: clean /dev/null deletion)": { + f"{CASE_STUDY_REL}/client.py": _CLIENT_POST, + f"{CASE_STUDY_REL}/legacy_parser.py": None, + }, +} + + +def _run(cmd: list[str], cwd: Path, env: dict[str, str] | None = None) -> tuple[int, str]: + proc = subprocess.run( + cmd, + cwd=cwd, + capture_output=True, + env=env, + text=False, ) - - -def evaluate_arm(name: str, diff_text: str, basis_data: dict) -> dict: - result = run_anti_entropy_verify( - diff_text=diff_text, - basis_data=basis_data, - goal_id=f"eval-{name.lower()}", + output = (proc.stdout + proc.stderr).decode("utf-8", errors="replace") + return proc.returncode, output + + +def _seed_sandbox(sandbox: Path) -> None: + """Seed the case-study pre-state and commit it as the sandbox baseline.""" + case_study_src = REPO_ROOT / CASE_STUDY_REL + for rel in _CASE_STUDY_FILES: + target = sandbox / CASE_STUDY_REL / rel + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(case_study_src / rel, target) + # The task spec is the authoritative input declaring the retirement basis. + shutil.copy2(case_study_src / "task_spec.json", sandbox / CASE_STUDY_REL / "task_spec.json") + _run(["git", "init", "-q"], sandbox) + _run(["git", "add", "-A"], sandbox) + rc, out = _run( + [ + "git", + "-c", + "user.name=anti-entropy-demo", + "-c", + "user.email=demo@localhost", + "commit", + "-qm", + "seed case-study pre-state", + ], + sandbox, ) - lines_added = sum(1 for line in diff_text.splitlines() if line.startswith("+") and not line.startswith("+++")) - lines_removed = sum(1 for line in diff_text.splitlines() if line.startswith("-") and not line.startswith("---")) - net_diff_lines = lines_added - lines_removed - - return { - "arm": name, - "functional_tests": "passed", - "anti_entropy_verdict": result["verdict"], - "retired_symbols": result["retired_symbols"], - "blocking_findings": result["blocking_finding_count"], - "advisory_findings": result["advisory_finding_count"], - "findings": [f["message"] for f in result["receipt"]["findings"]], - "net_diff_lines": net_diff_lines, - "overall_quality": "REJECTED (ENTROPIC)" if result["verdict"] != "passed" else "ACCEPTED (CLEAN)", - } + if rc != 0: + raise RuntimeError(f"sandbox seed commit failed: {out}") + + +def _capture_diff(sandbox: Path) -> str: + rc, diff_text = _run(["git", "diff"], sandbox) + if rc != 0: + raise RuntimeError(f"git diff failed in sandbox: {diff_text}") + return diff_text + + +def _run_functional_tests(sandbox: Path) -> tuple[str, str]: + test_rel = f"{CASE_STUDY_REL}/tests/test_client.py" + env = dict(os.environ) + env["PYTHONPATH"] = str(sandbox) + rc, output = _run([sys.executable, "-m", "pytest", test_rel, "-q"], sandbox, env=env) + if rc == 0: + return "passed (executed in sandbox)", output + return f"failed (exit {rc}, executed in sandbox)", output + + +def evaluate_arm(name: str, post_state: dict[str, str | None], basis_data: dict) -> dict: + sandbox = Path(tempfile.mkdtemp(prefix="ae_arm_")) + try: + _seed_sandbox(sandbox) + for rel, content in post_state.items(): + target = sandbox / rel + if content is None: + target.unlink() + else: + target.write_text(content, encoding="utf-8") + + diff_text = _capture_diff(sandbox) + tests_result, tests_output = _run_functional_tests(sandbox) + result = run_anti_entropy_verify( + diff_text=diff_text, + basis_data=basis_data, + goal_id=f"fixture-{abs(hash(name)) % 10000}", + workspace=sandbox, + ) + + lines_added = sum( + 1 for line in diff_text.splitlines() if line.startswith("+") and not line.startswith("+++") + ) + lines_removed = sum( + 1 for line in diff_text.splitlines() if line.startswith("-") and not line.startswith("---") + ) + + return { + "arm": name, + "functional_tests": tests_result, + "functional_tests_output_tail": tests_output.strip().splitlines()[-1:] or [""], + "anti_entropy_verdict": result["verdict"], + "retired_symbols": result["retired_symbols"], + "blocking_findings": result["blocking_finding_count"], + "advisory_findings": result["advisory_finding_count"], + "findings": [f["message"] for f in result["receipt"]["findings"]], + "net_diff_lines": lines_added - lines_removed, + "source_evidence": result["receipt"]["source_evidence"], + } + finally: + shutil.rmtree(sandbox, ignore_errors=True) def main() -> int: - spec_path = Path("examples/anti_entropy/case_study/task_spec.json") + spec_path = REPO_ROOT / CASE_STUDY_REL / "task_spec.json" + if not spec_path.is_file(): + print(f"Notice: task specification not found at {spec_path}, demonstration skipped.") + return 0 + spec = json.loads(spec_path.read_text(encoding="utf-8")) - basis = spec["retirement_basis"] + basis = spec.get("retirement_basis", {}) + + objective = spec.get("objective", "") + target_symbols = basis.get("target_symbols", []) + target_paths = basis.get("target_paths", []) print("================================================================================") - print("Anti-Entropy Governance Arm Comparison (Three-Arm Factorial Contrast)") + print("Anti-Entropy Governance Isolated Sandbox Demonstration") print("================================================================================") - print(f"Task Objective: {spec["objective"]}") - print(f"Declared Retirement Targets: {basis["target_symbols"]}") - print(f"Declared Target Paths to Delete: {basis["target_paths"]}") + print(f"Task Objective: {objective}") + print(f"Declared Retirement Targets: {target_symbols}") + print(f"Declared Target Paths to Delete: {target_paths}") + print("Each arm: seeded git sandbox -> fixed post-state -> real git diff ->") + print("real pytest run -> oracle verify bound to the sandbox workspace.") print("--------------------------------------------------------------------------------\n") - arms = [ - ("Arm 1 (Baseline - No Governance)", generate_arm1_diff()), - ("Arm 2 (Control - Prompt Only)", generate_arm2_diff()), - ("Arm 3 (Treatment - Anti-Entropy Two-Tier)", generate_arm3_diff()), - ] - - for arm_name, diff in arms: - eval_res = evaluate_arm(arm_name, diff, basis) - print(f"[{eval_res["arm"]}]") - print(f" • Functional Tests: {eval_res["functional_tests"]}") - print(f" • Anti-Entropy Verdict: {eval_res["anti_entropy_verdict"]}") - print(f" • Overall Code Quality: {eval_res["overall_quality"]}") - print(f" • Net Diff Lines: {eval_res["net_diff_lines"]:+} lines") - print(f" • Cleanly Retired: {eval_res["retired_symbols"]}") - print(f" • Blocking Violations: {eval_res["blocking_findings"]}") - print(f" • Advisory Hygiene Flaws: {eval_res["advisory_findings"]}") - if eval_res["findings"]: - for f in eval_res["findings"]: - print(f" - Violation: {f}") + for arm_name, post_state in _ARM_POST_STATES.items(): + res = evaluate_arm(arm_name, post_state, basis) + print(f"[{res['arm']}]") + print(f" • Functional Tests: {res['functional_tests']}") + if res["functional_tests_output_tail"]: + print(f" - pytest tail: {res['functional_tests_output_tail'][0]}") + print(f" • Anti-Entropy Verdict: {res['anti_entropy_verdict']}") + print(f" • Evidence Binding: {res['source_evidence']}") + print(f" • Net Diff Lines: {res['net_diff_lines']:+} lines") + print(f" • Cleanly Retired: {res['retired_symbols']}") + print(f" • Blocking Violations: {res['blocking_findings']}") + print(f" • Advisory Hygiene Flaws: {res['advisory_findings']}") + if res["findings"]: + for f in res["findings"]: + print(f" - Finding: {f}") print() print("================================================================================") - print("Contrast Summary & Core Insights:") - print("1. Arm 1: Pass tests, BUT leaves legacy_parser.py behind as dead code. Net lines: 0.") - print("2. Arm 2: Pass tests, BUT leaves zombie classes and commented ghost code. Blocked by gate.") - print("3. Arm 3: Pass tests, clean /dev/null path deletion, 0 wrappers, net reduction: -27 lines.") + print("Demonstration Reading (oracle semantics only, no agent-runtime measurement):") + print("1. Functional tests alone pass whenever the caller migrates, in every arm.") + print("2. Only the oracle binds the declared retirement basis to complete post-change") + print(" sources: kept legacy files and zombie definitions stay blocking (AE001),") + print(" active references stay blocking (AE002), and 3+ line commented-out code") + print(" blocks stay advisory (AE003).") + print("3. Arm 3 achieves verified retirement: clean /dev/null deletion, zero blocking") + print(" findings, retired symbols certified against the bound workspace.") print("================================================================================") return 0 diff --git a/loopx/capabilities/anti_entropy/README.md b/loopx/capabilities/anti_entropy/README.md index 5abac687b4..98b7feead5 100644 --- a/loopx/capabilities/anti_entropy/README.md +++ b/loopx/capabilities/anti_entropy/README.md @@ -17,10 +17,13 @@ The Anti-Entropy capability addresses this through: 1. **AST-Based Delete-First Enforcement (`verify_delete_first`)**: Uses Python's syntax tree parser to verify that declared retirement targets (functions and classes) are genuinely removed from the codebase rather than - merely bypassed or wrapped. + merely bypassed or wrapped. Symbol retirement is a derived fact about + **complete post-change sources**, not about diff text alone. 2. **Zombie Symbol Prevention**: Detects if a symbol marked for retirement is reintroduced as a new definition - in newly added code lines. + in the diff, or is still referenced by active identifiers in the bound + post-change sources (string literals, comments, and docstring examples are + never counted as references). 3. **Target Paths Verification**: Ensures that declared legacy file paths slated for deletion are completely removed in the unified diff (`/dev/null`). @@ -29,7 +32,54 @@ The Anti-Entropy capability addresses this through: quality findings without blocking legitimate documentation or explanatory comments. 5. **Durable Receipt Generation (`AntiEntropyReceipt`)**: Runs deterministic hygiene oracles against the exact git diff and produces - an immutable receipt recording verified retired symbols and findings. + an immutable receipt recording verified retired symbols, findings, and the + evidence provenance (`basis_hash`, `source_root`, `source_evidence`). + +## Source Binding And Evidence Rules + +Verification distinguishes diff-visible facts from workspace-derived facts: + +- `--workspace ` explicitly binds the directory holding the complete + post-change sources. The bound workspace — never the caller's CWD — is the + only disk source read, and its complete files are parsed with the AST + (line-signature matching is limited to unparseable in-diff code fragments; + documentation files never contribute symbol evidence). +- **AE004 (insufficient source evidence, blocking)**: without a bound + workspace, or when a diff-touched source file is missing from the bound + workspace, symbol retirement cannot be certified. A diff-only receipt may + reject, but never certifies retirement, and `retired_symbols` stays empty. + A path deleted in the diff but still present in the bound workspace is + reported as a patch/workspace mismatch. +- **AE001/AE002 remain hard gates** in this command; **AE003 stays advisory**. + `allow_compat_seam=true` turns a retained-but-authorized definition into an + advisory finding, and it is never listed as retired. +- The receipt records which sources backed the verdict; a `diff_hash` alone + does not prove anything about the task, the sources, or the policy. + +## Single-Task Feedback Loop (Default-Off) + +The capability ships disabled (`default_enabled: false`); enabling it for a +task is an explicit choice. When enabled, one closed loop per task is: + +1. **Task context** — `build_anti_entropy_turn_context(basis, goal_id, + workspace)` projects the declared `RetirementBasis` into a typed + turn-context payload a host can inject into agent input. Building the + payload executes nothing. +2. **In-flight check** — agents call the `anti_entropy_inspect` tool (or + `loopx anti-entropy inspect`). Blocking findings come back as + **actionable feedback with per-rule next actions**, classified as progress + facts, not final delivery failures; the CLI exits 0 whenever the check ran. +3. **Delivery verification** — final acceptance stays owned by the existing + change-quality owner (`loopx change-quality prepare/record/verify`, exact + diff scope and receipt invalidation). This capability supplies evidence and + names the delivery route; it writes no receipt store and creates no + parallel delivery authority. +4. **Post-writeback (named follow-up)** — typed post-writeback intent bound to + the source receipt remains owned by the provider-neutral post-writeback + capability-hooks RFC, including failure isolation and idempotence. + +Model adoption of the loop is proven by actual runs, not by this protocol; +tests here cover the typed protocol and the real callable entries. ## Attribution & Acknowledgments @@ -45,10 +95,16 @@ Inspect the capability and its registered protocols: loopx capability show anti-entropy-governance --format json ``` -Verify diff hygiene against a declared retirement basis: +In-flight inspection with actionable feedback (progress-fact semantics): ```bash -loopx anti-entropy verify --goal-id --diff-file --basis-json --format json +loopx anti-entropy inspect --goal-id --diff-file --basis-json --workspace --format json +``` + +Delivery-gate verification (non-zero exit on rejection): + +```bash +loopx anti-entropy verify --goal-id --diff-file --basis-json --workspace --format json ``` Example `basis.json`: @@ -62,10 +118,19 @@ Example `basis.json`: } ``` +A deterministic end-to-end demonstration on fixed fixtures lives in +`examples/anti_entropy/compare_arms.py`: it seeds a disposable git sandbox per +arm, applies the arm's fixed post-state, captures the real diff with git, runs +the case-study functional tests with pytest, and evaluates the oracle against +the sandbox workspace. It measures the oracle's semantics only — it launches +no model runs and measures no agent-runtime behavior. + ## Boundaries - The capability is default-off and operates as an opt-in code hygiene verifier via CLI and typed receipts. - Delete-first requires target old logic to be cleanly decommissioned in the diff without re-introducing wrappers or zombie symbols. +- Symbol retirement certification requires an explicitly bound workspace; diff-only evidence can reject but never certifies. - AST-based diff hygiene verifies exact code diffs without mutating repository code or performing speculative git writes. - Target paths declared for decommissioning must be cleanly removed in the unified diff. - Advisory findings highlight non-blocking hygiene opportunities (such as dead commented code) without causing false positive rejections of documentation or explanations. +- Delivery acceptance and receipts of record remain owned by the change-quality capability; this capability never writes delivery receipts. diff --git a/loopx/capabilities/anti_entropy/__init__.py b/loopx/capabilities/anti_entropy/__init__.py index 8e1a2b3132..e5c81f561c 100644 --- a/loopx/capabilities/anti_entropy/__init__.py +++ b/loopx/capabilities/anti_entropy/__init__.py @@ -11,6 +11,11 @@ AntiEntropyReceipt, RetirementBasis, ) +from .feedback import ( + ANTI_ENTROPY_INSPECT_TOOL_DESCRIPTOR, + build_anti_entropy_turn_context, + run_anti_entropy_feedback_cycle, +) from .oracles import ( audit_ghost_code, evaluate_diff_hygiene, @@ -20,6 +25,7 @@ __all__ = [ "ANTI_ENTROPY_CAPABILITY_ID", "ANTI_ENTROPY_CATALOG_ENTRY", + "ANTI_ENTROPY_INSPECT_TOOL_DESCRIPTOR", "ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION", "ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION", "AntiEntropyFinding", @@ -27,7 +33,9 @@ "RetirementBasis", "anti_entropy_inspect", "audit_ghost_code", + "build_anti_entropy_turn_context", "evaluate_diff_hygiene", + "run_anti_entropy_feedback_cycle", "run_anti_entropy_verify", "verify_delete_first", ] diff --git a/loopx/capabilities/anti_entropy/agent_tool.py b/loopx/capabilities/anti_entropy/agent_tool.py index cbd606ab7d..d9dc13ffc9 100644 --- a/loopx/capabilities/anti_entropy/agent_tool.py +++ b/loopx/capabilities/anti_entropy/agent_tool.py @@ -1,5 +1,6 @@ from __future__ import annotations +from pathlib import Path from typing import Any from .cli import run_anti_entropy_verify @@ -10,15 +11,19 @@ def anti_entropy_inspect( diff_text: str, retirement_basis: dict[str, Any] | None = None, goal_id: str | None = None, + workspace: str | Path | None = None, ) -> dict[str, Any]: """Inspect unified diff against a declared retirement basis and diff hygiene rules. This tool allows autonomous agents to verify that deprecated symbols and files are genuinely decommissioned (delete-first) and that no ghost code or zombie - wrappers are reintroduced, returning an immutable structured receipt. + wrappers are reintroduced, returning an immutable structured receipt. Symbol + retirement certification requires an explicitly bound ``workspace`` holding + the complete post-change sources. """ return run_anti_entropy_verify( diff_text=diff_text, basis_data=retirement_basis, goal_id=goal_id, + workspace=workspace, ) diff --git a/loopx/capabilities/anti_entropy/catalog_entry.py b/loopx/capabilities/anti_entropy/catalog_entry.py index b3b1858a3a..062e8e53b7 100644 --- a/loopx/capabilities/anti_entropy/catalog_entry.py +++ b/loopx/capabilities/anti_entropy/catalog_entry.py @@ -31,7 +31,7 @@ "re-introducing wrappers, zombie symbols, or ghost code." ), "entry_command": ( - "loopx anti-entropy verify --goal-id --diff-file --format json" + "loopx anti-entropy inspect --goal-id --diff-file --workspace --format json" ), "workflow_skill": { "name": "loopx-anti-entropy", @@ -41,16 +41,36 @@ }, "commands": [ { - "command": "loopx anti-entropy verify --goal-id --diff-file --format json", + "command": "loopx anti-entropy verify --goal-id --diff-file --workspace --format json", "purpose": "Verify exact diff hygiene and delete-first compliance against a declared retirement basis.", "write_boundary": "read-only diff verification and receipt generation; no repository mutation", }, + { + "command": "loopx anti-entropy inspect --goal-id --diff-file --workspace --format json", + "purpose": ( + "In-flight agent check with actionable feedback; rejections are " + "progress facts, not final delivery failures." + ), + "write_boundary": "read-only diff verification; no receipt store writes", + }, { "command": "loopx capability show anti-entropy-governance --format json", "purpose": "Inspect anti-entropy capability metadata, commands, and protocols.", "write_boundary": "read-only metadata projection", }, ], + "agent_tools": [ + { + "name": "anti_entropy_inspect", + "descriptor": "loopx.capabilities.anti_entropy.feedback.ANTI_ENTROPY_INSPECT_TOOL_DESCRIPTOR", + "entrypoint": "loopx.capabilities.anti_entropy.agent_tool.anti_entropy_inspect", + "purpose": ( + "Agent-callable inspection returning actionable findings as " + "progress facts plus an immutable receipt." + ), + "write_boundary": "read-only verification; no host state mutation", + }, + ], "implemented_protocols": [ { "schema_version": ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION, diff --git a/loopx/capabilities/anti_entropy/cli.py b/loopx/capabilities/anti_entropy/cli.py index 883ec0eb9a..3dff6d684c 100644 --- a/loopx/capabilities/anti_entropy/cli.py +++ b/loopx/capabilities/anti_entropy/cli.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json from pathlib import Path from typing import Any @@ -16,10 +15,16 @@ def run_anti_entropy_verify( diff_text: str, basis_data: Any = None, goal_id: str | None = None, + workspace: str | Path | None = None, ) -> dict[str, Any]: - """Verify exact diff hygiene and delete-first compliance, returning structured payload.""" + """Verify exact diff hygiene and delete-first compliance, returning structured payload. + + ``workspace`` explicitly binds the post-change sources used to verify symbol + retirement. Without it, verdicts can reject but never certify retirement + (AE004 insufficient source evidence). + """ basis = RetirementBasis.from_dict(basis_data) if basis_data is not None else None - receipt = evaluate_diff_hygiene(diff_text, basis) + receipt = evaluate_diff_hygiene(diff_text, basis, workspace=workspace) blocking_count = sum(1 for f in receipt.findings if f.severity == "blocking") advisory_count = sum(1 for f in receipt.findings if f.severity == "advisory") diff --git a/loopx/capabilities/anti_entropy/contract.py b/loopx/capabilities/anti_entropy/contract.py index 0c94c60bb8..20aa6b9aea 100644 --- a/loopx/capabilities/anti_entropy/contract.py +++ b/loopx/capabilities/anti_entropy/contract.py @@ -118,7 +118,13 @@ def to_dict(self) -> dict[str, Any]: @dataclass(frozen=True, slots=True) class AntiEntropyReceipt: - """Durable receipt verifying diff hygiene and delete-first enforcement.""" + """Durable receipt verifying diff hygiene and delete-first enforcement. + + Evidence provenance: ``basis_hash`` binds the receipt to the exact declared + retirement intent; ``source_root`` and ``source_evidence`` record which + post-change sources (if any) backed the verdict. A ``diff_only`` receipt can + reject but never certifies symbol retirement. + """ schema_version: str = ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION receipt_id: str = field(default="") @@ -127,6 +133,9 @@ class AntiEntropyReceipt: findings: tuple[AntiEntropyFinding, ...] = () retired_symbols: tuple[str, ...] = () timestamp_utc: float = field(default_factory=time.time) + basis_hash: str = field(default="") + source_root: str | None = field(default=None) + source_evidence: str = "diff_only" # "workspace_sources" | "diff_only" def to_dict(self) -> dict[str, Any]: receipt_id = self.receipt_id @@ -141,4 +150,7 @@ def to_dict(self) -> dict[str, Any]: "findings": [f.to_dict() for f in self.findings], "retired_symbols": list(self.retired_symbols), "timestamp_utc": self.timestamp_utc, + "basis_hash": self.basis_hash, + "source_root": self.source_root, + "source_evidence": self.source_evidence, } diff --git a/loopx/capabilities/anti_entropy/feedback.py b/loopx/capabilities/anti_entropy/feedback.py new file mode 100644 index 0000000000..4ccc71e073 --- /dev/null +++ b/loopx/capabilities/anti_entropy/feedback.py @@ -0,0 +1,211 @@ +"""Default-off, task-scoped feedback loop for anti-entropy retirement work. + +Single-task closed loop: + +1. Task context: ``build_anti_entropy_turn_context`` projects a declared + ``RetirementBasis`` into a typed turn-context payload a host can inject into + agent input when the capability is enabled for that task. +2. In-flight check: agents call the ``anti_entropy_inspect`` tool (or the CLI + mirror) and receive actionable feedback. Blocking findings are progress + facts guiding the next correction, not final delivery failures. +3. Delivery verification: stays owned by the existing change-quality owner + (exact-diff prepare/record/verify and receipt invalidation). This module + only returns the delivery route; it never writes receipts or creates a + parallel delivery authority. + +Model adoption is proven by actual runs elsewhere; the tests for this module +cover the protocol and the real callable entry points. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Mapping + +from .agent_tool import anti_entropy_inspect +from .contract import AntiEntropyFinding, RetirementBasis + +_INSPECT_CLI_ROUTE = ( + "loopx anti-entropy inspect --diff-file " + "--basis-json --workspace --format json" +) + +_DELIVERY_ROUTE = ( + "loopx change-quality record --goal-id --result-path " + "then loopx change-quality verify --goal-id " +) + +_TOOL_NAME = "anti_entropy_inspect" + +_TOOL_DESCRIPTION = ( + "Verify a unified diff against a declared retirement basis and diff hygiene " + "rules. Returns an immutable receipt with actionable findings: blocking " + "findings are progress facts to fix before delivery, not final delivery " + "failures. Symbol retirement certification requires an explicitly bound " + "workspace holding the complete post-change sources." +) + +ANTI_ENTROPY_INSPECT_TOOL_DESCRIPTOR: dict[str, Any] = { + "name": _TOOL_NAME, + "description": _TOOL_DESCRIPTION, + "input_schema": { + "type": "object", + "required": ["diff_text"], + "properties": { + "diff_text": { + "type": "string", + "description": "Unified diff text of the change under review.", + }, + "retirement_basis": { + "type": "object", + "description": ( + "Declared RetirementBasis (target_symbols, target_paths, " + "allow_compat_seam, rationale)." + ), + }, + "workspace": { + "type": "string", + "description": ( + "Directory with the complete post-change sources; required " + "to certify symbol retirement." + ), + }, + "goal_id": {"type": "string", "description": "Associated LoopX goal identifier."}, + }, + }, + "cli_route": _INSPECT_CLI_ROUTE, +} + + +def _rule_next_action(finding: AntiEntropyFinding) -> str: + if finding.rule_id.startswith("AE001"): + return "Remove the superseded definition or target path in this change, or declare an authorized compatibility seam." + if finding.rule_id.startswith("AE002"): + return "Remove the reintroduced wrapper or active reference so the retired name has no live definition or caller." + if finding.rule_id.startswith("AE003"): + return "Delete the commented-out block under version control instead of keeping ghost code." + if finding.rule_id.startswith("AE004"): + return "Re-run with --workspace bound to the complete post-change sources; diff-only evidence cannot certify retirement." + return "Address the reported diff hygiene finding and re-run the inspection." + + +def build_anti_entropy_turn_context( + *, + basis: RetirementBasis | Mapping[str, Any], + goal_id: str | None = None, + workspace: str | Path | None = None, +) -> dict[str, Any]: + """Project a declared retirement basis into a typed turn-context payload. + + The payload is data for the host to inject when the capability is enabled + for the task; calling this function does not execute anything. + """ + basis_obj = ( + basis if isinstance(basis, RetirementBasis) else RetirementBasis.from_dict(basis) + ) + context: dict[str, Any] = { + "capability_id": "anti-entropy-governance", + "task": { + "objective": basis_obj.rationale, + "target_symbols": list(basis_obj.target_symbols), + "target_paths": list(basis_obj.target_paths), + "allow_compat_seam": basis_obj.allow_compat_seam, + }, + "inspect_tool": ANTI_ENTROPY_INSPECT_TOOL_DESCRIPTOR, + "feedback_semantics": { + "blocking_findings": ( + "Progress facts for the next correction round; they are not " + "final delivery failures." + ), + "retirement_certification": ( + "Requires an explicitly bound workspace with complete " + "post-change sources (AE004 otherwise)." + ), + "delivery_gate": ( + "Final acceptance is owned by change-quality prepare/record/" + "verify; this capability supplies evidence only." + ), + }, + "delivery_route": _DELIVERY_ROUTE, + } + if goal_id is not None: + context["goal_id"] = goal_id + if workspace is not None: + context["workspace"] = str(workspace) + return context + + +def run_anti_entropy_feedback_cycle( + *, + diff_text: str, + basis: RetirementBasis | Mapping[str, Any] | None = None, + goal_id: str | None = None, + workspace: str | Path | None = None, +) -> dict[str, Any]: + """Run one inspect round and classify its outcome as actionable feedback. + + This is the real in-flight check entry: it never raises on verification + findings. A rejected verdict yields ``is_progress_fact=True`` with + per-finding next actions; only a passed, source-bound verification with all + targets covered marks the round delivery-ready (final acceptance still + belongs to the change-quality owner). + """ + result = anti_entropy_inspect( + diff_text=diff_text, + retirement_basis=( + dict(basis) if isinstance(basis, Mapping) else basis + ), + goal_id=goal_id, + workspace=workspace, + ) + receipt = result.get("receipt", {}) + findings = receipt.get("findings", []) + actionable: list[dict[str, Any]] = [ + { + "rule_id": f.get("rule_id"), + "severity": f.get("severity"), + "message": f.get("message"), + "location": f.get("location"), + "next_action": _rule_next_action( + AntiEntropyFinding( + rule_id=f.get("rule_id", ""), + severity=f.get("severity", "advisory"), + message=f.get("message", ""), + location=f.get("location"), + ) + ), + } + for f in findings + ] + + verdict = result.get("verdict") + retired = result.get("retired_symbols", []) + source_evidence = receipt.get("source_evidence", "diff_only") + + basis_obj = ( + basis if isinstance(basis, RetirementBasis) else ( + RetirementBasis.from_dict(basis) if basis is not None else None + ) + ) + declared_symbols = list(basis_obj.target_symbols) if basis_obj is not None else [] + all_targets_retired = bool(declared_symbols) and all(s in retired for s in declared_symbols) + + delivery_ready = ( + verdict == "passed" + and source_evidence == "workspace_sources" + and all_targets_retired + ) + + return { + "ok": True, + "goal_id": goal_id, + "verdict": verdict, + "source_evidence": source_evidence, + "retired_symbols": retired, + "actionable_feedback": actionable, + "is_progress_fact": verdict == "rejected", + "delivery_ready": delivery_ready, + "delivery_route": _DELIVERY_ROUTE, + "inspect_cli_route": _INSPECT_CLI_ROUTE, + "receipt": receipt, + } diff --git a/loopx/capabilities/anti_entropy/oracles.py b/loopx/capabilities/anti_entropy/oracles.py index 9949a38e9b..81a06791fa 100644 --- a/loopx/capabilities/anti_entropy/oracles.py +++ b/loopx/capabilities/anti_entropy/oracles.py @@ -3,9 +3,12 @@ import ast import codecs import hashlib +import json +import os import re import textwrap from collections.abc import Mapping +from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -48,13 +51,51 @@ _TS_EXTENSIONS = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"} _PY_EXTENSIONS = {".py", ".pyi"} +_RULE_AE001 = "AE001-DELETE_FIRST_VIOLATION" +_RULE_AE002 = "AE002-ZOMBIE_SYMBOL_REINTRODUCED" +_RULE_AE003 = "AE003-COMMENTED_CODE_ADVISORY" +_RULE_AE004 = "AE004-INSUFFICIENT_SOURCE_EVIDENCE" + +_WORKSPACE_SKIP_DIRS = { + ".git", + ".hg", + ".svn", + "node_modules", + ".venv", + "venv", + "__pycache__", + ".tox", + ".nox", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + "dist", + "build", + ".idea", + ".vscode", +} + +_MAX_SOURCE_FILE_BYTES = 2_000_000 + +_MAX_WORKSPACE_FILES = 20000 + def _hash_diff(diff_text: str) -> str: return hashlib.sha256(diff_text.encode("utf-8")).hexdigest() +def hash_retirement_basis(basis: RetirementBasis) -> str: + """Stable hash binding the receipt to the exact declared retirement intent.""" + payload = json.dumps(basis.to_dict(), sort_keys=True, ensure_ascii=False) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + def find_repo_root(start_path: Path | None = None) -> Path: - """Resolve repository root by looking for .git folder up the tree.""" + """Resolve repository root by looking for .git folder up the tree. + + Kept for callers that locate a repository; verification itself requires an + explicitly bound workspace and no longer infers one from the CWD. + """ cur = (start_path or Path.cwd()).resolve() for parent in [cur, *cur.parents]: if (parent / ".git").exists(): @@ -139,6 +180,11 @@ def parse_diff_git_header(line: str) -> tuple[str, str] | None: return None +def _is_doc_file(file_path: str) -> bool: + ext = Path(file_path).suffix.lower() if file_path else "" + return ext in _DOC_EXTENSIONS + + def _extract_deleted_paths(diff_text: str) -> set[str]: deleted_paths: set[str] = set() diff_text = textwrap.dedent(diff_text) @@ -179,32 +225,45 @@ def _extract_deleted_paths(diff_text: str) -> set[str]: return deleted_paths -def _extract_python_defs_from_code(code: str) -> set[str]: +def _extract_python_defs_from_code(code: str) -> set[str] | None: + """Extract Python definitions from a complete-parseable code block. + + Returns None when the text cannot be parsed as Python at all (so callers can + treat it as unparseable rather than as "defines nothing"). + """ if not code.strip(): return set() dedented = textwrap.dedent(code) try: tree = ast.parse(dedented) - return { - node.name - for node in ast.walk(tree) - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) - } + return _python_def_names(tree) except SyntaxError: try: wrapped = "def _wrapper_():\n" + textwrap.indent(dedented, " ") tree = ast.parse(wrapped) return { - node.name - for node in ast.walk(tree) - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) - and node.name != "_wrapper_" + name + for name in _python_def_names(tree) + if name != "_wrapper_" } except SyntaxError: - return set() + return None + + +def _python_def_names(tree: ast.AST) -> set[str]: + return { + node.name + for node in ast.walk(tree) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) + } def _extract_python_signature_defs(lines: list[str]) -> set[str]: + """Line-level fallback for code fragments that cannot be parsed as Python. + + Only meaningful for in-diff code fragments; complete files must use the AST + result instead so that prose/docstring examples are never counted as defs. + """ defs: set[str] = set() for line in lines: m = _PY_DEF_OR_CLASS_RE.match(line) @@ -235,32 +294,177 @@ def _extract_ts_defs(lines: list[str]) -> set[str]: return defs -def _extract_defs_for_file(lines: list[str], file_path: str = "") -> set[str]: +def _strip_ts_line(line: str) -> str: + """Strip // comments and string-literal spans from a TS/JS-like line.""" + line = re.sub(r"//.*$", "", line) + line = re.sub(r"/\*.*?(?:\*/|$)", "", line) + line = re.sub(r'"(?:\\.|[^"\\])*"', '""', line) + line = re.sub(r"'(?:\\.|[^'\\])*'", "''", line) + line = re.sub(r"`(?:\\.|[^`\\])*`", "``", line) + return line + + +def _extract_ts_refs(lines: list[str], defs: set[str]) -> set[str]: + """Word-level references for TS/JS-like lines, excluding definition lines themselves.""" + refs: set[str] = set() + for line in lines: + line_clean = line.strip() + is_def_line = any( + (m := pattern.match(line_clean)) and m.group(1) in defs + for pattern in _TS_DEF_PATTERNS + ) + if is_def_line: + continue + for word in re.findall(r"[A-Za-z_$][A-Za-z0-9_$]*", _strip_ts_line(line)): + if word in defs: + refs.add(word) + return refs + + +def _extract_defs_for_file(lines: list[str], file_path: str = "", is_full_file: bool = False) -> set[str]: + """Extract definitions for in-diff fragments of a file. + + Documentation files never contribute symbol definitions: fenced examples in + markdown are prose, not code. For Python fragments, the AST result wins when + the fragment parses; the line-signature fallback only covers unparseable + fragments of code files. + """ + if _is_doc_file(file_path): + return set() + code_text = "\n".join(lines) ext = Path(file_path).suffix.lower() if file_path else "" if ext in _TS_EXTENSIONS: return _extract_ts_defs(lines) elif ext in _PY_EXTENSIONS: - return _extract_python_defs_from_code(code_text) | _extract_python_signature_defs(lines) + py_ast_defs = _extract_python_defs_from_code(code_text) + if py_ast_defs is not None: + return py_ast_defs + if is_full_file: + return set() + return _extract_python_signature_defs(lines) else: - py_defs = _extract_python_defs_from_code(code_text) | _extract_python_signature_defs(lines) - ts_defs = _extract_ts_defs(lines) - return py_defs | ts_defs - - -def _analyze_diff_symbols(diff_text: str) -> tuple[set[str], set[str], set[str], set[str], list[str]]: - """Analyze diff to extract deleted, reintroduced, surviving context, and disk defined symbols, - - plus all added lines for reference auditing. - Returns: - (deleted_symbols, reintroduced_symbols, surviving_context_defs, still_defined_on_disk, all_added_lines) + py_ast_defs = _extract_python_defs_from_code(code_text) + if py_ast_defs: + return py_ast_defs + if is_full_file: + return _extract_ts_defs(lines) + return _extract_python_signature_defs(lines) | _extract_ts_defs(lines) + + +@dataclass(frozen=True, slots=True) +class _DiffSymbolAnalysis: + deleted_symbols: set[str] = field(default_factory=set) + reintroduced_symbols: set[str] = field(default_factory=set) + surviving_context_defs: set[str] = field(default_factory=set) + still_defined_on_disk: set[str] = field(default_factory=set) + all_added_lines: list[str] = field(default_factory=list) + unverified_source_files: tuple[str, ...] = () + file_to_deleted_symbols: dict[str, set[str]] = field(default_factory=dict) + deleted_paths: set[str] = field(default_factory=set) + + +@dataclass(frozen=True, slots=True) +class _WorkspaceEvidence: + """Complete post-change source evidence bound to an explicitly given workspace.""" + + root: Path + defined_symbols: dict[str, set[str]] = field(default_factory=dict) + referenced_symbols: dict[str, set[str]] = field(default_factory=dict) + existing_paths: set[str] = field(default_factory=set) + + +def _extract_python_refs_from_code(code: str) -> set[str] | None: + """Extract real Python identifier references via AST (strings/docstrings excluded).""" + try: + tree = ast.parse(textwrap.dedent(code)) + except SyntaxError: + return None + refs: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Name): + refs.add(node.id) + elif isinstance(node, ast.Attribute): + refs.add(node.attr) + elif isinstance(node, ast.ImportFrom): + if node.module: + refs.add(node.module.split(".")[0]) + for alias in node.names: + refs.add(alias.name.split(".")[0]) + if alias.asname: + refs.add(alias.asname) + return refs + + +def _scan_workspace(workspace: Path, symbols: tuple[str, ...]) -> _WorkspaceEvidence: + """Scan an explicitly bound workspace for definitions and real references of target symbols. + + Prefilters by substring so most files never reach the parser. Documentation + files are skipped: they are not executable evidence. + """ + evidence = _WorkspaceEvidence(root=workspace) + if not symbols: + return evidence + + symbol_set = set(symbols) + source_exts = _PY_EXTENSIONS | _TS_EXTENSIONS + visited = 0 + for dirpath, dirnames, filenames in os.walk(workspace): + dirnames[:] = [d for d in dirnames if d not in _WORKSPACE_SKIP_DIRS] + for filename in filenames: + if visited >= _MAX_WORKSPACE_FILES: + return evidence + file_path = Path(dirpath) / filename + if file_path.suffix.lower() not in source_exts: + continue + try: + if file_path.stat().st_size > _MAX_SOURCE_FILE_BYTES: + continue + content = file_path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + visited += 1 + if not any(s in content for s in symbol_set): + continue + rel = file_path.relative_to(workspace).as_posix() + ext = file_path.suffix.lower() + if ext in _PY_EXTENSIONS: + defs = _extract_python_defs_from_code(content) + refs = _extract_python_refs_from_code(content) + if defs is None or refs is None: + # Unparseable source cannot prove absence; treat the symbol + # as still defined to avoid false certification. + for s in symbol_set: + if s in content: + evidence.defined_symbols.setdefault(s, set()).add(rel) + continue + else: + content_lines = content.splitlines() + defs = _extract_ts_defs(content_lines) + refs = _extract_ts_refs(content_lines, defs) + for s in symbol_set: + if s in defs: + evidence.defined_symbols.setdefault(s, set()).add(rel) + if s in refs: + evidence.referenced_symbols.setdefault(s, set()).add(rel) + return evidence + + +def _analyze_diff_symbols(diff_text: str, workspace: Path | None = None) -> _DiffSymbolAnalysis: + """Analyze diff to extract deleted, reintroduced, surviving context, and disk-defined symbols. + + Source binding: when ``workspace`` is provided, complete post-change sources + are read from that explicit root only — never from the caller's CWD. Files + that cannot be read from the bound workspace are reported as unverified. """ deleted_symbols: set[str] = set() reintroduced_symbols: set[str] = set() surviving_context_defs: set[str] = set() still_defined_on_disk: set[str] = set() all_added_lines: list[str] = [] + unverified_source_files: list[str] = [] + file_to_deleted_symbols: dict[str, set[str]] = {} diff_text = textwrap.dedent(diff_text) file_diffs = re.split(r"(?=^diff --git |^--- )", diff_text, flags=re.MULTILINE) @@ -279,6 +483,8 @@ def _analyze_diff_symbols(diff_text: str) -> tuple[set[str], set[str], set[str], file_path = decode_git_diff_path(m_patch.group(1).strip(), is_source=False) hunk_texts = re.split(r"(?=^@@\s+-\d+.*?\s+\+\d+.*?\s+@@)", f_diff, flags=re.MULTILINE) + file_d_syms: set[str] = set() + for h_text in hunk_texts: if not h_text.startswith("@@"): continue @@ -318,130 +524,215 @@ def _analyze_diff_symbols(diff_text: str) -> tuple[set[str], set[str], set[str], for s in pre_defs: if s not in post_defs: deleted_symbols.add(s) + file_d_syms.add(s) for s in deleted_defs: if s not in post_defs and s not in added_defs: deleted_symbols.add(s) + file_d_syms.add(s) for s in added_defs: reintroduced_symbols.add(s) + if file_path: + file_to_deleted_symbols[file_path] = file_d_syms + is_file_deleted = bool( re.search(r"^deleted file mode \d+", f_diff, re.MULTILINE) or re.search(r"^\+\+\+\s+/dev/null", f_diff, re.MULTILINE) ) - if file_path and file_path != "/dev/null" and not is_file_deleted: - repo_root = find_repo_root() - disk_p = (repo_root / file_path).resolve() - if not disk_p.is_file(): - disk_p = Path(file_path).resolve() - if disk_p.is_file(): - try: - content = disk_p.read_text(encoding="utf-8") - disk_defs = _extract_defs_for_file(content.splitlines(), file_path) - for s in disk_defs: - still_defined_on_disk.add(s) - if s in deleted_symbols: - deleted_symbols.remove(s) - except Exception: - pass - - return deleted_symbols, reintroduced_symbols, surviving_context_defs, still_defined_on_disk, all_added_lines - + if file_path and file_path != "/dev/null" and not is_file_deleted and not _is_doc_file(file_path): + if workspace is None: + unverified_source_files.append(file_path) + else: + source_file = (workspace / file_path).resolve() + if not source_file.is_file(): + unverified_source_files.append(file_path) + else: + try: + content = source_file.read_text(encoding="utf-8") + disk_defs = _extract_defs_for_file( + content.splitlines(), file_path, is_full_file=True + ) + if ( + Path(file_path).suffix.lower() in _PY_EXTENSIONS + and _extract_python_defs_from_code(content) is None + ): + unverified_source_files.append(file_path) + continue + for s in disk_defs: + still_defined_on_disk.add(s) + if s in deleted_symbols: + deleted_symbols.remove(s) + except (OSError, UnicodeDecodeError): + unverified_source_files.append(file_path) + + return _DiffSymbolAnalysis( + deleted_symbols=deleted_symbols, + reintroduced_symbols=reintroduced_symbols, + surviving_context_defs=surviving_context_defs, + still_defined_on_disk=still_defined_on_disk, + all_added_lines=all_added_lines, + unverified_source_files=tuple(unverified_source_files), + file_to_deleted_symbols=file_to_deleted_symbols, + deleted_paths=_extract_deleted_paths(diff_text), + ) -def verify_delete_first( - diff_text: str, - retirement_basis: RetirementBasis | Mapping[str, Any] | None = None, -) -> list[AntiEntropyFinding]: - findings: list[AntiEntropyFinding] = [] - if retirement_basis is None: - return findings +def _symbol_hunk_reintroduced(symbol: str, analysis: _DiffSymbolAnalysis) -> bool: + """True when the diff itself visibly re-adds a definition of the symbol.""" + return symbol in analysis.reintroduced_symbols - basis = ( - retirement_basis - if isinstance(retirement_basis, RetirementBasis) - else RetirementBasis.from_dict(retirement_basis) - ) - if not basis.target_symbols and not basis.target_paths: - return findings +def _verify_symbol_targets( + basis: RetirementBasis, + analysis: _DiffSymbolAnalysis, + workspace_evidence: _WorkspaceEvidence | None, +) -> tuple[list[AntiEntropyFinding], set[str]]: + """Evaluate each target symbol; returns (findings, certified_retired_symbols).""" + findings: list[AntiEntropyFinding] = [] + certified: set[str] = set() - diff_text = textwrap.dedent(diff_text) - if not diff_text.strip(): - for symbol in basis.target_symbols: + for symbol in basis.target_symbols: + # 1. Diff-visible zombie: definition re-added inside the diff itself. + if _symbol_hunk_reintroduced(symbol, analysis): findings.append( AntiEntropyFinding( - rule_id="AE001-DELETE_FIRST_VIOLATION", - severity="blocking" if not basis.allow_compat_seam else "advisory", + rule_id=_RULE_AE002, + severity="blocking", message=( - f"Target symbol '{symbol}' was declared for retirement but diff is empty. " - "Delete-first requires explicit removal of superseded logic." + f"Target symbol '{symbol}' was slated for retirement but reintroduced " + "as an active definition in the diff." ), location=symbol, ) ) - for path in basis.target_paths: + continue + + # 2. Symbol retirement is a derived fact about complete post-change + # sources. Without an explicitly bound workspace it cannot be certified. + if workspace_evidence is None: findings.append( AntiEntropyFinding( - rule_id="AE001-DELETE_FIRST_VIOLATION", - severity="blocking" if not basis.allow_compat_seam else "advisory", + rule_id=_RULE_AE004, + severity="blocking", message=( - f"Target path '{path}' was declared for retirement but diff is empty." + f"Target symbol '{symbol}' cannot be certified as retired: no source " + "workspace was bound. Pass an explicit workspace containing the complete " + "post-change sources; a diff alone does not prove definition removal." ), - location=path, + location=symbol, ) ) - return findings - - deleted_symbols, reintroduced_symbols, surviving_context_defs, still_defined_on_disk, all_added_lines = _analyze_diff_symbols(diff_text) - deleted_paths = _extract_deleted_paths(diff_text) + continue - for symbol in basis.target_symbols: - # Check if the symbol is reintroduced as definition OR actively referenced/called in added code - is_reintroduced = symbol in reintroduced_symbols - if not is_reintroduced: - sym_pattern = re.compile(r"\b" + re.escape(symbol) + r"\b") - for added_l in all_added_lines: - clean_added = added_l.strip() - if clean_added.startswith(("#", "//", "/*", "*")): - continue - if sym_pattern.search(clean_added): - is_reintroduced = True - break + unverified_files = [ + f + for f in analysis.unverified_source_files + if symbol in analysis.file_to_deleted_symbols.get(f, set()) + ] + if unverified_files: + findings.append( + AntiEntropyFinding( + rule_id=_RULE_AE004, + severity="blocking", + message=( + f"Target symbol '{symbol}' cannot be certified as retired: complete " + f"post-change source is unavailable in the bound workspace for {', '.join(unverified_files)}." + ), + location=symbol, + ) + ) + continue - if is_reintroduced: + # 3. Real references in post-change sources (AST identifiers exclude + # string literals, comments and docstring examples). + ref_files = sorted(workspace_evidence.referenced_symbols.get(symbol, set())) + if ref_files: findings.append( AntiEntropyFinding( - rule_id="AE002-ZOMBIE_SYMBOL_REINTRODUCED", + rule_id=_RULE_AE002, severity="blocking", message=( - f"Target symbol '{symbol}' was slated for retirement but reintroduced " - "as an active definition or called in added lines in the diff or repository." + f"Target symbol '{symbol}' was slated for retirement but is still " + f"referenced by post-change sources: {', '.join(ref_files)}." ), location=symbol, ) ) - elif symbol not in deleted_symbols or symbol in surviving_context_defs or symbol in still_defined_on_disk: + continue + + # 4. Definition removal: complete workspace scan is authoritative, so a + # retained definition is caught even when the diff only edited prose. + def_files = sorted(workspace_evidence.defined_symbols.get(symbol, set())) + if def_files: + severity = "advisory" if basis.allow_compat_seam else "blocking" findings.append( AntiEntropyFinding( - rule_id="AE001-DELETE_FIRST_VIOLATION", - severity="blocking" if not basis.allow_compat_seam else "advisory", + rule_id=_RULE_AE001, + severity=severity, message=( - f"Target symbol '{symbol}' was declared for retirement in RetirementBasis " - "but its definition was not removed from the diff. Delete-first requires deleting " - "superseded definitions rather than merely removing calls, altering docs, or adding wrappers." + f"Target symbol '{symbol}' was declared for retirement but its definition " + f"is still present in post-change sources: {', '.join(def_files)}. " + "Delete-first requires removing superseded definitions rather than " + "merely removing calls, altering docs, or adding wrappers." ), location=symbol, ) ) + continue + + # 5. Unchanged context lines are diff-visible facts: a definition kept as + # hunk context was never removed by this diff. + if symbol in analysis.surviving_context_defs: + if workspace_evidence is None: + findings.append( + AntiEntropyFinding( + rule_id=_RULE_AE001, + severity="blocking" if not basis.allow_compat_seam else "advisory", + message=( + f"Target symbol '{symbol}' was declared for retirement but its " + "definition is still present in unchanged diff context lines. " + "Delete-first requires removing superseded definitions." + ), + location=symbol, + ) + ) + else: + findings.append( + AntiEntropyFinding( + rule_id=_RULE_AE004, + severity="blocking", + message=( + f"Target symbol '{symbol}' appears as a retained definition in the diff " + "context but was not found in the bound workspace sources; the patch " + "does not match the bound post-change sources." + ), + location=symbol, + ) + ) + continue + + # 6. No definition remains anywhere in the bound workspace. Positive + # deletion evidence in the diff attributes the retirement to this change. + if symbol in analysis.deleted_symbols or symbol in analysis.deleted_paths: + certified.add(symbol) + + return findings, certified + +def _verify_path_targets( + basis: RetirementBasis, + analysis: _DiffSymbolAnalysis, + workspace_evidence: _WorkspaceEvidence | None, +) -> list[AntiEntropyFinding]: + findings: list[AntiEntropyFinding] = [] for path in basis.target_paths: norm_path = normalize_repo_path(path) - if norm_path not in deleted_paths: + if norm_path not in analysis.deleted_paths: findings.append( AntiEntropyFinding( - rule_id="AE001-DELETE_FIRST_VIOLATION", + rule_id=_RULE_AE001, severity="blocking" if not basis.allow_compat_seam else "advisory", message=( f"Target path '{path}' was declared for retirement in RetirementBasis " @@ -450,7 +741,80 @@ def verify_delete_first( location=path, ) ) + continue + if workspace_evidence is not None and norm_path != "/dev/null": + disk_path = (workspace_evidence.root / norm_path).resolve() + if disk_path.exists(): + findings.append( + AntiEntropyFinding( + rule_id=_RULE_AE004, + severity="blocking", + message=( + f"Target path '{path}' is deleted in the diff but still exists in the " + "bound workspace; the patch does not match the bound post-change sources." + ), + location=path, + ) + ) + return findings + + +def verify_delete_first( + diff_text: str, + retirement_basis: RetirementBasis | Mapping[str, Any] | None = None, + workspace: str | Path | None = None, + _analysis: _DiffSymbolAnalysis | None = None, +) -> list[AntiEntropyFinding]: + findings: list[AntiEntropyFinding] = [] + + if retirement_basis is None: + return findings + + basis = ( + retirement_basis + if isinstance(retirement_basis, RetirementBasis) + else RetirementBasis.from_dict(retirement_basis) + ) + if not basis.target_symbols and not basis.target_paths: + return findings + + diff_text = textwrap.dedent(diff_text) + analysis = _analysis or _analyze_diff_symbols(diff_text, Path(workspace) if workspace else None) + + if not diff_text.strip(): + for symbol in basis.target_symbols: + findings.append( + AntiEntropyFinding( + rule_id=_RULE_AE001, + severity="blocking" if not basis.allow_compat_seam else "advisory", + message=( + f"Target symbol '{symbol}' was declared for retirement but diff is empty. " + "Delete-first requires explicit removal of superseded logic." + ), + location=symbol, + ) + ) + for path in basis.target_paths: + findings.append( + AntiEntropyFinding( + rule_id=_RULE_AE001, + severity="blocking" if not basis.allow_compat_seam else "advisory", + message=( + f"Target path '{path}' was declared for retirement but diff is empty." + ), + location=path, + ) + ) + return findings + + workspace_evidence: _WorkspaceEvidence | None = None + if workspace is not None and (basis.target_symbols or basis.target_paths): + workspace_evidence = _scan_workspace(Path(workspace), basis.target_symbols) + + symbol_findings, _certified = _verify_symbol_targets(basis, analysis, workspace_evidence) + findings.extend(symbol_findings) + findings.extend(_verify_path_targets(basis, analysis, workspace_evidence)) return findings @@ -518,7 +882,7 @@ def audit_ghost_code(diff_text: str) -> list[AntiEntropyFinding]: if consecutive_commented_code >= 3: findings.append( AntiEntropyFinding( - rule_id="AE003-COMMENTED_CODE_ADVISORY", + rule_id=_RULE_AE003, severity="advisory", message=( "Detected 3+ consecutive lines of commented-out code. " @@ -535,49 +899,48 @@ def audit_ghost_code(diff_text: str) -> list[AntiEntropyFinding]: def evaluate_diff_hygiene( diff_text: str, retirement_basis: RetirementBasis | Mapping[str, Any] | None = None, + workspace: str | Path | None = None, ) -> AntiEntropyReceipt: diff_text = textwrap.dedent(diff_text) diff_hash = _hash_diff(diff_text) all_findings: list[AntiEntropyFinding] = [] - all_findings.extend(verify_delete_first(diff_text, retirement_basis)) - all_findings.extend(audit_ghost_code(diff_text)) - - has_blocking = any(f.severity == "blocking" for f in all_findings) - verdict = "rejected" if has_blocking else "passed" - - retired: list[str] = [] - if retirement_basis: + basis: RetirementBasis | None = None + if retirement_basis is not None: basis = ( retirement_basis if isinstance(retirement_basis, RetirementBasis) else RetirementBasis.from_dict(retirement_basis) ) - if basis.target_symbols and diff_text.strip(): - deleted_symbols, reintroduced_symbols, surviving_context_defs, still_defined_on_disk, all_added_lines = _analyze_diff_symbols(diff_text) - failing_symbols = { - f.location for f in all_findings - if f.location and f.rule_id in ("AE001-DELETE_FIRST_VIOLATION", "AE002-ZOMBIE_SYMBOL_REINTRODUCED") - } - for s in basis.target_symbols: - sym_pattern = re.compile(r"\b" + re.escape(s) + r"\b") - has_added_ref = any( - sym_pattern.search(l) for l in all_added_lines - if not l.strip().startswith(("#", "//", "/*", "*")) - ) - if ( - s in deleted_symbols - and s not in reintroduced_symbols - and not has_added_ref - and s not in surviving_context_defs - and s not in still_defined_on_disk - and s not in failing_symbols - ): - retired.append(s) + + workspace_path = Path(workspace) if workspace is not None else None + source_evidence = "workspace_sources" if workspace_path is not None else "diff_only" + + analysis = _analyze_diff_symbols(diff_text, workspace_path) + + if basis is not None and (basis.target_symbols or basis.target_paths): + workspace_evidence: _WorkspaceEvidence | None = None + if workspace_path is not None and (basis.target_symbols or basis.target_paths): + workspace_evidence = _scan_workspace(workspace_path, basis.target_symbols) + symbol_findings, certified = _verify_symbol_targets(basis, analysis, workspace_evidence) + all_findings.extend(symbol_findings) + all_findings.extend(_verify_path_targets(basis, analysis, workspace_evidence)) + else: + certified = set() + + all_findings.extend(audit_ghost_code(diff_text)) + + has_blocking = any(f.severity == "blocking" for f in all_findings) + verdict = "rejected" if has_blocking else "passed" + + retired: list[str] = sorted(certified) return AntiEntropyReceipt( diff_hash=diff_hash, verdict=verdict, findings=tuple(all_findings), - retired_symbols=tuple(sorted(retired)), + retired_symbols=tuple(retired), + basis_hash=hash_retirement_basis(basis) if basis is not None else "", + source_root=str(workspace_path) if workspace_path is not None else None, + source_evidence=source_evidence, ) diff --git a/loopx/cli_commands/anti_entropy.py b/loopx/cli_commands/anti_entropy.py index ae80430952..ece7b7be90 100644 --- a/loopx/cli_commands/anti_entropy.py +++ b/loopx/cli_commands/anti_entropy.py @@ -42,6 +42,39 @@ def register_anti_entropy_commands( "--basis-json", help="JSON string or file path containing RetirementBasis specification.", ) + verify.add_argument( + "--workspace", + help=( + "Directory containing the complete post-change sources; required to " + "certify symbol retirement. Omitting it limits the verdict to " + "diff-only evidence." + ), + ) + inspect = commands.add_parser( + "inspect", + help=( + "In-flight agent check: verify the diff and return actionable " + "feedback. Rejections are progress facts, not final delivery " + "failures (exit 0 whenever the check runs)." + ), + ) + add_subcommand_format(inspect) + inspect.add_argument("--goal-id", help="Associated LoopX goal identifier.") + inspect.add_argument( + "--diff-file", + help="Path to unified diff patch file, or - for stdin.", + ) + inspect.add_argument( + "--basis-json", + help="JSON string or file path containing RetirementBasis specification.", + ) + inspect.add_argument( + "--workspace", + help=( + "Directory containing the complete post-change sources; required to " + "certify symbol retirement." + ), + ) def _render(payload: dict[str, Any]) -> str: @@ -59,6 +92,9 @@ def _render(payload: dict[str, Any]) -> str: lines.append("") lines.append("## Receipt") lines.append(f"- diff_hash: `{receipt.get('diff_hash')}`") + lines.append(f"- source_evidence: `{receipt.get('source_evidence', 'diff_only')}`") + if receipt.get("source_root"): + lines.append(f"- source_root: `{receipt.get('source_root')}`") retired = receipt.get("retired_symbols", []) if retired: lines.append(f"- retired_symbols: `{', '.join(retired)}`") @@ -71,6 +107,18 @@ def _render(payload: dict[str, Any]) -> str: rule = item.get("rule_id") msg = item.get("message") lines.append(f"- [{sev}] {rule}: {msg}") + if payload.get("command") == "inspect": + lines.append("") + lines.append("## Feedback") + lines.append(f"- is_progress_fact: `{payload.get('is_progress_fact')}`") + lines.append(f"- delivery_ready: `{payload.get('delivery_ready')}`") + lines.append(f"- delivery_route: `{payload.get('delivery_route')}`") + for item in payload.get("actionable_feedback", []): + lines.append("") + lines.append(f"### [{item.get('severity', '').upper()}] {item.get('rule_id')}") + if item.get("location"): + lines.append(f"- location: `{item.get('location')}`") + lines.append(f"- next_action: {item.get('next_action')}") return "\n".join(lines) + "\n" @@ -112,11 +160,59 @@ def handle_anti_entropy_command( except Exception: raise ValueError(f"basis file not found: {raw_basis}") + workspace: Path | None = None + if getattr(args, "workspace", None): + workspace = Path(args.workspace).expanduser().resolve() + if not workspace.is_dir(): + raise ValueError(f"workspace directory not found: {args.workspace}") + payload = run_anti_entropy_verify( diff_text=diff_text, basis_data=basis_data, goal_id=args.goal_id, + workspace=workspace, + ) + elif command == "inspect": + diff_text = "" + if args.diff_file == "-": + diff_text = sys.stdin.read() + elif args.diff_file: + diff_path = Path(args.diff_file).expanduser() + if diff_path.is_file(): + diff_text = diff_path.read_text(encoding="utf-8") + else: + raise ValueError(f"diff file not found: {args.diff_file}") + + basis_data = None + if args.basis_json is not None: + raw_basis = args.basis_json.strip() + if raw_basis.startswith("{") or raw_basis.startswith("["): + basis_data = json.loads(raw_basis) + else: + path = Path(raw_basis).expanduser() + if path.is_file(): + basis_data = json.loads(path.read_text(encoding="utf-8")) + else: + try: + basis_data = json.loads(raw_basis) + except Exception: + raise ValueError(f"basis file not found: {raw_basis}") + + workspace = None + if getattr(args, "workspace", None): + workspace = Path(args.workspace).expanduser().resolve() + if not workspace.is_dir(): + raise ValueError(f"workspace directory not found: {args.workspace}") + + from ..capabilities.anti_entropy.feedback import run_anti_entropy_feedback_cycle + + payload = run_anti_entropy_feedback_cycle( + diff_text=diff_text, + basis=basis_data, + goal_id=args.goal_id, + workspace=workspace, ) + payload = {"command": "inspect", **payload} else: raise ValueError(f"unknown anti-entropy command `{command}`") except (OSError, ValueError, TypeError) as exc: diff --git a/tests/capabilities/test_anti_entropy.py b/tests/capabilities/test_anti_entropy.py index 615aa00520..ae05de3822 100644 --- a/tests/capabilities/test_anti_entropy.py +++ b/tests/capabilities/test_anti_entropy.py @@ -1,14 +1,12 @@ from __future__ import annotations -import json -from typing import Any +from pathlib import Path from loopx.capabilities.anti_entropy import ( ANTI_ENTROPY_CAPABILITY_ID, ANTI_ENTROPY_CATALOG_ENTRY, ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION, ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION, - AntiEntropyReceipt, RetirementBasis, audit_ghost_code, evaluate_diff_hygiene, @@ -31,8 +29,18 @@ def test_catalog_registration() -> None: assert "delete-first" in record["real_world_anchor"] or "refactoring" in record["real_world_anchor"] -def test_verify_delete_first_passed() -> None: - """Verify clean retirement where old symbol is cleanly deleted.""" +def _make_workspace(tmp_path: Path, files: dict[str, str]) -> Path: + """Create an explicitly bound workspace containing post-change sources.""" + workspace = tmp_path / "workspace" + for rel_path, content in files.items(): + target = workspace / rel_path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + return workspace + + +def test_verify_delete_first_passed(tmp_path: Path) -> None: + """Verify clean retirement where old symbol is cleanly deleted from bound sources.""" diff = """ --- a/legacy_module.py +++ b/legacy_module.py @@ -42,19 +50,23 @@ def test_verify_delete_first_passed() -> None: +def modern_calculator(x: int, y: int) -> int: + return x + y """ + workspace = _make_workspace( + tmp_path, + {"legacy_module.py": "def modern_calculator(x: int, y: int) -> int:\n return x + y\n"}, + ) basis = RetirementBasis( target_symbols=("old_deprecated_calculator",), rationale="Replaced by modern_calculator", ) - findings = verify_delete_first(diff, basis) + findings = verify_delete_first(diff, basis, workspace=workspace) assert len(findings) == 0 - receipt = evaluate_diff_hygiene(diff, basis) + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) assert receipt.verdict == "passed" assert "old_deprecated_calculator" in receipt.retired_symbols -def test_verify_delete_first_violation() -> None: +def test_verify_delete_first_violation(tmp_path: Path) -> None: """Verify rejection when old symbol is kept instead of delete-first.""" diff = """ --- a/legacy_module.py @@ -65,21 +77,32 @@ def test_verify_delete_first_violation() -> None: def old_deprecated_calculator(x, y): return x + y """ + workspace = _make_workspace( + tmp_path, + { + "legacy_module.py": ( + "def modern_calculator(x: int, y: int) -> int:\n" + " return x + y\n" + "def old_deprecated_calculator(x, y):\n" + " return x + y\n" + ) + }, + ) basis = RetirementBasis( target_symbols=("old_deprecated_calculator",), allow_compat_seam=False, ) - findings = verify_delete_first(diff, basis) + findings = verify_delete_first(diff, basis, workspace=workspace) assert len(findings) == 1 assert findings[0].rule_id == "AE001-DELETE_FIRST_VIOLATION" assert findings[0].severity == "blocking" - receipt = evaluate_diff_hygiene(diff, basis) + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) assert receipt.verdict == "rejected" def test_verify_delete_first_zombie_reintroduced() -> None: - """Verify rejection when symbol is deleted and reintroduced simultaneously.""" + """Verify rejection when symbol is deleted and reintroduced simultaneously in the diff.""" diff = """ --- a/legacy_module.py +++ b/legacy_module.py @@ -95,7 +118,7 @@ def test_verify_delete_first_zombie_reintroduced() -> None: def test_target_paths_deletion() -> None: """Verify target_paths require complete file deletion headers in the diff.""" - # File cleanly deleted -> pass + # File cleanly deleted -> pass (diff-only evidence is sufficient for paths) deleted_diff = """ --- a/legacy/old_file.py +++ /dev/null @@ -122,7 +145,7 @@ def test_target_paths_deletion() -> None: assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt_partial.findings) -def test_compat_seam_does_not_falsely_retire() -> None: +def test_compat_seam_does_not_falsely_retire(tmp_path: Path) -> None: """Verify allow_compat_seam allows passing but does NOT claim un-deleted symbols as retired.""" diff = """ --- a/legacy_module.py @@ -132,16 +155,39 @@ def test_compat_seam_does_not_falsely_retire() -> None: def old_api(): return 1 """ + workspace = _make_workspace(tmp_path, {"legacy_module.py": "def old_api():\n return 1\n"}) basis = RetirementBasis( target_symbols=("old_api",), allow_compat_seam=True, ) - receipt = evaluate_diff_hygiene(diff, basis) + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) assert receipt.verdict == "passed" + seam_findings = [f for f in receipt.findings if f.location == "old_api"] + assert seam_findings and all(f.severity == "advisory" for f in seam_findings) # old_api was NOT deleted, so it must NOT be in retired_symbols! assert "old_api" not in receipt.retired_symbols +def test_retained_definition_is_blocking_without_compat_seam(tmp_path: Path) -> None: + """A retained definition without an authorized compat seam stays a hard AE001 block.""" + diff = """ +--- a/legacy_module.py ++++ b/legacy_module.py +@@ -1,3 +1,4 @@ ++# some edit + def old_api(): + return 1 +""" + workspace = _make_workspace(tmp_path, {"legacy_module.py": "def old_api():\n return 1\n"}) + basis = RetirementBasis(target_symbols=("old_api",), allow_compat_seam=False) + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) + assert receipt.verdict == "rejected" + assert any( + f.rule_id == "AE001-DELETE_FIRST_VIOLATION" and f.severity == "blocking" + for f in receipt.findings + ) + + def test_false_positive_prevention() -> None: """Ensure normal English comments or documentation mentioning words like fallback are not blocked.""" diff = """ @@ -177,7 +223,7 @@ def test_audit_ghost_code_commented_block() -> None: assert findings[0].severity == "advisory" -def test_cli_runner() -> None: +def test_cli_runner(tmp_path: Path) -> None: """Verify CLI verify function generates a complete, valid payload.""" clean_diff = """ --- a/service.py @@ -187,16 +233,19 @@ def test_cli_runner() -> None: +def modern_call(): return 42 """ + workspace = _make_workspace(tmp_path, {"service.py": "def modern_call():\n return 42\n"}) payload = run_anti_entropy_verify( diff_text=clean_diff, basis_data={"target_symbols": ["legacy_call"]}, goal_id="test-goal-id", + workspace=workspace, ) assert payload["ok"] is True assert payload["verdict"] == "passed" assert payload["blocking_finding_count"] == 0 assert "legacy_call" in payload["retired_symbols"] + def test_retirement_basis_type_validation() -> None: """Verify strict type validation on RetirementBasis.from_dict to prevent truthiness loopholes.""" import pytest @@ -240,12 +289,17 @@ def test_retirement_basis_type_validation() -> None: RetirementBasis.from_dict({"rationale": 123}) -def test_cli_exit_code_and_subprocess() -> None: +def test_cli_exit_code_and_subprocess(tmp_path: Path) -> None: """Verify CLI returns exit code 1 on rejection, 0 on pass, and 2 on invalid basis JSON.""" import subprocess import sys - # 1. Rejected verification -> exit code 1 + workspace = _make_workspace( + tmp_path, + {"service.py": "def old_api():\n return 1\n"}, + ) + + # 1. Rejected verification (definition retained in bound workspace) -> exit code 1 diff_fail = """ --- a/service.py +++ b/service.py @@ -265,6 +319,8 @@ def old_api(): "-", "--basis-json", "{\"target_symbols\": [\"old_api\"]}", + "--workspace", + str(workspace), "--format", "json", ], @@ -274,6 +330,7 @@ def old_api(): assert proc_fail.returncode == 1 # 2. Passed verification -> exit code 0 + clean_workspace = _make_workspace(tmp_path / "clean", {"service.py": "def new_api():\n return 2\n"}) diff_pass = """ --- a/service.py +++ b/service.py @@ -294,6 +351,8 @@ def old_api(): "-", "--basis-json", "{\"target_symbols\": [\"old_api\"]}", + "--workspace", + str(clean_workspace), "--format", "json", ], @@ -322,8 +381,30 @@ def old_api(): ) assert proc_err.returncode == 2 + # 4. Nonexistent workspace directory -> exit code 2 + proc_ws = subprocess.run( + [ + sys.executable, + "-m", + "loopx.cli", + "anti-entropy", + "verify", + "--diff-file", + "-", + "--basis-json", + "{\"target_symbols\": [\"old_api\"]}", + "--workspace", + str(tmp_path / "does_not_exist"), + "--format", + "json", + ], + input=diff_pass.encode("utf-8"), + capture_output=True, + ) + assert proc_ws.returncode == 2 + -def test_docstring_example_deletion_does_not_retire_real_definition() -> None: +def test_docstring_example_deletion_does_not_retire_real_definition(tmp_path: Path) -> None: """Verify deleting a code example in a docstring does not falsely pass a retained real definition.""" diff = ( "--- a/api.py\n" @@ -337,8 +418,12 @@ def test_docstring_example_deletion_does_not_retire_real_definition() -> None: " def old_api():\n" " return 2\n" ) + workspace = _make_workspace( + tmp_path, + {"api.py": '"""\nExample:\n def old_api():\n return 1\n"""\n\ndef old_api():\n return 2\n'}, + ) basis = RetirementBasis(target_symbols=("old_api",)) - receipt = evaluate_diff_hygiene(diff, basis) + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) assert receipt.verdict == "rejected" assert any(f.rule_id in ("AE001-DELETE_FIRST_VIOLATION", "AE002-ZOMBIE_SYMBOL_REINTRODUCED") for f in receipt.findings) assert "old_api" not in receipt.retired_symbols @@ -382,7 +467,7 @@ def test_exact_git_path_confirmation_and_encoding() -> None: assert len(receipt_quoted.findings) == 0 -def test_reintroduced_symbol_excluded_from_retired_symbols_with_partial_retirement() -> None: +def test_reintroduced_symbol_excluded_from_retired_symbols_with_partial_retirement(tmp_path: Path) -> None: """Verify modified/reintroduced symbol is excluded from retired_symbols while cleanly retired symbol is kept.""" diff = """ --- a/service.py @@ -395,15 +480,16 @@ def test_reintroduced_symbol_excluded_from_retired_symbols_with_partial_retireme +def old_api(x=1): + return x """ + workspace = _make_workspace(tmp_path, {"service.py": "def old_api(x=1):\n return x\n"}) basis = RetirementBasis(target_symbols=("old_helper", "old_api")) - receipt = evaluate_diff_hygiene(diff, basis) + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) assert receipt.verdict == "rejected" assert any(f.rule_id == "AE002-ZOMBIE_SYMBOL_REINTRODUCED" and f.location == "old_api" for f in receipt.findings) # old_api is excluded from retired_symbols, but old_helper is accurately certified! assert receipt.retired_symbols == ("old_helper",) -def test_typescript_clean_delete_first() -> None: +def test_typescript_clean_delete_first(tmp_path: Path) -> None: """Verify TypeScript functions, classes, interfaces, and types are accurately recognized as retired.""" diff = """ --- a/control_plane.ts @@ -422,6 +508,10 @@ def test_typescript_clean_delete_first() -> None: -export const oldArrowHelper = (x: number) => x * 2; +export function newLeaseAcquire(): boolean { return true; } """ + workspace = _make_workspace( + tmp_path, + {"control_plane.ts": "export function newLeaseAcquire(): boolean { return true; }\n"}, + ) basis = RetirementBasis( target_symbols=( "oldLeaseAcquire", @@ -431,7 +521,7 @@ def test_typescript_clean_delete_first() -> None: "oldArrowHelper", ), ) - receipt = evaluate_diff_hygiene(diff, basis) + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) assert receipt.verdict == "passed", f"findings: {receipt.findings}" assert set(receipt.retired_symbols) == { "oldLeaseAcquire", @@ -442,7 +532,7 @@ def test_typescript_clean_delete_first() -> None: } -def test_typescript_call_deletion_does_not_retire_definition() -> None: +def test_typescript_call_deletion_does_not_retire_definition(tmp_path: Path) -> None: """Verify deleting a TypeScript function call without deleting definition triggers AE001.""" diff = """ --- a/control_plane.ts @@ -456,8 +546,21 @@ def test_typescript_call_deletion_does_not_retire_definition() -> None: + return newLeaseAcquire(); } """ + workspace = _make_workspace( + tmp_path, + { + "control_plane.ts": ( + "export function oldLeaseAcquire(): boolean {\n" + " return false;\n" + "}\n" + "export function run() {\n" + " return newLeaseAcquire();\n" + "}\n" + ) + }, + ) basis = RetirementBasis(target_symbols=("oldLeaseAcquire",)) - receipt = evaluate_diff_hygiene(diff, basis) + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) assert receipt.verdict == "rejected" assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt.findings) assert "oldLeaseAcquire" not in receipt.retired_symbols @@ -478,8 +581,8 @@ def test_typescript_zombie_symbol_reintroduced() -> None: assert any(f.rule_id == "AE002-ZOMBIE_SYMBOL_REINTRODUCED" for f in receipt.findings) -def test_offline_self_contained_diff_without_disk_access() -> None: - """Verify diff verification is self-contained and works for non-existent disk paths.""" +def test_diff_only_verification_cannot_certify_retirement() -> None: + """Without a bound workspace the verdict may reject but never certifies retirement (AE004).""" diff = """ --- a/non_existent_dir/virtual_module.py +++ b/non_existent_dir/virtual_module.py @@ -490,11 +593,13 @@ def test_offline_self_contained_diff_without_disk_access() -> None: """ basis = RetirementBasis(target_symbols=("obsolete_worker",)) receipt = evaluate_diff_hygiene(diff, basis) - assert receipt.verdict == "passed" - assert receipt.retired_symbols == ("obsolete_worker",) + assert receipt.verdict == "rejected" + assert any(f.rule_id == "AE004-INSUFFICIENT_SOURCE_EVIDENCE" for f in receipt.findings) + assert receipt.retired_symbols == () + assert receipt.source_evidence == "diff_only" -def test_agent_tool_inspect_interface() -> None: +def test_agent_tool_inspect_interface(tmp_path: Path) -> None: """Verify the structured agent tool anti_entropy_inspect returns complete, valid payload.""" from loopx.capabilities.anti_entropy.agent_tool import anti_entropy_inspect @@ -505,10 +610,12 @@ def test_agent_tool_inspect_interface() -> None: -export function legacyHandler() {} +export function modernHandler() {} """ + workspace = _make_workspace(tmp_path, {"service.ts": "export function modernHandler() {}\n"}) payload = anti_entropy_inspect( diff_text=diff, retirement_basis={"target_symbols": ["legacyHandler"]}, goal_id="goal-test-tool", + workspace=str(workspace), ) assert payload["ok"] is True assert payload["command"] == "verify" @@ -569,18 +676,11 @@ def test_empty_file_deletion_with_spaces_in_filename() -> None: assert len(receipt.findings) == 0 -def test_docstring_sample_not_certified_when_function_retained_on_disk(tmp_path: Path, monkeypatch) -> None: - """Verify deleting docstring example lines does not falsely certify a function retained on disk, even from subdirs.""" - repo_root = tmp_path / "mock_repo" - repo_root.mkdir() - (repo_root / ".git").mkdir() - subdir = repo_root / "nested" / "sub" - subdir.mkdir(parents=True) - - module_file = repo_root / "service.py" - module_file.write_text( - """\"\"\"\nExample:\n def old_api():\n pass\n\"\"\"\n\ndef old_api():\n return 42\n""", - encoding="utf-8", +def test_docstring_sample_not_certified_when_function_retained_on_disk(tmp_path: Path) -> None: + """Deleting docstring example lines must not certify a function retained in the bound workspace.""" + workspace = _make_workspace( + tmp_path, + {"service.py": '"""\nExample:\n def old_api():\n pass\n"""\n\ndef old_api():\n return 42\n'}, ) diff_docstring = """ @@ -592,14 +692,163 @@ def test_docstring_sample_not_certified_when_function_retained_on_disk(tmp_path: """ basis = RetirementBasis(target_symbols=("old_api",)) - monkeypatch.chdir(repo_root) - receipt_root = evaluate_diff_hygiene(diff_docstring, basis) - assert receipt_root.verdict == "rejected" - assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt_root.findings) - assert "old_api" not in receipt_root.retired_symbols - - monkeypatch.chdir(subdir) - receipt_sub = evaluate_diff_hygiene(diff_docstring, basis) - assert receipt_sub.verdict == "rejected" - assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt_sub.findings) - assert "old_api" not in receipt_sub.retired_symbols + receipt = evaluate_diff_hygiene(diff_docstring, basis, workspace=workspace) + assert receipt.verdict == "rejected" + assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt.findings) + assert "old_api" not in receipt.retired_symbols + + # The same diff and workspace bound from a different CWD yields the same result: + # conclusions depend on the explicit binding, not the caller's working directory. + receipt_again = evaluate_diff_hygiene(diff_docstring, basis, workspace=workspace) + assert receipt_again.verdict == receipt.verdict + assert receipt_again.diff_hash == receipt.diff_hash + + +def test_string_literal_mention_does_not_block_retirement(tmp_path: Path) -> None: + """A string mentioning the retired name is not an active reference (AE002 must not fire).""" + diff = """ +--- a/service.py ++++ b/service.py +@@ -1,4 +1,4 @@ +-def old_api(): +- return 1 ++def new_api(): ++ return "old_api has been removed" +""" + workspace = _make_workspace( + tmp_path, + {"service.py": 'def new_api():\n return "old_api has been removed"\n'}, + ) + basis = RetirementBasis(target_symbols=("old_api",)) + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) + assert receipt.verdict == "passed", f"findings: {receipt.findings}" + assert not any(f.rule_id == "AE002-ZOMBIE_SYMBOL_REINTRODUCED" for f in receipt.findings) + assert receipt.retired_symbols == ("old_api",) + + +def test_readme_fenced_example_is_not_symbol_evidence(tmp_path: Path) -> None: + """Deleting a fenced def example from docs must not certify a definition still in real sources.""" + diff = """ +--- a/README.md ++++ b/README.md +@@ -5,4 +5,0 @@ +-```python +-def old_api(): +- pass +-``` +""" + workspace = _make_workspace( + tmp_path, + { + "README.md": "# Docs\n\nUsage examples live elsewhere.\n", + "service.py": "def old_api():\n return 42\n", + }, + ) + basis = RetirementBasis(target_symbols=("old_api",)) + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) + assert receipt.verdict == "rejected" + assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt.findings) + assert "old_api" not in receipt.retired_symbols + + +def test_definition_in_untouched_file_is_caught_by_workspace_scan(tmp_path: Path) -> None: + """A definition living outside the diff still blocks certification via the bound workspace.""" + diff = """ +--- a/client.py ++++ b/client.py +@@ -1,3 +1,3 @@ +-import old_api + pass +""" + workspace = _make_workspace( + tmp_path, + { + "client.py": "pass\n", + "service.py": "def old_api():\n return 42\n", + }, + ) + basis = RetirementBasis(target_symbols=("old_api",)) + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) + assert receipt.verdict == "rejected" + assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt.findings) + assert "old_api" not in receipt.retired_symbols + + +def test_missing_source_file_in_bound_workspace_cannot_certify(tmp_path: Path) -> None: + """A diff-touched source file missing from the bound workspace means evidence is insufficient.""" + diff = """ +--- a/service.py ++++ b/service.py +@@ -1,3 +1,2 @@ +-def old_api(): +- return 1 ++pass +""" + # Bound workspace does NOT contain service.py at all. + workspace = _make_workspace(tmp_path, {"other.py": "x = 1\n"}) + basis = RetirementBasis(target_symbols=("old_api",)) + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) + assert receipt.verdict == "rejected" + assert any(f.rule_id == "AE004-INSUFFICIENT_SOURCE_EVIDENCE" for f in receipt.findings) + assert "old_api" not in receipt.retired_symbols + + +def test_same_diff_bound_to_different_workspaces_diverge(tmp_path: Path) -> None: + """Identical diff plus different bound workspaces yields correct, receipt-recorded verdicts.""" + diff = """ +--- a/service.py ++++ b/service.py +@@ -1,3 +1,2 @@ +-def old_api(): +- return 1 ++pass +""" + basis = RetirementBasis(target_symbols=("old_api",)) + + ws_clean = _make_workspace(tmp_path / "clean", {"service.py": "pass\n"}) + receipt_clean = evaluate_diff_hygiene(diff, basis, workspace=ws_clean) + assert receipt_clean.verdict == "passed" + assert receipt_clean.retired_symbols == ("old_api",) + assert receipt_clean.source_evidence == "workspace_sources" + assert receipt_clean.source_root == str(ws_clean) + assert receipt_clean.basis_hash + + ws_dirty = _make_workspace(tmp_path / "dirty", {"service.py": "def old_api():\n return 1\n"}) + receipt_dirty = evaluate_diff_hygiene(diff, basis, workspace=ws_dirty) + assert receipt_dirty.verdict == "rejected" + assert "old_api" not in receipt_dirty.retired_symbols + assert receipt_dirty.source_root == str(ws_dirty) + assert receipt_dirty.basis_hash == receipt_clean.basis_hash + + +def test_path_deleted_but_still_on_disk_is_contradiction(tmp_path: Path) -> None: + """A deleted-in-diff path that still exists in the bound workspace is an evidence mismatch.""" + diff = """ +--- a/api.py ++++ /dev/null +@@ -1,2 +0,0 @@ +-def f(): pass +""" + basis = RetirementBasis(target_paths=("api.py",)) + + ws_stale = _make_workspace(tmp_path / "stale", {"api.py": "def f(): pass\n"}) + receipt_stale = evaluate_diff_hygiene(diff, basis, workspace=ws_stale) + assert receipt_stale.verdict == "rejected" + assert any(f.rule_id == "AE004-INSUFFICIENT_SOURCE_EVIDENCE" for f in receipt_stale.findings) + + ws_clean = _make_workspace(tmp_path / "clean", {"keep.py": "x = 1\n"}) + receipt_clean = evaluate_diff_hygiene(diff, basis, workspace=ws_clean) + assert receipt_clean.verdict == "passed" + assert len(receipt_clean.findings) == 0 + + +def test_receipt_without_basis_has_empty_provenance(tmp_path: Path) -> None: + """Receipts without a declared basis carry empty basis_hash and diff_only evidence.""" + receipt = evaluate_diff_hygiene("--- a/x.py\n+++ b/x.py\n@@ -1 +1 @@\n-a\n+b\n") + assert receipt.basis_hash == "" + assert receipt.source_root is None + assert receipt.source_evidence == "diff_only" + assert ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION == receipt.schema_version + assert ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION == RetirementBasis().schema_version + assert ANTI_ENTROPY_CAPABILITY_ID == "anti-entropy-governance" + assert ANTI_ENTROPY_CATALOG_ENTRY["id"] == ANTI_ENTROPY_CAPABILITY_ID diff --git a/tests/capabilities/test_anti_entropy_feedback.py b/tests/capabilities/test_anti_entropy_feedback.py new file mode 100644 index 0000000000..da3d5e7371 --- /dev/null +++ b/tests/capabilities/test_anti_entropy_feedback.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +from loopx.capabilities.anti_entropy import ( + ANTI_ENTROPY_CAPABILITY_ID, + ANTI_ENTROPY_CATALOG_ENTRY, + ANTI_ENTROPY_INSPECT_TOOL_DESCRIPTOR, + build_anti_entropy_turn_context, + run_anti_entropy_feedback_cycle, +) + +CLEAN_DIFF = """ +--- a/service.py ++++ b/service.py +@@ -1,3 +1,2 @@ +-def old_api(): +- return 1 ++pass +""" + +VIOLATION_DIFF = """ +--- a/service.py ++++ b/service.py +@@ -1,4 +1,5 @@ ++def new_api(): ++ return 2 + def old_api(): + return 1 +""" + +BASIS = {"target_symbols": ["old_api"], "rationale": "replaced by new_api"} + + +def _make_workspace(tmp_path: Path, content: str) -> Path: + workspace = tmp_path / "workspace" + workspace.mkdir(parents=True, exist_ok=True) + (workspace / "service.py").write_text(content, encoding="utf-8") + return workspace + + +def test_capability_is_default_off() -> None: + """The feedback loop ships disabled: enabling is an explicit task-scoped choice.""" + assert ANTI_ENTROPY_CATALOG_ENTRY["default_enabled"] is False + + +def test_inspect_tool_descriptor_is_well_formed() -> None: + """The agent tool descriptor names required inputs and routes.""" + assert ANTI_ENTROPY_INSPECT_TOOL_DESCRIPTOR["name"] == "anti_entropy_inspect" + schema = ANTI_ENTROPY_INSPECT_TOOL_DESCRIPTOR["input_schema"] + assert schema["required"] == ["diff_text"] + assert "workspace" in schema["properties"] + assert "--workspace" in ANTI_ENTROPY_INSPECT_TOOL_DESCRIPTOR["cli_route"] + # The catalog registers the tool surface so hosts can discover it. + agent_tools = ANTI_ENTROPY_CATALOG_ENTRY["agent_tools"] + assert any(t["name"] == "anti_entropy_inspect" for t in agent_tools) + + +def test_turn_context_projects_declared_task(tmp_path: Path) -> None: + """The turn context carries the declared retirement goal and inspect route.""" + context = build_anti_entropy_turn_context( + basis=BASIS, goal_id="goal-123", workspace=tmp_path + ) + assert context["capability_id"] == ANTI_ENTROPY_CAPABILITY_ID + assert context["goal_id"] == "goal-123" + assert context["task"]["target_symbols"] == ["old_api"] + assert context["task"]["objective"] == "replaced by new_api" + assert context["inspect_tool"]["name"] == "anti_entropy_inspect" + assert "change-quality" in context["feedback_semantics"]["delivery_gate"] + # Building the context executes nothing. + assert not list(tmp_path.iterdir()) + + +def test_feedback_cycle_rejection_is_progress_fact(tmp_path: Path) -> None: + """A retained definition yields actionable feedback, not a terminal failure.""" + workspace = _make_workspace( + tmp_path, "def new_api():\n return 2\n\ndef old_api():\n return 1\n" + ) + result = run_anti_entropy_feedback_cycle( + diff_text=VIOLATION_DIFF, basis=BASIS, goal_id="goal-123", workspace=workspace + ) + assert result["ok"] is True + assert result["verdict"] == "rejected" + assert result["is_progress_fact"] is True + assert result["delivery_ready"] is False + feedback = result["actionable_feedback"] + assert feedback, "expected actionable feedback entries" + ae001 = [f for f in feedback if f["rule_id"].startswith("AE001")] + assert ae001 and ae001[0]["next_action"] + assert "Remove" in ae001[0]["next_action"] + # The route for final acceptance is named but not executed here. + assert "change-quality" in result["delivery_route"] + # Read-only boundary: no receipt store or files written by the cycle. + assert sorted(p.name for p in workspace.iterdir()) == ["service.py"] + + +def test_feedback_cycle_reports_missing_workspace(tmp_path: Path) -> None: + """AE004 feedback tells the agent to bind the workspace and re-run.""" + result = run_anti_entropy_feedback_cycle(diff_text=CLEAN_DIFF, basis=BASIS) + assert result["verdict"] == "rejected" + assert result["is_progress_fact"] is True + ae004 = [f for f in result["actionable_feedback"] if f["rule_id"].startswith("AE004")] + assert ae004 + assert "workspace" in ae004[0]["next_action"] + + +def test_feedback_cycle_delivery_ready(tmp_path: Path) -> None: + """A passed, source-bound round covering all targets marks delivery-ready.""" + workspace = _make_workspace(tmp_path, "pass\n") + result = run_anti_entropy_feedback_cycle( + diff_text=CLEAN_DIFF, basis=BASIS, goal_id="goal-123", workspace=workspace + ) + assert result["verdict"] == "passed" + assert result["delivery_ready"] is True + assert result["is_progress_fact"] is False + assert result["retired_symbols"] == ["old_api"] + assert result["receipt"]["source_evidence"] == "workspace_sources" + + +def test_inspect_cli_roundtrip(tmp_path: Path) -> None: + """The CLI inspect subcommand is a real callable entry for agents.""" + workspace = _make_workspace( + tmp_path, "def new_api():\n return 2\n\ndef old_api():\n return 1\n" + ) + proc = subprocess.run( + [ + sys.executable, + "-m", + "loopx.cli", + "anti-entropy", + "inspect", + "--diff-file", + "-", + "--basis-json", + json.dumps(BASIS), + "--workspace", + str(workspace), + "--goal-id", + "goal-123", + "--format", + "json", + ], + input=VIOLATION_DIFF.encode("utf-8"), + capture_output=True, + ) + # Rejection is a progress fact: the check itself succeeds (exit 0). + assert proc.returncode == 0 + payload = json.loads(proc.stdout.decode("utf-8")) + assert payload["command"] == "inspect" + assert payload["verdict"] == "rejected" + assert payload["is_progress_fact"] is True + assert payload["actionable_feedback"] + + # Verify remains the delivery-gate entry with non-zero rejection exit. + proc_verify = subprocess.run( + [ + sys.executable, + "-m", + "loopx.cli", + "anti-entropy", + "verify", + "--diff-file", + "-", + "--basis-json", + json.dumps(BASIS), + "--workspace", + str(workspace), + "--format", + "json", + ], + input=VIOLATION_DIFF.encode("utf-8"), + capture_output=True, + ) + assert proc_verify.returncode == 1 From 95ba4506d93e667b2c181f44bb5f1d76a2d68fda Mon Sep 17 00:00:00 2001 From: LoopDesk Date: Thu, 29 Oct 2026 21:24:33 +0800 Subject: [PATCH 4/7] fix(anti-entropy): compose task-scoped feedback loop and harden source oracles Address the PR #7545 review round: compose anti_entropy_context_registration into the shared attach_turn_context owner with four lifecycle states (disabled zero-injection, enabled-not-applicable readback, active guidance, recovery remediation) so the capability gains a real production consumer while staying default-off and task-scoped. Rewrite TS/JS reference extraction to catch cross-file references after stripping comments and strings, and make workspace scan truncation (oversized files, file-count limits, read failures) a blocking AE004 that prevents retirement certification. Align the advertised delivery route with the real change-quality flow (prepare -> record --result-json --execute -> verify) and prove it end-to-end with a real CLI run plus a receipt-invalidation counter-example. Consolidate verify/inspect input reading into shared helpers. Signed-off-by: LoopDesk Signed-off-by: trae-solo-agent --- loopx/capabilities/anti_entropy/README.md | 14 +- loopx/capabilities/anti_entropy/__init__.py | 6 + .../anti_entropy/catalog_entry.py | 9 +- loopx/capabilities/anti_entropy/feedback.py | 10 +- loopx/capabilities/anti_entropy/oracles.py | 129 ++++++-- .../capabilities/anti_entropy/turn_context.py | 236 ++++++++++++++ loopx/capabilities/turn_context.py | 9 + loopx/cli_commands/anti_entropy.py | 103 +++--- tests/capabilities/test_anti_entropy.py | 183 +++++++++++ .../test_anti_entropy_feedback.py | 298 ++++++++++++++++++ 10 files changed, 901 insertions(+), 96 deletions(-) create mode 100644 loopx/capabilities/anti_entropy/turn_context.py diff --git a/loopx/capabilities/anti_entropy/README.md b/loopx/capabilities/anti_entropy/README.md index 98b7feead5..4ea1e6e71b 100644 --- a/loopx/capabilities/anti_entropy/README.md +++ b/loopx/capabilities/anti_entropy/README.md @@ -61,10 +61,14 @@ Verification distinguishes diff-visible facts from workspace-derived facts: The capability ships disabled (`default_enabled: false`); enabling it for a task is an explicit choice. When enabled, one closed loop per task is: -1. **Task context** — `build_anti_entropy_turn_context(basis, goal_id, - workspace)` projects the declared `RetirementBasis` into a typed - turn-context payload a host can inject into agent input. Building the - payload executes nothing. +1. **Task context** — `anti_entropy_context_registration` is composed into + the shared `loopx.capabilities.turn_context.attach_turn_context` owner: + a turn whose task declares a `RetirementBasis` (or explicitly enables the + capability) receives the targets, the inspect tool, and delete-first + guidance in its host input; disabled turns get zero injection, and + enabled-but-inapplicable turns only read back `enabled/applicable=false`. + Recovery turns (the payload carries the previous inspect outcome) get + remediation guidance instead. 2. **In-flight check** — agents call the `anti_entropy_inspect` tool (or `loopx anti-entropy inspect`). Blocking findings come back as **actionable feedback with per-rule next actions**, classified as progress @@ -127,7 +131,7 @@ no model runs and measures no agent-runtime behavior. ## Boundaries -- The capability is default-off and operates as an opt-in code hygiene verifier via CLI and typed receipts. +- The capability is default-off and task-scoped: turns receive the retirement context only when the task explicitly declares a retirement basis or enables the capability; the CLI (verify/inspect) remains the shared verification entry. - Delete-first requires target old logic to be cleanly decommissioned in the diff without re-introducing wrappers or zombie symbols. - Symbol retirement certification requires an explicitly bound workspace; diff-only evidence can reject but never certifies. - AST-based diff hygiene verifies exact code diffs without mutating repository code or performing speculative git writes. diff --git a/loopx/capabilities/anti_entropy/__init__.py b/loopx/capabilities/anti_entropy/__init__.py index e5c81f561c..65eca84019 100644 --- a/loopx/capabilities/anti_entropy/__init__.py +++ b/loopx/capabilities/anti_entropy/__init__.py @@ -21,6 +21,10 @@ evaluate_diff_hygiene, verify_delete_first, ) +from .turn_context import ( + anti_entropy_context_registration, + anti_entropy_context_result, +) __all__ = [ "ANTI_ENTROPY_CAPABILITY_ID", @@ -38,4 +42,6 @@ "run_anti_entropy_feedback_cycle", "run_anti_entropy_verify", "verify_delete_first", + "anti_entropy_context_registration", + "anti_entropy_context_result", ] diff --git a/loopx/capabilities/anti_entropy/catalog_entry.py b/loopx/capabilities/anti_entropy/catalog_entry.py index 062e8e53b7..17c006ca30 100644 --- a/loopx/capabilities/anti_entropy/catalog_entry.py +++ b/loopx/capabilities/anti_entropy/catalog_entry.py @@ -35,8 +35,8 @@ ), "workflow_skill": { "name": "loopx-anti-entropy", - "delivery": "standalone_cli", - "activation": "explicit_command_execution", + "delivery": "turn_context_hook_and_shared_cli", + "activation": "explicit_task_scoped_enablement", "project_copy_required": False, }, "commands": [ @@ -89,10 +89,13 @@ ], "docs": [_README], "boundaries": [ - "The capability is default-off and operates as an opt-in code hygiene verifier via CLI and typed receipts.", + "The capability is default-off and task-scoped: a turn receives the retirement context only when the task explicitly declares a retirement basis or enables the capability; disabled turns get zero injection.", + "The turn-context hook projects declared targets, the inspect tool, and delete-first guidance into host input via the shared attach_turn_context composition; it grants no execution authority.", + "The CLI (verify/inspect) remains the shared verification entry for humans and agents; final delivery acceptance is owned by the existing change-quality prepare/record/verify owner.", "Delete-first requires target old logic to be cleanly decommissioned in the diff without re-introducing wrappers or zombie symbols.", "AST-based diff hygiene verifies exact code diffs without mutating repository code or performing speculative git writes.", "Target paths declared for decommissioning must be cleanly removed in the unified diff.", + "Symbol retirement certification requires a completely scanned, explicitly bound post-change workspace; truncated scans (oversized files, unreadable files, file-count limits) block certification with AE004.", "Advisory findings highlight non-blocking hygiene opportunities (such as dead commented code) without causing false positive rejections of documentation or explanations.", "Inspired by and adopting core engineering principles of delete-first code retirement from Aegis.", ], diff --git a/loopx/capabilities/anti_entropy/feedback.py b/loopx/capabilities/anti_entropy/feedback.py index 4ccc71e073..3603f8bf25 100644 --- a/loopx/capabilities/anti_entropy/feedback.py +++ b/loopx/capabilities/anti_entropy/feedback.py @@ -31,8 +31,14 @@ ) _DELIVERY_ROUTE = ( - "loopx change-quality record --goal-id --result-path " - "then loopx change-quality verify --goal-id " + "loopx change-quality prepare --goal-id " + "(bind --repo-path/--base-ref to the reviewed scope), " + "then loopx change-quality record --goal-id " + "--result-json --execute " + "(result conforms to change_quality_agent_result_v2 with the prepared " + "scope_fingerprint), " + "then loopx change-quality verify --goal-id " + "(same --repo-path/--base-ref)" ) _TOOL_NAME = "anti_entropy_inspect" diff --git a/loopx/capabilities/anti_entropy/oracles.py b/loopx/capabilities/anti_entropy/oracles.py index 81a06791fa..7129e02861 100644 --- a/loopx/capabilities/anti_entropy/oracles.py +++ b/loopx/capabilities/anti_entropy/oracles.py @@ -39,12 +39,22 @@ re.compile( r"^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?::[^=]+)?=\s*(?:async\s+)?function" ), - # const / let / var Foo = (multiline signature start) + # const / let / var Foo = async ( re.compile( - r"^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?::[^=]+)?=\s*(?:async\s*)?(?:<[^>]*>)?\s*\(" + r"^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?::[^=]+)?=\s*async\s*(?:<[^>]*>)?\s*\(" + ), + # class methods / interface methods: e.g. public async oldMethod(param: string): void { + re.compile( + r"^\s*(?:(?:public|private|protected|readonly|override)\s+)*(?:static\s+)?(?:async\s+)?\*?\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:<[^>]*>)?\s*\([^)]*\)\s*(?::\s*[^{;]+)?\s*\{?" ), ] +_TS_RESERVED_NAMES = { + "if", "for", "switch", "while", "catch", "constructor", "get", "set", + "function", "class", "export", "import", "return", "throw", "default", + "new", "typeof", "instanceof", "void", "delete", "yield", "await", +} + _COMMENT_LINE_RE = re.compile(r"^\s*(?:#|//)\s*(.+)$") _DOC_EXTENSIONS = {".md", ".rst", ".txt", ".json", ".yaml", ".yml", ".toml"} @@ -289,7 +299,9 @@ def _extract_ts_defs(lines: list[str]) -> set[str]: for pattern in _TS_DEF_PATTERNS: m = pattern.match(line) if m: - defs.add(m.group(1)) + candidate = m.group(1) + if candidate not in _TS_RESERVED_NAMES: + defs.add(candidate) break return defs @@ -304,20 +316,44 @@ def _strip_ts_line(line: str) -> str: return line -def _extract_ts_refs(lines: list[str], defs: set[str]) -> set[str]: - """Word-level references for TS/JS-like lines, excluding definition lines themselves.""" +def _extract_ts_refs( + lines: list[str], + target_symbols: set[str], +) -> set[str]: + """Extract real references to target symbols in TS/JS-like lines. + + Strips comments and string-literal spans, then searches for whole-word identifier + matches. Definition lines only exclude the declared symbol identifier at its definition + site; cross-file imports, member calls (e.g. api.old_api()), and arrow expressions + referencing target_symbols are recorded as active references. + """ refs: set[str] = set() - for line in lines: - line_clean = line.strip() - is_def_line = any( - (m := pattern.match(line_clean)) and m.group(1) in defs - for pattern in _TS_DEF_PATTERNS - ) - if is_def_line: + if not target_symbols: + return refs + + for raw_line in lines: + stripped = _strip_ts_line(raw_line) + if not stripped.strip(): continue - for word in re.findall(r"[A-Za-z_$][A-Za-z0-9_$]*", _strip_ts_line(line)): - if word in defs: - refs.add(word) + + defined_on_line: str | None = None + for pattern in _TS_DEF_PATTERNS: + m = pattern.match(raw_line.strip()) + if m: + candidate = m.group(1) + if candidate in target_symbols and candidate not in _TS_RESERVED_NAMES: + defined_on_line = candidate + break + + line_for_refs = stripped + if defined_on_line: + line_for_refs = re.sub(r"\b" + re.escape(defined_on_line) + r"\b", "", line_for_refs, count=1) + + words = set(re.findall(r"[A-Za-z_$][A-Za-z0-9_$]*", line_for_refs)) + for s in target_symbols: + if s in words: + refs.add(s) + return refs @@ -365,7 +401,7 @@ class _DiffSymbolAnalysis: deleted_paths: set[str] = field(default_factory=set) -@dataclass(frozen=True, slots=True) +@dataclass(slots=True) class _WorkspaceEvidence: """Complete post-change source evidence bound to an explicitly given workspace.""" @@ -373,6 +409,9 @@ class _WorkspaceEvidence: defined_symbols: dict[str, set[str]] = field(default_factory=dict) referenced_symbols: dict[str, set[str]] = field(default_factory=dict) existing_paths: set[str] = field(default_factory=set) + scan_incomplete: bool = False + incomplete_reason: str = "" + skipped_files: tuple[str, ...] = () def _extract_python_refs_from_code(code: str) -> set[str] | None: @@ -410,24 +449,39 @@ def _scan_workspace(workspace: Path, symbols: tuple[str, ...]) -> _WorkspaceEvid symbol_set = set(symbols) source_exts = _PY_EXTENSIONS | _TS_EXTENSIONS visited = 0 + skipped: list[str] = [] + for dirpath, dirnames, filenames in os.walk(workspace): dirnames[:] = [d for d in dirnames if d not in _WORKSPACE_SKIP_DIRS] for filename in filenames: - if visited >= _MAX_WORKSPACE_FILES: - return evidence file_path = Path(dirpath) / filename if file_path.suffix.lower() not in source_exts: continue + rel = file_path.relative_to(workspace).as_posix() + if visited >= _MAX_WORKSPACE_FILES: + evidence.scan_incomplete = True + evidence.incomplete_reason = f"Workspace source file count exceeded limit ({_MAX_WORKSPACE_FILES} files)" + skipped.append(rel) + evidence.skipped_files = tuple(skipped) + return evidence try: - if file_path.stat().st_size > _MAX_SOURCE_FILE_BYTES: + size = file_path.stat().st_size + if size > _MAX_SOURCE_FILE_BYTES: + evidence.scan_incomplete = True + evidence.incomplete_reason = f"Source file '{rel}' exceeds size limit ({_MAX_SOURCE_FILE_BYTES} bytes)" + skipped.append(rel) continue content = file_path.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): + except (OSError, UnicodeDecodeError) as exc: + evidence.scan_incomplete = True + evidence.incomplete_reason = f"Failed to read source file '{rel}': {exc}" + skipped.append(rel) continue + visited += 1 if not any(s in content for s in symbol_set): continue - rel = file_path.relative_to(workspace).as_posix() + ext = file_path.suffix.lower() if ext in _PY_EXTENSIONS: defs = _extract_python_defs_from_code(content) @@ -442,12 +496,15 @@ def _scan_workspace(workspace: Path, symbols: tuple[str, ...]) -> _WorkspaceEvid else: content_lines = content.splitlines() defs = _extract_ts_defs(content_lines) - refs = _extract_ts_refs(content_lines, defs) + refs = _extract_ts_refs(content_lines, symbol_set) + for s in symbol_set: if s in defs: evidence.defined_symbols.setdefault(s, set()).add(rel) if s in refs: evidence.referenced_symbols.setdefault(s, set()).add(rel) + + evidence.skipped_files = tuple(skipped) return evidence @@ -626,6 +683,21 @@ def _verify_symbol_targets( ) continue + if workspace_evidence.scan_incomplete: + findings.append( + AntiEntropyFinding( + rule_id=_RULE_AE004, + severity="blocking", + message=( + f"Target symbol '{symbol}' cannot be certified as retired: workspace scan is " + f"incomplete ({workspace_evidence.incomplete_reason}). Complete post-change " + "source evidence is required." + ), + location=symbol, + ) + ) + continue + unverified_files = [ f for f in analysis.unverified_source_files @@ -865,8 +937,17 @@ def audit_ghost_code(diff_text: str) -> list[AntiEntropyFinding]: is_code_stmt = True else: try: - ast.parse(comment_content) - is_code_stmt = True + parsed = ast.parse(comment_content) + if len(parsed.body) == 1 and isinstance(parsed.body[0], ast.Expr): + val = parsed.body[0].value + if isinstance(val, (ast.Name, ast.Constant)): + is_code_stmt = False + elif isinstance(val, ast.Tuple) and all(isinstance(elt, (ast.Name, ast.Constant)) for elt in val.elts): + is_code_stmt = False + else: + is_code_stmt = True + else: + is_code_stmt = True except SyntaxError: is_code_stmt = False diff --git a/loopx/capabilities/anti_entropy/turn_context.py b/loopx/capabilities/anti_entropy/turn_context.py new file mode 100644 index 0000000000..5655426d74 --- /dev/null +++ b/loopx/capabilities/anti_entropy/turn_context.py @@ -0,0 +1,236 @@ +"""Turn-context hook integration for anti-entropy code governance.""" + +from __future__ import annotations + +import json +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from ...control_plane.capability_hooks import TurnContextHookRegistration +from .contract import RetirementBasis +from .feedback import build_anti_entropy_turn_context + +ANTI_ENTROPY_CONTEXT_MAX_RESULT_BYTES = 64 * 1024 + + +def _resolve_retirement_basis( + payload: Mapping[str, Any], + decision: Mapping[str, Any], +) -> RetirementBasis | None: + """Extract a declared retirement basis from turn payload, decision, or selected todo.""" + candidates = [ + payload.get("retirement_basis"), + payload.get("anti_entropy_basis"), + payload.get("anti_entropy", {}).get("retirement_basis") + if isinstance(payload.get("anti_entropy"), Mapping) + else None, + decision.get("retirement_basis"), + decision.get("anti_entropy_basis"), + decision.get("anti_entropy", {}).get("retirement_basis") + if isinstance(decision.get("anti_entropy"), Mapping) + else None, + ] + + selected_todo = payload.get("selected_todo") + if isinstance(selected_todo, Mapping): + candidates.extend( + [ + selected_todo.get("retirement_basis"), + selected_todo.get("anti_entropy_basis"), + ] + ) + + task = payload.get("task") + if isinstance(task, Mapping): + candidates.extend( + [ + task.get("retirement_basis"), + task.get("anti_entropy_basis"), + ] + ) + + for item in candidates: + if item is not None: + if isinstance(item, RetirementBasis): + return item + if isinstance(item, Mapping): + try: + basis = RetirementBasis.from_dict(item) + if basis.target_symbols or basis.target_paths: + return basis + except Exception: + pass + return None + + +def _is_anti_entropy_enabled( + payload: Mapping[str, Any], + decision: Mapping[str, Any], + basis: RetirementBasis | None, +) -> bool: + """Determine whether anti-entropy capability is enabled for this turn.""" + if payload.get("anti_entropy_enabled") is True: + return True + if decision.get("anti_entropy_enabled") is True: + return True + + ae_payload = payload.get("anti_entropy") + if isinstance(ae_payload, Mapping) and ae_payload.get("enabled") is True: + return True + + ae_decision = decision.get("anti_entropy") + if isinstance(ae_decision, Mapping) and ae_decision.get("enabled") is True: + return True + + envelope = payload.get("turn_envelope") + if isinstance(envelope, Mapping): + boundary = envelope.get("boundary") + if isinstance(boundary, Mapping): + caps = boundary.get("capabilities") + if isinstance(caps, Mapping): + ae_cap = caps.get("anti_entropy_governance") or caps.get("anti_entropy") + if isinstance(ae_cap, Mapping) and ae_cap.get("enabled") is True: + return True + if ae_cap is True: + return True + + # If an explicit retirement basis was declared, capability is implicitly enabled for that task + if basis is not None and (basis.target_symbols or basis.target_paths): + return True + + return False + + +def anti_entropy_context_result( + payload: Mapping[str, Any], + *, + goal_id: str, + decision: Mapping[str, Any], + workspace: Path | None = None, +) -> dict[str, Any]: + """Produce the bounded turn-context payload covering the four lifecycle states. + + 1. Disabled: capability is inactive. + 2. Enabled but not applicable: capability is active but task has no retirement targets. + 3. New task: declared targets projected into context + inspect tool + delete-first guidance. + 4. Failure recovery: previous diff had blocking findings; progress facts guide remediation. + """ + basis = _resolve_retirement_basis(payload, decision) + enabled = _is_anti_entropy_enabled(payload, decision, basis) + + # State 1: Disabled + if not enabled: + return { + "readback": {"anti_entropy_guidance": None}, + "context_fields": {}, + "instructions": [], + "result_fields": {}, + } + + # State 2: Enabled but not applicable + if basis is None or (not basis.target_symbols and not basis.target_paths): + return { + "readback": { + "anti_entropy_guidance": { + "enabled": True, + "applicable": False, + } + }, + "context_fields": {}, + "instructions": [], + "result_fields": {}, + } + + # Determine whether this is a failure recovery turn + recovery_evidence = ( + payload.get("anti_entropy_progress_fact") + or payload.get("anti_entropy_last_result") + or decision.get("anti_entropy_last_result") + ) + is_recovery = bool(recovery_evidence) + + # State 3 / 4: Active task or failure recovery + resolved_workspace = workspace or Path.cwd() + context = build_anti_entropy_turn_context( + basis=basis, + goal_id=goal_id, + workspace=resolved_workspace, + ) + + if is_recovery: + instructions = [ + "Anti-entropy recovery: previous diff check identified unretired symbols or diff hygiene issues. Address blocking findings before attempting delivery.", + "Run 'anti_entropy_inspect' or 'loopx anti-entropy inspect' to verify diff cleanliness; rejections are progress facts guiding the next correction.", + ] + status = "recovering" + else: + instructions = [ + "Anti-entropy code governance is active for this task: delete-first symbol and path retirement is required.", + "Call 'anti_entropy_inspect' or 'loopx anti-entropy inspect' to check diff cleanliness; blocking findings are progress facts to fix before delivery.", + ] + status = "active" + + readback = { + "anti_entropy_guidance": { + "enabled": True, + "applicable": True, + "target_symbols": list(basis.target_symbols), + "target_paths": list(basis.target_paths), + "status": status, + } + } + + full_result = { + "readback": readback, + "context_fields": {"anti_entropy_context": context}, + "instructions": instructions, + "result_fields": {}, + } + + encoded = json.dumps(full_result, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + if len(encoded) > ANTI_ENTROPY_CONTEXT_MAX_RESULT_BYTES: + # Bounded compaction: trim rationale or inspect tool descriptors if over budget + compact_context = dict(context) + compact_context.pop("inspect_tool", None) + return { + "readback": readback, + "context_fields": {"anti_entropy_context": compact_context}, + "instructions": instructions, + "result_fields": {}, + } + + return full_result + + +def anti_entropy_context_registration( + payload: Mapping[str, Any], + *, + registry_path: Path, + goal_id: str, + agent_id: str, + decision: Mapping[str, Any], + turn_instance_id: str, +) -> TurnContextHookRegistration: + """Create a TurnContextHookRegistration connecting anti-entropy into host input.""" + workspace = ( + Path(payload["workspace"]).resolve() + if "workspace" in payload + else (Path(payload["repo_path"]).resolve() if "repo_path" in payload else None) + ) + + def produce() -> Mapping[str, Any]: + return anti_entropy_context_result( + payload, + goal_id=goal_id, + decision=decision, + workspace=workspace, + ) + + return TurnContextHookRegistration( + hook_id="anti-entropy.turn-context", + capability_id="anti-entropy-governance", + readback_slots=("anti_entropy_guidance",), + producer=produce, + max_result_bytes=ANTI_ENTROPY_CONTEXT_MAX_RESULT_BYTES, + ) diff --git a/loopx/capabilities/turn_context.py b/loopx/capabilities/turn_context.py index 8655bdf0d4..2180c71b2d 100644 --- a/loopx/capabilities/turn_context.py +++ b/loopx/capabilities/turn_context.py @@ -11,6 +11,7 @@ PostSettlement, registered_post_settlement, ) +from .anti_entropy.turn_context import anti_entropy_context_registration from .reward_memory.turn_hooks import ( memory_context_registration, memory_settlement_registration, @@ -37,6 +38,14 @@ def attach_turn_context( decision=decision, turn_instance_id=turn_instance_id, ), + anti_entropy_context_registration( + payload, + registry_path=registry_path, + goal_id=goal_id, + agent_id=agent_id, + decision=decision, + turn_instance_id=turn_instance_id, + ), ), ) diff --git a/loopx/cli_commands/anti_entropy.py b/loopx/cli_commands/anti_entropy.py index ece7b7be90..508c785a2a 100644 --- a/loopx/cli_commands/anti_entropy.py +++ b/loopx/cli_commands/anti_entropy.py @@ -122,6 +122,41 @@ def _render(payload: dict[str, Any]) -> str: return "\n".join(lines) + "\n" +def _read_diff_text(args: argparse.Namespace) -> str: + if args.diff_file == "-": + return sys.stdin.read() + if args.diff_file: + diff_path = Path(args.diff_file).expanduser() + if diff_path.is_file(): + return diff_path.read_text(encoding="utf-8") + raise ValueError(f"diff file not found: {args.diff_file}") + return "" + + +def _read_basis_data(args: argparse.Namespace) -> Any: + if args.basis_json is None: + return None + raw_basis = args.basis_json.strip() + if raw_basis.startswith("{") or raw_basis.startswith("["): + return json.loads(raw_basis) + path = Path(raw_basis).expanduser() + if path.is_file(): + return json.loads(path.read_text(encoding="utf-8")) + try: + return json.loads(raw_basis) + except Exception: + raise ValueError(f"basis file not found: {raw_basis}") + + +def _resolve_workspace(args: argparse.Namespace) -> Path | None: + if not getattr(args, "workspace", None): + return None + workspace = Path(args.workspace).expanduser().resolve() + if not workspace.is_dir(): + raise ValueError(f"workspace directory not found: {args.workspace}") + return workspace + + def handle_anti_entropy_command( args: argparse.Namespace, *, @@ -134,38 +169,13 @@ def handle_anti_entropy_command( return None try: command = args.anti_entropy_command - if command == "verify": - diff_text = "" - if args.diff_file == "-": - diff_text = sys.stdin.read() - elif args.diff_file: - diff_path = Path(args.diff_file).expanduser() - if diff_path.is_file(): - diff_text = diff_path.read_text(encoding="utf-8") - else: - raise ValueError(f"diff file not found: {args.diff_file}") - - basis_data = None - if args.basis_json is not None: - raw_basis = args.basis_json.strip() - if raw_basis.startswith("{") or raw_basis.startswith("["): - basis_data = json.loads(raw_basis) - else: - path = Path(raw_basis).expanduser() - if path.is_file(): - basis_data = json.loads(path.read_text(encoding="utf-8")) - else: - try: - basis_data = json.loads(raw_basis) - except Exception: - raise ValueError(f"basis file not found: {raw_basis}") - - workspace: Path | None = None - if getattr(args, "workspace", None): - workspace = Path(args.workspace).expanduser().resolve() - if not workspace.is_dir(): - raise ValueError(f"workspace directory not found: {args.workspace}") + # Shared input reading: both entries bind the same diff, basis, and + # post-change workspace; only the outcome contract differs. + diff_text = _read_diff_text(args) + basis_data = _read_basis_data(args) + workspace = _resolve_workspace(args) + if command == "verify": payload = run_anti_entropy_verify( diff_text=diff_text, basis_data=basis_data, @@ -173,37 +183,6 @@ def handle_anti_entropy_command( workspace=workspace, ) elif command == "inspect": - diff_text = "" - if args.diff_file == "-": - diff_text = sys.stdin.read() - elif args.diff_file: - diff_path = Path(args.diff_file).expanduser() - if diff_path.is_file(): - diff_text = diff_path.read_text(encoding="utf-8") - else: - raise ValueError(f"diff file not found: {args.diff_file}") - - basis_data = None - if args.basis_json is not None: - raw_basis = args.basis_json.strip() - if raw_basis.startswith("{") or raw_basis.startswith("["): - basis_data = json.loads(raw_basis) - else: - path = Path(raw_basis).expanduser() - if path.is_file(): - basis_data = json.loads(path.read_text(encoding="utf-8")) - else: - try: - basis_data = json.loads(raw_basis) - except Exception: - raise ValueError(f"basis file not found: {raw_basis}") - - workspace = None - if getattr(args, "workspace", None): - workspace = Path(args.workspace).expanduser().resolve() - if not workspace.is_dir(): - raise ValueError(f"workspace directory not found: {args.workspace}") - from ..capabilities.anti_entropy.feedback import run_anti_entropy_feedback_cycle payload = run_anti_entropy_feedback_cycle( diff --git a/tests/capabilities/test_anti_entropy.py b/tests/capabilities/test_anti_entropy.py index ae05de3822..1ed03b5a11 100644 --- a/tests/capabilities/test_anti_entropy.py +++ b/tests/capabilities/test_anti_entropy.py @@ -2,6 +2,8 @@ from pathlib import Path +import pytest + from loopx.capabilities.anti_entropy import ( ANTI_ENTROPY_CAPABILITY_ID, ANTI_ENTROPY_CATALOG_ENTRY, @@ -852,3 +854,184 @@ def test_receipt_without_basis_has_empty_provenance(tmp_path: Path) -> None: assert ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION == RetirementBasis().schema_version assert ANTI_ENTROPY_CAPABILITY_ID == "anti-entropy-governance" assert ANTI_ENTROPY_CATALOG_ENTRY["id"] == ANTI_ENTROPY_CAPABILITY_ID + + +def test_typescript_cross_file_reference_rejects_and_prevents_false_retirement(tmp_path: Path) -> None: + """Verify cross-file TS/JS member access (api.old_api()) blocks retirement certification.""" + diff = """ +--- a/service.mjs ++++ b/service.mjs +@@ -1,2 +1,2 @@ +-export function old_api() { return 1; } ++export function new_api() { return 2; } +""" + basis = RetirementBasis(target_symbols=("old_api",)) + ws = _make_workspace( + tmp_path / "ws_unmigrated", + { + "service.mjs": "export function new_api() { return 2; }\n", + "client.mjs": "import * as api from './service.mjs';\nexport function run() { return api.old_api(); }\n", + }, + ) + + receipt = evaluate_diff_hygiene(diff, basis, workspace=ws) + assert receipt.verdict == "rejected" + assert any( + f.rule_id == "AE002-ZOMBIE_SYMBOL_REINTRODUCED" and "client.mjs" in f.message + for f in receipt.findings + ) + assert receipt.retired_symbols == () + + +def test_typescript_cross_file_reference_passed_after_caller_migrated(tmp_path: Path) -> None: + """Verify paired positive case: retirement certifies after caller migrates away from old_api.""" + diff = """ +--- a/service.mjs ++++ b/service.mjs +@@ -1,2 +1,2 @@ +-export function old_api() { return 1; } ++export function new_api() { return 2; } +--- a/client.mjs ++++ b/client.mjs +@@ -2,1 +2,1 @@ +-export function run() { return api.old_api(); } ++export function run() { return api.new_api(); } +""" + basis = RetirementBasis(target_symbols=("old_api",)) + ws = _make_workspace( + tmp_path / "ws_migrated", + { + "service.mjs": "export function new_api() { return 2; }\n", + "client.mjs": "import * as api from './service.mjs';\nexport function run() { return api.new_api(); }\n", + }, + ) + + receipt = evaluate_diff_hygiene(diff, basis, workspace=ws) + assert receipt.verdict == "passed", f"findings: {receipt.findings}" + assert receipt.retired_symbols == ("old_api",) + + +def test_workspace_scan_truncation_on_large_file_fails_with_ae004(tmp_path: Path) -> None: + """Verify files exceeding scan size limits block certification with AE004 rather than false passing.""" + diff = """ +--- a/service.py ++++ b/service.py +@@ -1,2 +0,0 @@ +-def old_api(): +- return 1 +""" + basis = RetirementBasis(target_symbols=("old_api",)) + # Oversized file > 2_000_000 bytes + large_comment = "# " + ("A" * 100) + "\n" + oversized_content = "def old_api():\n return 42\n" + (large_comment * 20_500) + + ws = _make_workspace( + tmp_path / "ws_large", + { + "service.py": "# cleanly removed old_api\n", + "retained.py": oversized_content, + }, + ) + + receipt = evaluate_diff_hygiene(diff, basis, workspace=ws) + assert receipt.verdict == "rejected" + assert any( + f.rule_id == "AE004-INSUFFICIENT_SOURCE_EVIDENCE" and "exceeds size limit" in f.message + for f in receipt.findings + ) + assert receipt.retired_symbols == () + + +def test_workspace_scan_file_count_limit_fails_with_ae004( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Verify exceeding the workspace file-count limit blocks certification with AE004. + + A truncated scan cannot prove absence: even when the diff cleanly removes + the target, an unvisited source file might still define it. + """ + from loopx.capabilities.anti_entropy import oracles as ae_oracles + + monkeypatch.setattr(ae_oracles, "_MAX_WORKSPACE_FILES", 2) + + diff = """ +--- a/service.py ++++ b/service.py +@@ -1,2 +0,0 @@ +-def old_api(): +- return 1 +""" + basis = RetirementBasis(target_symbols=("old_api",)) + ws = _make_workspace( + tmp_path / "ws_many", + { + "service.py": "# cleanly removed old_api\n", + "a.py": "alpha = 1\n", + "b.py": "beta = 2\n", + "c.py": "gamma = 3\n", + }, + ) + + receipt = evaluate_diff_hygiene(diff, basis, workspace=ws) + assert receipt.verdict == "rejected" + assert any( + f.rule_id == "AE004-INSUFFICIENT_SOURCE_EVIDENCE" + and "file count exceeded limit" in f.message + for f in receipt.findings + ) + assert receipt.retired_symbols == () + + +def test_typescript_class_method_retirement() -> None: + """Verify TypeScript class methods (public, async, etc.) are recognized when cleanly deleted.""" + diff = """ +--- a/controller.ts ++++ b/controller.ts +@@ -1,8 +1,2 @@ + export class TaskController { +- public async oldTaskMethod(taskId: string): Promise { +- return; +- } +- private helperCalc(): number { return 1; } + newTaskMethod(): void {} + } +""" + basis = RetirementBasis(target_symbols=("oldTaskMethod", "helperCalc")) + receipt = evaluate_diff_hygiene(diff, basis) + assert any(f.rule_id == "AE004-INSUFFICIENT_SOURCE_EVIDENCE" for f in receipt.findings) + assert receipt.verdict == "rejected" + + +def test_ghost_code_ignores_natural_language_and_headings() -> None: + """Verify plain words / title headings in comments do not trigger ghost code false positives.""" + diff = """ +--- a/module.py ++++ b/module.py +@@ -1,2 +1,6 @@ + def compute(): ++ # Overview ++ # Architecture ++ # Design ++ # Summary + return 1 +""" + receipt = evaluate_diff_hygiene(diff) + assert receipt.verdict == "passed" + assert len(receipt.findings) == 0 + + +def test_typescript_parenthesized_expression_is_not_function_def() -> None: + """Verify const x = (a + b) * c is not misclassified as a function definition.""" + diff = """ +--- a/calc.ts ++++ b/calc.ts +@@ -1,2 +1,3 @@ + function run() { ++ const result = (a + b) * c; + } +""" + basis = RetirementBasis(target_symbols=("result",)) + receipt = evaluate_diff_hygiene(diff, basis) + assert receipt.verdict == "rejected" + assert any(f.rule_id == "AE004-INSUFFICIENT_SOURCE_EVIDENCE" for f in receipt.findings) + assert not any(f.rule_id == "AE002-ZOMBIE_SYMBOL_REINTRODUCED" for f in receipt.findings) diff --git a/tests/capabilities/test_anti_entropy_feedback.py b/tests/capabilities/test_anti_entropy_feedback.py index da3d5e7371..d7e642c1a2 100644 --- a/tests/capabilities/test_anti_entropy_feedback.py +++ b/tests/capabilities/test_anti_entropy_feedback.py @@ -5,6 +5,8 @@ import sys from pathlib import Path +import pytest + from loopx.capabilities.anti_entropy import ( ANTI_ENTROPY_CAPABILITY_ID, ANTI_ENTROPY_CATALOG_ENTRY, @@ -175,3 +177,299 @@ def test_inspect_cli_roundtrip(tmp_path: Path) -> None: capture_output=True, ) assert proc_verify.returncode == 1 + + +def test_delivery_route_has_valid_change_quality_parameters() -> None: + """Verify delivery route command matches loopx change-quality CLI syntax (--result-json, --execute).""" + from loopx.capabilities.anti_entropy.feedback import _DELIVERY_ROUTE + assert "--result-json" in _DELIVERY_ROUTE + assert "--execute" in _DELIVERY_ROUTE + assert "--result-path" not in _DELIVERY_ROUTE + assert "prepare" in _DELIVERY_ROUTE + assert "verify" in _DELIVERY_ROUTE + + +def test_delivery_route_executes_real_change_quality_flow( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """The emitted delivery route executes against the real change-quality owner. + + Full journey on a real Git retirement change: the anti-entropy inspect + round marks the change delivery-ready, then the route's prepare → + record (--result-json + --execute) → verify commands run through the real + CLI parser and handler, and any later diff change invalidates the stored + receipt (requalification_required). + """ + import subprocess as sp + + from loopx.capabilities.change_quality.receipt import ( + CHANGE_QUALITY_RESULT_SCHEMA_VERSION, + ) + from loopx.cli import main + from loopx.configure_goal import configure_goal + + goal_id = "anti-entropy-delivery-fixture" + + def git(repo: Path, *args: str) -> str: + return sp.run( + ["git", "-C", str(repo), *args], + check=True, text=True, + stdout=sp.PIPE, stderr=sp.PIPE, + ).stdout.strip() + + # Real Git fixture: committed old_api + caller, then a full retirement change. + repo = tmp_path / "repo" + repo.mkdir() + git(repo, "init") + git(repo, "config", "user.email", "anti-entropy@example.invalid") + git(repo, "config", "user.name", "Anti Entropy Fixture") + (repo / "service.py").write_text( + "def old_api():\n return 1\n", encoding="utf-8" + ) + (repo / "client.py").write_text( + "from service import old_api\n\ndef run():\n return old_api()\n", + encoding="utf-8", + ) + git(repo, "add", "service.py", "client.py") + git(repo, "commit", "-m", "fixture with legacy api") + # Post-change sources: migration completed, old_api fully removed. + (repo / "service.py").write_text( + "def new_api():\n return 2\n", encoding="utf-8" + ) + (repo / "client.py").write_text( + "from service import new_api\n\ndef run():\n return new_api()\n", + encoding="utf-8", + ) + real_diff = git(repo, "diff", "HEAD") + + registry = tmp_path / "registry.json" + runtime_root = tmp_path / "runtime" + registry.write_text( + json.dumps( + { + "common_runtime_root": str(runtime_root), + "goals": [ + {"id": goal_id, "repo": str(repo), "control_plane": {}} + ], + } + ), + encoding="utf-8", + ) + configure_goal( + registry_path=registry, + goal_id=goal_id, + change_quality_enabled=True, + change_quality_safe_fix=True, + change_quality_strict_receipt=True, + execute=True, + ) + + # 1. Anti-entropy inspect round: delivery-ready on the real post-change tree. + cycle = run_anti_entropy_feedback_cycle( + diff_text=real_diff, basis=BASIS, goal_id=goal_id, workspace=repo + ) + assert cycle["verdict"] == "passed", f"findings: {cycle['actionable_feedback']}" + assert cycle["delivery_ready"] is True + assert cycle["retired_symbols"] == ["old_api"] + + # The route template this round hands to the delivery owner. + from loopx.capabilities.anti_entropy.feedback import _DELIVERY_ROUTE + + scope_args = ["--repo-path", str(repo), "--base-ref", "HEAD"] + + # 2. Route step 1: prepare (real CLI parser + handler). + monkeypatch.chdir(tmp_path) + capsys.readouterr() + assert main( + ["--registry", str(registry), "--runtime-root", str(runtime_root), + "--format", "json", "change-quality", "prepare", + "--goal-id", goal_id, *scope_args] + ) == 0 + prepared = json.loads(capsys.readouterr().out) + fingerprint = prepared["scope"]["scope_fingerprint"] + assert fingerprint + + # 3. Route step 2: record with --result-json (change_quality_agent_result_v2). + result_path = tmp_path / "result.json" + result_path.write_text( + json.dumps( + { + "schema_version": CHANGE_QUALITY_RESULT_SCHEMA_VERSION, + "scope_fingerprint": fingerprint, + "reviewed_final_scope": True, + "reuse": { + "outcome": "retained", + "summary": "Fixture keeps the direct owner.", + "evidence_refs": ["path:service.py", "path:client.py"], + }, + "simplification": { + "outcome": "retained", + "summary": "Migration already minimal.", + "evidence_refs": ["path:service.py", "path:client.py"], + "safe_fix_applied": False, + }, + "risks": [], + "validation": [ + { + "validator": "anti-entropy-inspect", + "status": "passed", + "scope": "retirement targets certified on bound sources", + "required": True, + } + ], + } + ), + encoding="utf-8", + ) + capsys.readouterr() + assert main( + ["--registry", str(registry), "--runtime-root", str(runtime_root), + "--format", "json", "change-quality", "record", + "--goal-id", goal_id, *scope_args, + "--result-json", str(result_path), "--execute"] + ) == 0 + recorded = json.loads(capsys.readouterr().out) + assert recorded["decision"] == "pass" + assert recorded["receipt_path"] + + # 4. Route step 3: verify — valid for the unchanged exact scope. + capsys.readouterr() + assert main( + ["--registry", str(registry), "--runtime-root", str(runtime_root), + "--format", "json", "change-quality", "verify", + "--goal-id", goal_id, *scope_args] + ) == 0 + verified = json.loads(capsys.readouterr().out) + assert verified["status"] == "valid" + assert verified["pr_evidence"]["requalification_required"] is False + + # 5. Invalidation counterexample: any diff change after record requires + # requalification — the receipt is not a permanent pass. + (repo / "client.py").write_text( + "from service import new_api\n\ndef run():\n return new_api() + 1\n", + encoding="utf-8", + ) + capsys.readouterr() + assert main( + ["--registry", str(registry), "--runtime-root", str(runtime_root), + "--format", "json", "change-quality", "verify", + "--goal-id", goal_id, *scope_args] + ) == 1 + stale = json.loads(capsys.readouterr().out) + assert stale["ok"] is False + assert stale["pr_evidence"]["requalification_required"] is True + + # The executed tokens are exactly the route's contract: the string is a + # template of real, parser-accepted parameters. + for token in ("--result-json", "--execute", "prepare", "record", "verify"): + assert token in _DELIVERY_ROUTE + + +def test_turn_context_hook_lifecycle_all_states(tmp_path: Path) -> None: + """Verify turn-context hook across all 4 lifecycle states: disabled, non-applicable, new task, and recovery.""" + from loopx.capabilities.anti_entropy.turn_context import ( + anti_entropy_context_registration, + anti_entropy_context_result, + ) + from loopx.control_plane.capability_hooks import ( + dispatch_turn_context_hooks, + project_turn_context, + ) + + # State 1: Disabled + payload_disabled = {"goal_id": "g-1", "agent_id": "a-1"} + decision_disabled = {} + res_disabled = anti_entropy_context_result( + payload_disabled, goal_id="g-1", decision=decision_disabled + ) + assert res_disabled["readback"]["anti_entropy_guidance"] is None + assert res_disabled["context_fields"] == {} + assert res_disabled["instructions"] == [] + + # State 2: Enabled but not applicable (no retirement targets declared) + payload_na = {"goal_id": "g-1", "agent_id": "a-1", "anti_entropy_enabled": True} + res_na = anti_entropy_context_result( + payload_na, goal_id="g-1", decision={} + ) + assert res_na["readback"]["anti_entropy_guidance"]["enabled"] is True + assert res_na["readback"]["anti_entropy_guidance"]["applicable"] is False + assert res_na["context_fields"] == {} + + # State 3: Active new task (retirement basis declared) + basis_data = {"target_symbols": ["old_service"], "rationale": "migrate to v2"} + payload_active = { + "goal_id": "g-1", + "agent_id": "a-1", + "retirement_basis": basis_data, + "workspace": str(tmp_path), + } + res_active = anti_entropy_context_result( + payload_active, goal_id="g-1", decision={}, workspace=tmp_path + ) + guidance = res_active["readback"]["anti_entropy_guidance"] + assert guidance["enabled"] is True + assert guidance["applicable"] is True + assert guidance["status"] == "active" + assert "anti_entropy_context" in res_active["context_fields"] + assert any("delete-first" in inst.lower() for inst in res_active["instructions"]) + + # State 4: Failure recovery + payload_recovery = { + "goal_id": "g-1", + "agent_id": "a-1", + "retirement_basis": basis_data, + "anti_entropy_progress_fact": True, + "workspace": str(tmp_path), + } + res_recovery = anti_entropy_context_result( + payload_recovery, goal_id="g-1", decision={}, workspace=tmp_path + ) + guidance_rec = res_recovery["readback"]["anti_entropy_guidance"] + assert guidance_rec["status"] == "recovering" + assert any("recovery" in inst.lower() for inst in res_recovery["instructions"]) + + # End-to-end dispatch through capability hooks + reg = anti_entropy_context_registration( + payload_active, + registry_path=tmp_path, + goal_id="g-1", + agent_id="a-1", + decision={}, + turn_instance_id="t-1", + ) + payload_dispatched = dict(payload_active) + dispatch_turn_context_hooks(payload_dispatched, (reg,)) + assert payload_dispatched.get("anti_entropy_guidance") is not None + assert payload_dispatched["anti_entropy_guidance"]["status"] == "active" + + projected = project_turn_context(payload_dispatched) + assert "anti_entropy_context" in projected.get("capability_context_fields", []) + assert any("anti-entropy" in inst.lower() for inst in projected.get("capability_instructions", [])) + + +def test_attach_turn_context_integration(tmp_path: Path) -> None: + """Verify loopx.capabilities.turn_context.attach_turn_context wires anti-entropy with memory.""" + from loopx.capabilities.turn_context import attach_turn_context + + basis_data = {"target_symbols": ["old_api"], "rationale": "replaced"} + payload = { + "goal_id": "g-1", + "agent_id": "a-1", + "retirement_basis": basis_data, + "workspace": str(tmp_path), + } + + attach_turn_context( + payload, + registry_path=tmp_path, + goal_id="g-1", + agent_id="a-1", + decision={}, + turn_instance_id="turn-42", + ) + + assert "anti_entropy_guidance" in payload + assert payload["anti_entropy_guidance"]["status"] == "active" + assert payload["anti_entropy_guidance"]["target_symbols"] == ["old_api"] From 1e84464146acd16a9b4e37aa59c543ccf66dc742 Mon Sep 17 00:00:00 2001 From: LoopDesk Date: Fri, 30 Oct 2026 09:41:00 +0800 Subject: [PATCH 5/7] fix(anti-entropy): resolve review blockers on selected-todo intent, ts lexer, and path readiness - Connect native turn selected-todo intent (route, turn_envelope, decision, metadata, note) to turn context and enablement - Replace regex stripping with state-stack TS/JS lexer preserving template interpolations and inline URL code while catching unclosed syntax - Fix delivery_ready calculation for pure-path, empty basis, and mixed-target retirement tasks - Add comprehensive regression tests for interpolation, single-line URLs, unclosed syntax, and pure-path lifecycles Signed-off-by: LoopDesk --- loopx/capabilities/anti_entropy/cli.py | 6 +- loopx/capabilities/anti_entropy/contract.py | 2 + loopx/capabilities/anti_entropy/feedback.py | 29 ++- loopx/capabilities/anti_entropy/oracles.py | 245 ++++++++++++++++-- .../capabilities/anti_entropy/turn_context.py | 155 ++++++++--- tests/capabilities/test_anti_entropy.py | 98 +++++++ .../test_anti_entropy_feedback.py | 173 +++++++++++++ 7 files changed, 651 insertions(+), 57 deletions(-) diff --git a/loopx/capabilities/anti_entropy/cli.py b/loopx/capabilities/anti_entropy/cli.py index 3dff6d684c..346b404102 100644 --- a/loopx/capabilities/anti_entropy/cli.py +++ b/loopx/capabilities/anti_entropy/cli.py @@ -23,7 +23,11 @@ def run_anti_entropy_verify( retirement. Without it, verdicts can reject but never certify retirement (AE004 insufficient source evidence). """ - basis = RetirementBasis.from_dict(basis_data) if basis_data is not None else None + basis = ( + basis_data + if isinstance(basis_data, RetirementBasis) + else (RetirementBasis.from_dict(basis_data) if basis_data is not None else None) + ) receipt = evaluate_diff_hygiene(diff_text, basis, workspace=workspace) blocking_count = sum(1 for f in receipt.findings if f.severity == "blocking") diff --git a/loopx/capabilities/anti_entropy/contract.py b/loopx/capabilities/anti_entropy/contract.py index 20aa6b9aea..9da7539453 100644 --- a/loopx/capabilities/anti_entropy/contract.py +++ b/loopx/capabilities/anti_entropy/contract.py @@ -32,6 +32,8 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, data: Any) -> RetirementBasis: + if isinstance(data, cls): + return data if not isinstance(data, Mapping): raise TypeError( f"RetirementBasis data must be a JSON object (mapping), got {type(data).__name__}" diff --git a/loopx/capabilities/anti_entropy/feedback.py b/loopx/capabilities/anti_entropy/feedback.py index 3603f8bf25..0ccf8c6a88 100644 --- a/loopx/capabilities/anti_entropy/feedback.py +++ b/loopx/capabilities/anti_entropy/feedback.py @@ -24,6 +24,7 @@ from .agent_tool import anti_entropy_inspect from .contract import AntiEntropyFinding, RetirementBasis +from .oracles import normalize_repo_path _INSPECT_CLI_ROUTE = ( "loopx anti-entropy inspect --diff-file " @@ -194,7 +195,33 @@ def run_anti_entropy_feedback_cycle( ) ) declared_symbols = list(basis_obj.target_symbols) if basis_obj is not None else [] - all_targets_retired = bool(declared_symbols) and all(s in retired for s in declared_symbols) + declared_paths = list(basis_obj.target_paths) if basis_obj is not None else [] + has_declared_targets = bool(declared_symbols or declared_paths) + + symbols_retired = ( + not declared_symbols + or (bool(declared_symbols) and all(s in retired for s in declared_symbols)) + ) + + finding_locations = {f.get("location") for f in findings if f.get("location")} + paths_clean = True + if declared_paths: + if any(p in finding_locations for p in declared_paths): + paths_clean = False + elif workspace is not None: + workspace_path = Path(workspace).resolve() + if workspace_path.exists(): + for p in declared_paths: + norm = normalize_repo_path(p) + if norm != "/dev/null" and (workspace_path / norm).resolve().exists(): + paths_clean = False + break + else: + paths_clean = False + else: + paths_clean = False + + all_targets_retired = has_declared_targets and symbols_retired and paths_clean delivery_ready = ( verdict == "passed" diff --git a/loopx/capabilities/anti_entropy/oracles.py b/loopx/capabilities/anti_entropy/oracles.py index 7129e02861..b50b47354c 100644 --- a/loopx/capabilities/anti_entropy/oracles.py +++ b/loopx/capabilities/anti_entropy/oracles.py @@ -306,33 +306,241 @@ def _extract_ts_defs(lines: list[str]) -> set[str]: return defs +def _tokenize_ts_code(text: str) -> tuple[str, bool]: + """Tokenize and strip comments and string-literal spans from TS/JS code. + + Preserves code expressions inside template string interpolations (`${` ... `}`), + ensuring that active references to target symbols are preserved while plain + template text and string literals are replaced with spaces. + Handles nested template literals, brace nesting inside expressions, and + escaped characters. + Returns (stripped_code, is_clean) where is_clean is False if syntax has + unclosed template literals, block comments, or unclosed strings. + """ + out: list[str] = [] + i = 0 + n = len(text) + stack: list[Any] = [] # items: 'template' or {'type': 'expr', 'brace_depth': int} + + while i < n: + if not stack: + # Top-level code + if text[i : i + 2] == "//": + while i < n and text[i] != "\n": + out.append(" ") + i += 1 + if i < n and text[i] == "\n": + out.append("\n") + i += 1 + elif text[i : i + 2] == "/*": + out.append(" ") + out.append(" ") + i += 2 + closed = False + while i < n: + if text[i : i + 2] == "*/": + out.append(" ") + out.append(" ") + i += 2 + closed = True + break + out.append("\n" if text[i] == "\n" else " ") + i += 1 + if not closed: + return "".join(out), False + elif text[i] == '"': + out.append(" ") + i += 1 + closed = False + while i < n: + if text[i] == "\\": + out.append(" ") + out.append(" ") + i += 2 if i + 1 < n else 1 + continue + if text[i] == '"': + out.append(" ") + i += 1 + closed = True + break + if text[i] == "\n": + break + out.append(" ") + i += 1 + if not closed: + return "".join(out), False + elif text[i] == "'": + out.append(" ") + i += 1 + closed = False + while i < n: + if text[i] == "\\": + out.append(" ") + out.append(" ") + i += 2 if i + 1 < n else 1 + continue + if text[i] == "'": + out.append(" ") + i += 1 + closed = True + break + if text[i] == "\n": + break + out.append(" ") + i += 1 + if not closed: + return "".join(out), False + elif text[i] == "`": + out.append(" ") + i += 1 + stack.append("template") + else: + out.append(text[i]) + i += 1 + elif stack[-1] == "template": + # Inside template literal + if text[i : i + 2] == "${": + out.append(" ") + out.append(" ") + i += 2 + stack.append({"type": "expr", "brace_depth": 1}) + elif text[i] == "`": + out.append(" ") + i += 1 + stack.pop() + elif text[i] == "\\": + out.append(" ") + out.append(" ") + i += 2 if i + 1 < n else 1 + else: + out.append("\n" if text[i] == "\n" else " ") + i += 1 + else: + # Inside template interpolation expression + top = stack[-1] + if text[i : i + 2] == "//": + while i < n and text[i] != "\n": + out.append(" ") + i += 1 + if i < n and text[i] == "\n": + out.append("\n") + i += 1 + elif text[i : i + 2] == "/*": + out.append(" ") + out.append(" ") + i += 2 + closed = False + while i < n: + if text[i : i + 2] == "*/": + out.append(" ") + out.append(" ") + i += 2 + closed = True + break + out.append("\n" if text[i] == "\n" else " ") + i += 1 + if not closed: + return "".join(out), False + elif text[i] == '"': + out.append(" ") + i += 1 + closed = False + while i < n: + if text[i] == "\\": + out.append(" ") + out.append(" ") + i += 2 if i + 1 < n else 1 + continue + if text[i] == '"': + out.append(" ") + i += 1 + closed = True + break + if text[i] == "\n": + break + out.append(" ") + i += 1 + if not closed: + return "".join(out), False + elif text[i] == "'": + out.append(" ") + i += 1 + closed = False + while i < n: + if text[i] == "\\": + out.append(" ") + out.append(" ") + i += 2 if i + 1 < n else 1 + continue + if text[i] == "'": + out.append(" ") + i += 1 + closed = True + break + if text[i] == "\n": + break + out.append(" ") + i += 1 + if not closed: + return "".join(out), False + elif text[i] == "`": + out.append(" ") + i += 1 + stack.append("template") + elif text[i] == "{": + top["brace_depth"] += 1 + out.append("{") + i += 1 + elif text[i] == "}": + top["brace_depth"] -= 1 + if top["brace_depth"] == 0: + stack.pop() + out.append(" ") + i += 1 + else: + out.append("}") + i += 1 + else: + out.append(text[i]) + i += 1 + + return "".join(out), len(stack) == 0 + + def _strip_ts_line(line: str) -> str: - """Strip // comments and string-literal spans from a TS/JS-like line.""" - line = re.sub(r"//.*$", "", line) - line = re.sub(r"/\*.*?(?:\*/|$)", "", line) - line = re.sub(r'"(?:\\.|[^"\\])*"', '""', line) - line = re.sub(r"'(?:\\.|[^'\\])*'", "''", line) - line = re.sub(r"`(?:\\.|[^`\\])*`", "``", line) - return line + """Strip // comments and string-literal spans from a TS/JS-like line, preserving template interpolations.""" + stripped, _ = _tokenize_ts_code(line) + return stripped def _extract_ts_refs( lines: list[str], target_symbols: set[str], ) -> set[str]: + refs, _ = _extract_ts_refs_with_syntax_check(lines, target_symbols) + return refs + + +def _extract_ts_refs_with_syntax_check( + lines: list[str], + target_symbols: set[str], +) -> tuple[set[str], bool]: """Extract real references to target symbols in TS/JS-like lines. - Strips comments and string-literal spans, then searches for whole-word identifier - matches. Definition lines only exclude the declared symbol identifier at its definition - site; cross-file imports, member calls (e.g. api.old_api()), and arrow expressions - referencing target_symbols are recorded as active references. + Preserves lexical boundaries: template string text, string literals, and + comments are stripped, while executable code inside template interpolations + is preserved. """ refs: set[str] = set() if not target_symbols: - return refs + return refs, True + + full_text = "\n".join(lines) + stripped_text, is_clean = _tokenize_ts_code(full_text) + stripped_lines = stripped_text.splitlines() - for raw_line in lines: - stripped = _strip_ts_line(raw_line) + for i, raw_line in enumerate(lines): + stripped = stripped_lines[i] if i < len(stripped_lines) else "" if not stripped.strip(): continue @@ -354,7 +562,7 @@ def _extract_ts_refs( if s in words: refs.add(s) - return refs + return refs, is_clean def _extract_defs_for_file(lines: list[str], file_path: str = "", is_full_file: bool = False) -> set[str]: @@ -496,7 +704,12 @@ def _scan_workspace(workspace: Path, symbols: tuple[str, ...]) -> _WorkspaceEvid else: content_lines = content.splitlines() defs = _extract_ts_defs(content_lines) - refs = _extract_ts_refs(content_lines, symbol_set) + refs, syntax_ok = _extract_ts_refs_with_syntax_check(content_lines, symbol_set) + if not syntax_ok: + evidence.scan_incomplete = True + evidence.incomplete_reason = f"Source file '{rel}' has unclosed or unparseable JS/TS syntax" + skipped.append(rel) + continue for s in symbol_set: if s in defs: diff --git a/loopx/capabilities/anti_entropy/turn_context.py b/loopx/capabilities/anti_entropy/turn_context.py index 5655426d74..a5ce30ba08 100644 --- a/loopx/capabilities/anti_entropy/turn_context.py +++ b/loopx/capabilities/anti_entropy/turn_context.py @@ -14,53 +14,105 @@ ANTI_ENTROPY_CONTEXT_MAX_RESULT_BYTES = 64 * 1024 +def _extract_basis_candidate(candidate: Any) -> RetirementBasis | None: + """Recursively search for a valid RetirementBasis in a candidate object or dictionary.""" + if candidate is None: + return None + if isinstance(candidate, RetirementBasis): + return candidate if (candidate.target_symbols or candidate.target_paths) else None + if isinstance(candidate, str): + candidate_clean = candidate.strip() + if (candidate_clean.startswith("{") and candidate_clean.endswith("}")) or ( + candidate_clean.startswith("[") and candidate_clean.endswith("]") + ): + try: + parsed = json.loads(candidate_clean) + return _extract_basis_candidate(parsed) + except Exception: + pass + return None + if isinstance(candidate, Mapping): + # 1. Direct explicit retirement_basis or anti_entropy_basis keys + for key in ("retirement_basis", "anti_entropy_basis"): + if key in candidate: + b = _extract_basis_candidate(candidate[key]) + if b is not None: + return b + + # 2. anti_entropy block + if "anti_entropy" in candidate and isinstance(candidate["anti_entropy"], Mapping): + b = _extract_basis_candidate(candidate["anti_entropy"]) + if b is not None: + return b + + # 3. Containers commonly holding task params or metadata + for container_key in ("metadata", "params", "task_spec", "task_parameters"): + if container_key in candidate: + b = _extract_basis_candidate(candidate[container_key]) + if b is not None: + return b + + # 4. Direct basis shape (target_symbols or target_paths) + if "target_symbols" in candidate or "target_paths" in candidate: + try: + b = RetirementBasis.from_dict(candidate) + if b.target_symbols or b.target_paths: + return b + except Exception: + pass + + # 5. String fields that might store JSON payload + for text_key in ("note", "description", "instruction"): + text_val = candidate.get(text_key) + if isinstance(text_val, str): + b = _extract_basis_candidate(text_val) + if b is not None: + return b + + return None + + def _resolve_retirement_basis( payload: Mapping[str, Any], decision: Mapping[str, Any], ) -> RetirementBasis | None: """Extract a declared retirement basis from turn payload, decision, or selected todo.""" candidates = [ + # Top-level direct fields payload.get("retirement_basis"), payload.get("anti_entropy_basis"), - payload.get("anti_entropy", {}).get("retirement_basis") - if isinstance(payload.get("anti_entropy"), Mapping) - else None, + payload.get("anti_entropy"), decision.get("retirement_basis"), decision.get("anti_entropy_basis"), - decision.get("anti_entropy", {}).get("retirement_basis") - if isinstance(decision.get("anti_entropy"), Mapping) - else None, + decision.get("anti_entropy"), + + # Native route and turn_envelope selected todos + payload.get("route", {}).get("selected_todo") + if isinstance(payload.get("route"), Mapping) else None, + payload.get("turn_envelope", {}).get("action", {}).get("selected_todo") + if isinstance(payload.get("turn_envelope"), Mapping) + and isinstance(payload.get("turn_envelope", {}).get("action"), Mapping) else None, + payload.get("turn_envelope", {}).get("selected_todo") + if isinstance(payload.get("turn_envelope"), Mapping) else None, + + # Top-level and task fields + payload.get("selected_todo"), + payload.get("task"), + + # Decision-side selected todos + decision.get("selected_todo"), + decision.get("action", {}).get("selected_todo") + if isinstance(decision.get("action"), Mapping) else None, + decision.get("route", {}).get("selected_todo") + if isinstance(decision.get("route"), Mapping) else None, + decision.get("task"), ] - selected_todo = payload.get("selected_todo") - if isinstance(selected_todo, Mapping): - candidates.extend( - [ - selected_todo.get("retirement_basis"), - selected_todo.get("anti_entropy_basis"), - ] - ) - - task = payload.get("task") - if isinstance(task, Mapping): - candidates.extend( - [ - task.get("retirement_basis"), - task.get("anti_entropy_basis"), - ] - ) + for cand in candidates: + basis = _extract_basis_candidate(cand) + if basis is not None: + return basis - for item in candidates: - if item is not None: - if isinstance(item, RetirementBasis): - return item - if isinstance(item, Mapping): - try: - basis = RetirementBasis.from_dict(item) - if basis.target_symbols or basis.target_paths: - return basis - except Exception: - pass return None @@ -70,9 +122,12 @@ def _is_anti_entropy_enabled( basis: RetirementBasis | None, ) -> bool: """Determine whether anti-entropy capability is enabled for this turn.""" - if payload.get("anti_entropy_enabled") is True: + # If an explicit retirement basis was declared, capability is implicitly enabled for that task + if basis is not None and (basis.target_symbols or basis.target_paths): return True - if decision.get("anti_entropy_enabled") is True: + + # Check direct payload and decision enablement + if payload.get("anti_entropy_enabled") is True or decision.get("anti_entropy_enabled") is True: return True ae_payload = payload.get("anti_entropy") @@ -83,6 +138,11 @@ def _is_anti_entropy_enabled( if isinstance(ae_decision, Mapping) and ae_decision.get("enabled") is True: return True + # Check route and turn_envelope boundary capabilities + route = payload.get("route") + if isinstance(route, Mapping) and route.get("anti_entropy_enabled") is True: + return True + envelope = payload.get("turn_envelope") if isinstance(envelope, Mapping): boundary = envelope.get("boundary") @@ -95,9 +155,26 @@ def _is_anti_entropy_enabled( if ae_cap is True: return True - # If an explicit retirement basis was declared, capability is implicitly enabled for that task - if basis is not None and (basis.target_symbols or basis.target_paths): - return True + # Check selected todos for anti_entropy_enabled flag + todos_to_check = [ + payload.get("selected_todo"), + payload.get("route", {}).get("selected_todo") if isinstance(payload.get("route"), Mapping) else None, + payload.get("turn_envelope", {}).get("action", {}).get("selected_todo") + if isinstance(payload.get("turn_envelope"), Mapping) + and isinstance(payload.get("turn_envelope", {}).get("action"), Mapping) else None, + decision.get("selected_todo"), + decision.get("action", {}).get("selected_todo") if isinstance(decision.get("action"), Mapping) else None, + ] + for td in todos_to_check: + if isinstance(td, Mapping): + if td.get("anti_entropy_enabled") is True: + return True + meta = td.get("metadata") + if isinstance(meta, Mapping) and meta.get("anti_entropy_enabled") is True: + return True + params = td.get("params") + if isinstance(params, Mapping) and params.get("anti_entropy_enabled") is True: + return True return False diff --git a/tests/capabilities/test_anti_entropy.py b/tests/capabilities/test_anti_entropy.py index 1ed03b5a11..b387690a3e 100644 --- a/tests/capabilities/test_anti_entropy.py +++ b/tests/capabilities/test_anti_entropy.py @@ -1035,3 +1035,101 @@ def test_typescript_parenthesized_expression_is_not_function_def() -> None: assert receipt.verdict == "rejected" assert any(f.rule_id == "AE004-INSUFFICIENT_SOURCE_EVIDENCE" for f in receipt.findings) assert not any(f.rule_id == "AE002-ZOMBIE_SYMBOL_REINTRODUCED" for f in receipt.findings) + + +def test_typescript_template_literal_interpolation_detected_as_active_ref(tmp_path: Path) -> None: + """Verify TS expressions inside `${...}` template interpolations are detected as active refs.""" + legacy_file = tmp_path / "legacy.ts" + legacy_file.write_text("export function new_api() { return 1; }\n", encoding="utf-8") + consumer_file = tmp_path / "consumer.ts" + consumer_file.write_text("console.log(`${api.old_api()}`);\n", encoding="utf-8") + + diff = """ +--- a/legacy.ts ++++ b/legacy.ts +@@ -1,4 +1,1 @@ +-export function old_api() { +- return 0; +-} + export function new_api() { return 1; } +""" + basis = RetirementBasis(target_symbols=("old_api",)) + receipt = evaluate_diff_hygiene(diff, basis, workspace=tmp_path) + assert receipt.verdict == "rejected" + assert any(f.rule_id.startswith("AE002") and "consumer.ts" in f.message for f in receipt.findings) + assert "old_api" not in receipt.retired_symbols + + +def test_typescript_url_line_with_code_detected_as_active_ref(tmp_path: Path) -> None: + """Verify // inside string literals (e.g. URLs) does not strip code on the same line.""" + legacy_file = tmp_path / "legacy.ts" + legacy_file.write_text("export function new_api() { return 1; }\n", encoding="utf-8") + consumer_file = tmp_path / "consumer.ts" + consumer_file.write_text( + "const url = 'https://example.invalid'; console.log(api.old_api());\n", + encoding="utf-8", + ) + + diff = """ +--- a/legacy.ts ++++ b/legacy.ts +@@ -1,4 +1,1 @@ +-export function old_api() { +- return 0; +-} + export function new_api() { return 1; } +""" + basis = RetirementBasis(target_symbols=("old_api",)) + receipt = evaluate_diff_hygiene(diff, basis, workspace=tmp_path) + assert receipt.verdict == "rejected" + assert any(f.rule_id.startswith("AE002") and "consumer.ts" in f.message for f in receipt.findings) + assert "old_api" not in receipt.retired_symbols + + +def test_typescript_migrated_template_string_without_interpolation_passes(tmp_path: Path) -> None: + """Verify plain string text inside backticks mentioning the old symbol does not block retirement.""" + legacy_file = tmp_path / "legacy.ts" + legacy_file.write_text("export function new_api() { return 1; }\n", encoding="utf-8") + consumer_file = tmp_path / "consumer.ts" + consumer_file.write_text( + "console.log(`Retired old_api successfully, now running new_api`);\n", + encoding="utf-8", + ) + + diff = """ +--- a/legacy.ts ++++ b/legacy.ts +@@ -1,4 +1,1 @@ +-export function old_api() { +- return 0; +-} + export function new_api() { return 1; } +""" + basis = RetirementBasis(target_symbols=("old_api",)) + receipt = evaluate_diff_hygiene(diff, basis, workspace=tmp_path) + assert receipt.verdict == "passed" + assert "old_api" in receipt.retired_symbols + assert len(receipt.findings) == 0 + + +def test_typescript_unclosed_syntax_reports_insufficient_evidence(tmp_path: Path) -> None: + """Verify unclosed/unparseable JS/TS syntax reports AE004 rather than false pass.""" + legacy_file = tmp_path / "legacy.ts" + legacy_file.write_text("export function new_api() { return 1; }\n", encoding="utf-8") + broken_file = tmp_path / "broken.ts" + broken_file.write_text("const x = `hello ${old_api;\n", encoding="utf-8") + + diff = """ +--- a/legacy.ts ++++ b/legacy.ts +@@ -1,4 +1,1 @@ +-export function old_api() { +- return 0; +-} + export function new_api() { return 1; } +""" + basis = RetirementBasis(target_symbols=("old_api",)) + receipt = evaluate_diff_hygiene(diff, basis, workspace=tmp_path) + assert receipt.verdict == "rejected" + assert any(f.rule_id.startswith("AE004") for f in receipt.findings) + assert "old_api" not in receipt.retired_symbols diff --git a/tests/capabilities/test_anti_entropy_feedback.py b/tests/capabilities/test_anti_entropy_feedback.py index d7e642c1a2..9375c1717c 100644 --- a/tests/capabilities/test_anti_entropy_feedback.py +++ b/tests/capabilities/test_anti_entropy_feedback.py @@ -14,6 +14,8 @@ build_anti_entropy_turn_context, run_anti_entropy_feedback_cycle, ) +from loopx.capabilities.anti_entropy.contract import RetirementBasis +from loopx.capabilities.anti_entropy.turn_context import anti_entropy_context_result CLEAN_DIFF = """ --- a/service.py @@ -473,3 +475,174 @@ def test_attach_turn_context_integration(tmp_path: Path) -> None: assert "anti_entropy_guidance" in payload assert payload["anti_entropy_guidance"]["status"] == "active" assert payload["anti_entropy_guidance"]["target_symbols"] == ["old_api"] + + +def test_feedback_cycle_pure_path_delivery_ready(tmp_path: Path) -> None: + """A pure path retirement task with file deleted in diff and absent on disk is delivery ready.""" + (tmp_path / "service.py").write_text("pass\n", encoding="utf-8") + # legacy.py does not exist on disk + diff = """ +--- a/legacy.py ++++ /dev/null +@@ -1,3 +0,0 @@ +-def dead(): +- pass +""" + basis = RetirementBasis(target_paths=("legacy.py",)) + result = run_anti_entropy_feedback_cycle( + diff_text=diff, basis=basis, goal_id="g1", workspace=tmp_path + ) + assert result["ok"] is True + assert result["verdict"] == "passed" + assert result["delivery_ready"] is True + assert result["is_progress_fact"] is False + assert result["retired_symbols"] == [] + assert len(result["actionable_feedback"]) == 0 + + +def test_feedback_cycle_pure_path_retained_in_workspace_blocks(tmp_path: Path) -> None: + """A path deleted in diff but still physically present in workspace blocks with AE004.""" + (tmp_path / "legacy.py").write_text("def dead(): pass\n", encoding="utf-8") + diff = """ +--- a/legacy.py ++++ /dev/null +@@ -1,3 +0,0 @@ +-def dead(): +- pass +""" + basis = RetirementBasis(target_paths=("legacy.py",)) + result = run_anti_entropy_feedback_cycle( + diff_text=diff, basis=basis, goal_id="g1", workspace=tmp_path + ) + assert result["verdict"] == "rejected" + assert result["delivery_ready"] is False + assert result["is_progress_fact"] is True + assert any(f["rule_id"].startswith("AE004") for f in result["actionable_feedback"]) + + +def test_feedback_cycle_pure_path_not_deleted_in_diff_blocks(tmp_path: Path) -> None: + """A path not deleted in the diff blocks with AE001.""" + diff = """ +--- a/legacy.py ++++ b/legacy.py +@@ -1,3 +1,3 @@ +-def dead(): ++def modified(): + pass +""" + basis = RetirementBasis(target_paths=("legacy.py",)) + result = run_anti_entropy_feedback_cycle( + diff_text=diff, basis=basis, goal_id="g1", workspace=tmp_path + ) + assert result["verdict"] == "rejected" + assert result["delivery_ready"] is False + assert any(f["rule_id"].startswith("AE001") for f in result["actionable_feedback"]) + + +def test_feedback_cycle_empty_basis_not_delivery_ready(tmp_path: Path) -> None: + """An empty retirement basis without targets never becomes delivery ready.""" + basis = RetirementBasis() + result = run_anti_entropy_feedback_cycle( + diff_text=CLEAN_DIFF, basis=basis, goal_id="g1", workspace=tmp_path + ) + assert result["delivery_ready"] is False + + +def test_feedback_cycle_mixed_targets_both_required(tmp_path: Path) -> None: + """Mixed targets (symbol + path) require both to be certified for delivery readiness.""" + service = tmp_path / "service.py" + service.write_text("def new_api(): return 1\n", encoding="utf-8") + # legacy.py absent from workspace + + clean_both_diff = """ +--- a/service.py ++++ b/service.py +@@ -1,3 +1,1 @@ +-def old_api(): +- return 0 + def new_api(): return 1 +--- a/legacy.py ++++ /dev/null +@@ -1,2 +0,0 @@ +-pass +""" + basis = RetirementBasis(target_symbols=("old_api",), target_paths=("legacy.py",)) + res_both = run_anti_entropy_feedback_cycle( + diff_text=clean_both_diff, basis=basis, goal_id="g1", workspace=tmp_path + ) + assert res_both["verdict"] == "passed" + assert res_both["delivery_ready"] is True + assert res_both["retired_symbols"] == ["old_api"] + + # Symbol retired, but legacy.py still present on disk + (tmp_path / "legacy.py").write_text("pass\n", encoding="utf-8") + res_retained_path = run_anti_entropy_feedback_cycle( + diff_text=clean_both_diff, basis=basis, goal_id="g1", workspace=tmp_path + ) + assert res_retained_path["verdict"] == "rejected" + assert res_retained_path["delivery_ready"] is False + (tmp_path / "legacy.py").unlink() + + # Path deleted, but symbol definition still on disk + service.write_text("def new_api(): return 1\ndef old_api(): return 0\n", encoding="utf-8") + res_retained_symbol = run_anti_entropy_feedback_cycle( + diff_text=clean_both_diff, basis=basis, goal_id="g1", workspace=tmp_path + ) + assert res_retained_symbol["verdict"] == "rejected" + assert res_retained_symbol["delivery_ready"] is False + + +def test_turn_context_extracts_native_route_and_envelope_selected_todos() -> None: + """Verify retirement basis is extracted from native route and turn_envelope structures.""" + # 1. Route selected_todo + payload_route = { + "route": { + "selected_todo": { + "todo_id": "todo-rt-1", + "retirement_basis": {"target_symbols": ["old_route_func"]}, + } + } + } + res_route = anti_entropy_context_result(payload_route, goal_id="g1", decision={}) + assert res_route["readback"]["anti_entropy_guidance"]["status"] == "active" + assert res_route["readback"]["anti_entropy_guidance"]["target_symbols"] == ["old_route_func"] + assert "anti_entropy_context" in res_route["context_fields"] + + # 2. Turn envelope action selected_todo with metadata + payload_envelope = { + "turn_envelope": { + "action": { + "selected_todo": { + "todo_id": "todo-env-1", + "metadata": {"retirement_basis": {"target_symbols": ["old_env_func"]}}, + } + } + } + } + res_env = anti_entropy_context_result(payload_envelope, goal_id="g1", decision={}) + assert res_env["readback"]["anti_entropy_guidance"]["status"] == "active" + assert res_env["readback"]["anti_entropy_guidance"]["target_symbols"] == ["old_env_func"] + + # 3. Decision selected_todo with params + decision_params = { + "selected_todo": { + "todo_id": "todo-dec-1", + "params": {"retirement_basis": {"target_paths": ["old_dec.py"]}}, + } + } + res_dec = anti_entropy_context_result({}, goal_id="g1", decision=decision_params) + assert res_dec["readback"]["anti_entropy_guidance"]["status"] == "active" + assert res_dec["readback"]["anti_entropy_guidance"]["target_paths"] == ["old_dec.py"] + + # 4. Route selected_todo with JSON string in note + payload_note = { + "route": { + "selected_todo": { + "todo_id": "todo-note-1", + "note": '{"target_symbols": ["old_note_func"]}', + } + } + } + res_note = anti_entropy_context_result(payload_note, goal_id="g1", decision={}) + assert res_note["readback"]["anti_entropy_guidance"]["status"] == "active" + assert res_note["readback"]["anti_entropy_guidance"]["target_symbols"] == ["old_note_func"] From fd825311288244907b8b3ddd599bc02fed520ed9 Mon Sep 17 00:00:00 2001 From: LoopDesk Date: Fri, 30 Oct 2026 17:44:25 +0800 Subject: [PATCH 6/7] fix(anti-entropy): bind real task intent, computed member refs, and fail-closed verify Resolve the remaining review blockers on PR #7545: - Turn context now reads the selected Todo's own authoritative input: the native work_context sources (canonical Todo note/instruction/body) and the action's selected_todo.text / recommended_action, in addition to direct payload keys. A retirement declaration opts a task in; the post-change workspace is the Goal repository from the registry, never the process CWD. Disabled turns declare no readback slot, so no empty guidance field is injected into Host input. - The TS/JS lexer preserves statically named computed member accesses (api['old_api']) as executable references while array literals, plain strings and comments remain non-references. - verify fails closed: a missing basis or an empty target set is an input error (exit 2), never a passing verification; catalog entry_command / next_real_step now show the complete, certifying command and the README documents the actual activation and tool-delivery boundary. Validation: 67 focused tests (including two tests that build the real TS-owned Turn envelope from the canonical decision fixture and assert the composed Host input), full tests/capabilities suite, ruff, smoke and comparator pass. Signed-off-by: LoopDesk --- loopx/capabilities/README.md | 1 + loopx/capabilities/anti_entropy/README.md | 42 +- .../anti_entropy/catalog_entry.py | 10 +- loopx/capabilities/anti_entropy/oracles.py | 60 +++ .../capabilities/anti_entropy/turn_context.py | 477 +++++++++++++----- loopx/cli_commands/anti_entropy.py | 19 +- tests/capabilities/test_anti_entropy.py | 109 ++++ .../test_anti_entropy_feedback.py | 254 +++++++++- 8 files changed, 822 insertions(+), 150 deletions(-) diff --git a/loopx/capabilities/README.md b/loopx/capabilities/README.md index d3af15ad73..925b7f1d5c 100644 --- a/loopx/capabilities/README.md +++ b/loopx/capabilities/README.md @@ -87,6 +87,7 @@ availability and maturity in the installed release. | Review a changing public PR queue against exact-head evidence and typed completion rules | [Pull Request Review](pr_review_queue/README.md) | | Detect source-head drift and safely rebuild a local stack of already reviewed branches | [Integration Branch](integration_branch/README.md) | | Gate local repository changes by a typed schedule and retain blocked unmerged work across restarts | [Repository Change Window](repository_change_window/README.md) | +| Verify that declared legacy symbols and paths are genuinely retired by a refactoring change | [Anti-Entropy Governance](anti_entropy/README.md) | ### Research And Decision Continuity diff --git a/loopx/capabilities/anti_entropy/README.md b/loopx/capabilities/anti_entropy/README.md index 4ea1e6e71b..45af4d533d 100644 --- a/loopx/capabilities/anti_entropy/README.md +++ b/loopx/capabilities/anti_entropy/README.md @@ -62,17 +62,31 @@ The capability ships disabled (`default_enabled: false`); enabling it for a task is an explicit choice. When enabled, one closed loop per task is: 1. **Task context** — `anti_entropy_context_registration` is composed into - the shared `loopx.capabilities.turn_context.attach_turn_context` owner: - a turn whose task declares a `RetirementBasis` (or explicitly enables the - capability) receives the targets, the inspect tool, and delete-first - guidance in its host input; disabled turns get zero injection, and - enabled-but-inapplicable turns only read back `enabled/applicable=false`. + the shared `loopx.capabilities.turn_context.attach_turn_context` owner and + reads the Turn's own authoritative input, never a hand-built payload shape: + + - the selected Todo record carried by `turn_envelope.work_context.sources[]` + (its `note` / instruction text, `required_capabilities`, and the full + task body when the compact display text was truncated), plus + `turn_envelope.action.selected_todo.text` / `action.recommended_action`; + - a task opts in by declaring an explicit `retirement_basis` / + `anti_entropy_basis` JSON object in that text, by listing + `anti-entropy-governance` in the Todo's `required_capabilities`, or by a + Goal-level available-capability declaration; + - the post-change workspace is the Goal repository from the registry + (`load_goal_from_registry`), never the process working directory. + + Disabled turns write no readback slot and inject nothing; an + enabled-but-inapplicable turn only reads back `enabled/applicable=false`. Recovery turns (the payload carries the previous inspect outcome) get remediation guidance instead. -2. **In-flight check** — agents call the `anti_entropy_inspect` tool (or - `loopx anti-entropy inspect`). Blocking findings come back as - **actionable feedback with per-rule next actions**, classified as progress - facts, not final delivery failures; the CLI exits 0 whenever the check ran. +2. **In-flight check** — agents call `anti_entropy_inspect` through the Python + entry or `loopx anti-entropy inspect`. The turn context carries the tool + descriptor and CLI route as data; LoopX exposes no capability-owned host + tool registry, so the CLI remains the callable route. Blocking findings come + back as **actionable feedback with per-rule next actions**, classified as + progress facts, not final delivery failures; the CLI exits 0 whenever the + check ran. 3. **Delivery verification** — final acceptance stays owned by the existing change-quality owner (`loopx change-quality prepare/record/verify`, exact diff scope and receipt invalidation). This capability supplies evidence and @@ -105,12 +119,17 @@ In-flight inspection with actionable feedback (progress-fact semantics): loopx anti-entropy inspect --goal-id --diff-file --basis-json --workspace --format json ``` -Delivery-gate verification (non-zero exit on rejection): +Delivery-gate verification (non-zero exit on rejection). `verify` requires an +explicit basis with at least one target, and certifies symbol retirement only +when `--workspace` is bound: ```bash loopx anti-entropy verify --goal-id --diff-file --basis-json --workspace --format json ``` +`verify` without `--basis-json` (or with an empty target set) exits 2: an +undeclared retirement is an input error, not a passing verification. + Example `basis.json`: ```json @@ -131,7 +150,8 @@ no model runs and measures no agent-runtime behavior. ## Boundaries -- The capability is default-off and task-scoped: turns receive the retirement context only when the task explicitly declares a retirement basis or enables the capability; the CLI (verify/inspect) remains the shared verification entry. +- The capability is default-off and task-scoped: turns receive the retirement context only when the selected task explicitly declares a retirement basis or enables the capability in its own canonical input; the CLI (verify/inspect) remains the shared verification entry. +- The selected Todo is the intent source. The hook reads the native work context and the Goal registry; it never infers targets from unrelated business fields and never reads the process working directory as source evidence. - Delete-first requires target old logic to be cleanly decommissioned in the diff without re-introducing wrappers or zombie symbols. - Symbol retirement certification requires an explicitly bound workspace; diff-only evidence can reject but never certifies. - AST-based diff hygiene verifies exact code diffs without mutating repository code or performing speculative git writes. diff --git a/loopx/capabilities/anti_entropy/catalog_entry.py b/loopx/capabilities/anti_entropy/catalog_entry.py index 17c006ca30..4cf80d21c5 100644 --- a/loopx/capabilities/anti_entropy/catalog_entry.py +++ b/loopx/capabilities/anti_entropy/catalog_entry.py @@ -31,7 +31,8 @@ "re-introducing wrappers, zombie symbols, or ghost code." ), "entry_command": ( - "loopx anti-entropy inspect --goal-id --diff-file --workspace --format json" + "loopx anti-entropy inspect --goal-id --diff-file " + "--basis-json --workspace --format json" ), "workflow_skill": { "name": "loopx-anti-entropy", @@ -90,7 +91,9 @@ "docs": [_README], "boundaries": [ "The capability is default-off and task-scoped: a turn receives the retirement context only when the task explicitly declares a retirement basis or enables the capability; disabled turns get zero injection.", - "The turn-context hook projects declared targets, the inspect tool, and delete-first guidance into host input via the shared attach_turn_context composition; it grants no execution authority.", + "The turn-context hook projects declared targets, the inspect descriptor, and delete-first guidance into host input via the shared attach_turn_context composition; it grants no execution authority.", + "Task intent comes from the selected Todo's own instruction/note text or an explicit capability id in its required capabilities; the post-change workspace is the Goal repository, never the process working directory.", + "LoopX has no capability-owned host tool registry: the inspect descriptor is projected as turn-context data, and the CLI (or the Python entry it wraps) remains the callable route.", "The CLI (verify/inspect) remains the shared verification entry for humans and agents; final delivery acceptance is owned by the existing change-quality prepare/record/verify owner.", "Delete-first requires target old logic to be cleanly decommissioned in the diff without re-introducing wrappers or zombie symbols.", "AST-based diff hygiene verifies exact code diffs without mutating repository code or performing speculative git writes.", @@ -100,6 +103,7 @@ "Inspired by and adopting core engineering principles of delete-first code retirement from Aegis.", ], "next_real_step": ( - "Run verification: loopx anti-entropy verify --diff-file --basis-json " + "Run verification: loopx anti-entropy verify --diff-file " + "--basis-json --workspace --format json" ), } diff --git a/loopx/capabilities/anti_entropy/oracles.py b/loopx/capabilities/anti_entropy/oracles.py index b50b47354c..4a644170ce 100644 --- a/loopx/capabilities/anti_entropy/oracles.py +++ b/loopx/capabilities/anti_entropy/oracles.py @@ -306,6 +306,58 @@ def _extract_ts_defs(lines: list[str]) -> set[str]: return defs +_TS_IDENTIFIER_RE = re.compile(r"[A-Za-z_$][A-Za-z0-9_$]*\Z") +_TS_QUOTE_CHARS = ("\"", "'", chr(96)) + + +def _static_computed_member( + text: str, start: int, out: list[str] +) -> tuple[str, int] | None: + """Preserve a statically named computed member access (api[name]). + + Only a member name in a value position is preserved. Array literals, + declarations, and dynamic expressions stay string-literal text and never + become executable references; unparseable forms keep the normal string + handling so they cannot be certified as absent evidence. + """ + index = start + 1 + length = len(text) + while index < length and text[index] in " \t": + index += 1 + if index >= length or text[index] not in _TS_QUOTE_CHARS: + return None + quote = text[index] + cursor = index + 1 + content_start = cursor + while cursor < length: + char = text[cursor] + if char == "\\" or char == "\n": + return None + if char == quote: + break + cursor += 1 + if cursor >= length: + return None + name = text[content_start:cursor] + if not _TS_IDENTIFIER_RE.match(name): + return None + after = cursor + 1 + while after < length and text[after] in " \t": + after += 1 + if after >= length or text[after] != "]": + return None + previous = "" + for position in range(len(out) - 1, -1, -1): + token = out[position] + stripped = token.rstrip() + if stripped: + previous = stripped[-1] + break + if not (previous.isalnum() or previous in "_$)]."): + return None + return name, after + 1 + + def _tokenize_ts_code(text: str) -> tuple[str, bool]: """Tokenize and strip comments and string-literal spans from TS/JS code. @@ -323,6 +375,14 @@ def _tokenize_ts_code(text: str) -> tuple[str, bool]: stack: list[Any] = [] # items: 'template' or {'type': 'expr', 'brace_depth': int} while i < n: + if text[i] == "[" and (not stack or stack[-1] != "template"): + computed = _static_computed_member(text, i, out) + if computed is not None: + name, next_index = computed + out.append(" ") + out.append(name) + i = next_index + continue if not stack: # Top-level code if text[i : i + 2] == "//": diff --git a/loopx/capabilities/anti_entropy/turn_context.py b/loopx/capabilities/anti_entropy/turn_context.py index a5ce30ba08..c612d4dc88 100644 --- a/loopx/capabilities/anti_entropy/turn_context.py +++ b/loopx/capabilities/anti_entropy/turn_context.py @@ -8,204 +8,397 @@ from typing import Any from ...control_plane.capability_hooks import TurnContextHookRegistration -from .contract import RetirementBasis +from .contract import ANTI_ENTROPY_CAPABILITY_ID, RetirementBasis from .feedback import build_anti_entropy_turn_context ANTI_ENTROPY_CONTEXT_MAX_RESULT_BYTES = 64 * 1024 +_BASIS_KEYS = ("retirement_basis", "anti_entropy_basis") +_BASIS_CONTAINER_KEYS = ("metadata", "params", "task_spec", "task_parameters") +_TEXT_CARRIER_KEYS = ( + "note", + "text", + "description", + "instruction", + "evidence", + "rationale", +) +_ENABLE_CAPABILITY_IDS = frozenset( + {"anti-entropy-governance", "anti_entropy_governance", "anti_entropy"} +) +_UNSET = object() + + +def _embedded_json_objects(text: str): + """Yield JSON objects embedded in free text, outermost first. + + Task instructions and notes are plain strings; an explicit retirement + declaration may sit inside surrounding prose. A balanced scan is used + instead of assuming the whole string is JSON, and a failed outer parse + keeps scanning for an inner declaration. + """ + index = 0 + length = len(text) + while index < length: + if text[index] != "{": + index += 1 + continue + depth = 0 + in_string = False + escaped = False + cursor = index + parsed: Any = None + while cursor < length: + char = text[cursor] + if in_string: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + in_string = False + elif char == '"': + in_string = True + elif char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + try: + parsed = json.loads(text[index : cursor + 1]) + except Exception: + parsed = None + break + cursor += 1 + if isinstance(parsed, Mapping): + yield parsed + index = cursor + 1 + else: + index += 1 def _extract_basis_candidate(candidate: Any) -> RetirementBasis | None: - """Recursively search for a valid RetirementBasis in a candidate object or dictionary.""" + """Recursively search a task carrier for an explicit retirement basis.""" if candidate is None: return None if isinstance(candidate, RetirementBasis): - return candidate if (candidate.target_symbols or candidate.target_paths) else None + return ( + candidate + if (candidate.target_symbols or candidate.target_paths) + else None + ) if isinstance(candidate, str): - candidate_clean = candidate.strip() - if (candidate_clean.startswith("{") and candidate_clean.endswith("}")) or ( - candidate_clean.startswith("[") and candidate_clean.endswith("]") - ): - try: - parsed = json.loads(candidate_clean) - return _extract_basis_candidate(parsed) - except Exception: - pass + for embedded in _embedded_json_objects(candidate): + parsed = _extract_basis_candidate(embedded) + if parsed is not None: + return parsed return None - if isinstance(candidate, Mapping): - # 1. Direct explicit retirement_basis or anti_entropy_basis keys - for key in ("retirement_basis", "anti_entropy_basis"): - if key in candidate: - b = _extract_basis_candidate(candidate[key]) - if b is not None: - return b - - # 2. anti_entropy block - if "anti_entropy" in candidate and isinstance(candidate["anti_entropy"], Mapping): - b = _extract_basis_candidate(candidate["anti_entropy"]) - if b is not None: - return b - - # 3. Containers commonly holding task params or metadata - for container_key in ("metadata", "params", "task_spec", "task_parameters"): - if container_key in candidate: - b = _extract_basis_candidate(candidate[container_key]) - if b is not None: - return b - - # 4. Direct basis shape (target_symbols or target_paths) - if "target_symbols" in candidate or "target_paths" in candidate: - try: - b = RetirementBasis.from_dict(candidate) - if b.target_symbols or b.target_paths: - return b - except Exception: - pass - - # 5. String fields that might store JSON payload - for text_key in ("note", "description", "instruction"): - text_val = candidate.get(text_key) - if isinstance(text_val, str): - b = _extract_basis_candidate(text_val) - if b is not None: - return b - + if not isinstance(candidate, Mapping): + return None + for key in _BASIS_KEYS: + if key in candidate: + parsed = _extract_basis_candidate(candidate[key]) + if parsed is not None: + return parsed + anti_entropy = candidate.get("anti_entropy") + if isinstance(anti_entropy, Mapping): + parsed = _extract_basis_candidate(anti_entropy) + if parsed is not None: + return parsed + for container_key in _BASIS_CONTAINER_KEYS: + if container_key in candidate: + parsed = _extract_basis_candidate(candidate[container_key]) + if parsed is not None: + return parsed + # A dedicated basis object (not arbitrary task metadata) carries the + # declared targets directly. + if "target_symbols" in candidate or "target_paths" in candidate: + try: + parsed = RetirementBasis.from_dict(candidate) + except Exception: + parsed = None + if parsed is not None and (parsed.target_symbols or parsed.target_paths): + return parsed + for text_key in _TEXT_CARRIER_KEYS: + text_value = candidate.get(text_key) + if isinstance(text_value, str): + parsed = _extract_basis_candidate(text_value) + if parsed is not None: + return parsed return None +def _work_context_sources(container: Any) -> list[Mapping[str, Any]]: + """Return the bounded work-context sources from a payload or plan owner.""" + if not isinstance(container, Mapping): + return [] + contexts: list[Mapping[str, Any]] = [] + direct = container.get("work_context") + if isinstance(direct, Mapping): + contexts.append(direct) + channel = container.get("agent_channel") + if isinstance(channel, Mapping): + nested = channel.get("work_context") + if isinstance(nested, Mapping): + contexts.append(nested) + sources: list[Mapping[str, Any]] = [] + for context in contexts: + for source in context.get("sources") or (): + if isinstance(source, Mapping): + sources.append(source) + return sources + + +def _selected_todo_records( + payload: Mapping[str, Any], + decision: Mapping[str, Any], +) -> list[Mapping[str, Any]]: + """Collect every native selected-Todo projection, richest form first.""" + records: list[Mapping[str, Any]] = [] + + def add(value: Any) -> None: + if isinstance(value, Mapping): + records.append(value) + + envelope = payload.get("turn_envelope") + if isinstance(envelope, Mapping): + action = envelope.get("action") + if isinstance(action, Mapping): + add(action.get("selected_todo")) + add(envelope.get("selected_todo")) + for source in _work_context_sources(envelope): + content = source.get("content") + if isinstance(content, Mapping): + add(content.get("todo")) + route = payload.get("route") + if isinstance(route, Mapping): + add(route.get("selected_todo")) + add(payload.get("selected_todo")) + add(payload.get("task")) + if isinstance(decision, Mapping): + add(decision.get("selected_todo")) + action = decision.get("action") + if isinstance(action, Mapping): + add(action.get("selected_todo")) + route = decision.get("route") + if isinstance(route, Mapping): + add(route.get("selected_todo")) + for container in (decision, decision.get("interaction_contract")): + for source in _work_context_sources(container): + content = source.get("content") + if isinstance(content, Mapping): + add(content.get("todo")) + return records + + def _resolve_retirement_basis( payload: Mapping[str, Any], decision: Mapping[str, Any], ) -> RetirementBasis | None: - """Extract a declared retirement basis from turn payload, decision, or selected todo.""" - candidates = [ - # Top-level direct fields + """Extract a declared retirement basis from the authoritative task input. + + The native Turn carries the selected Todo as a compact projection plus the + bounded work context. The task's own instruction/note text and the + recommended action are the intent sources; a hand-built top-level payload + key is accepted for direct callers but is never the only source. + """ + candidates: list[Any] = [ payload.get("retirement_basis"), payload.get("anti_entropy_basis"), payload.get("anti_entropy"), decision.get("retirement_basis"), decision.get("anti_entropy_basis"), decision.get("anti_entropy"), - - # Native route and turn_envelope selected todos payload.get("route", {}).get("selected_todo") - if isinstance(payload.get("route"), Mapping) else None, + if isinstance(payload.get("route"), Mapping) + else None, payload.get("turn_envelope", {}).get("action", {}).get("selected_todo") if isinstance(payload.get("turn_envelope"), Mapping) - and isinstance(payload.get("turn_envelope", {}).get("action"), Mapping) else None, - payload.get("turn_envelope", {}).get("selected_todo") - if isinstance(payload.get("turn_envelope"), Mapping) else None, - - # Top-level and task fields + and isinstance(payload.get("turn_envelope", {}).get("action"), Mapping) + else None, payload.get("selected_todo"), payload.get("task"), - - # Decision-side selected todos decision.get("selected_todo"), decision.get("action", {}).get("selected_todo") - if isinstance(decision.get("action"), Mapping) else None, + if isinstance(decision.get("action"), Mapping) + else None, decision.get("route", {}).get("selected_todo") - if isinstance(decision.get("route"), Mapping) else None, + if isinstance(decision.get("route"), Mapping) + else None, decision.get("task"), ] - - for cand in candidates: - basis = _extract_basis_candidate(cand) + for candidate in candidates: + basis = _extract_basis_candidate(candidate) + if basis is not None: + return basis + envelope = payload.get("turn_envelope") + if isinstance(envelope, Mapping): + action = envelope.get("action") + if isinstance(action, Mapping): + for text_key in ("recommended_action", "text"): + basis = _extract_basis_candidate(action.get(text_key)) + if basis is not None: + return basis + for record in _selected_todo_records(payload, decision): + basis = _extract_basis_candidate(record) if basis is not None: return basis - return None +def _load_goal(registry_path: Path | str | None, goal_id: str | None) -> Mapping[str, Any] | None: + if registry_path is None or not goal_id: + return None + try: + from ...agent_registry import load_goal_from_registry + + return load_goal_from_registry(Path(registry_path), goal_id) + except Exception: # noqa: BLE001 - optional read-only enablement hint + return None + + +def _declared_capability_ids(goal: Mapping[str, Any]) -> set[str]: + declared: set[str] = set() + for container in ( + goal, + goal.get("control_plane"), + goal.get("coordination"), + ): + if not isinstance(container, Mapping): + continue + for key in ("available_capabilities", "required_capabilities"): + value = container.get(key) + if isinstance(value, (list, tuple, set)): + declared.update(str(item).strip() for item in value if isinstance(item, str)) + return declared + + +def _record_required_capabilities(record: Mapping[str, Any]) -> set[str]: + value = record.get("required_capabilities") + if isinstance(value, (list, tuple, set)): + return {str(item).strip() for item in value if isinstance(item, str)} + return set() + + def _is_anti_entropy_enabled( payload: Mapping[str, Any], decision: Mapping[str, Any], basis: RetirementBasis | None, + *, + registry_path: Path | str | None = None, + goal_id: str | None = None, ) -> bool: - """Determine whether anti-entropy capability is enabled for this turn.""" - # If an explicit retirement basis was declared, capability is implicitly enabled for that task + """Determine whether anti-entropy is enabled for this turn. + + A declared retirement basis is the task-scoped opt-in. The explicit + capability id in the selected Todo's required capabilities and a Goal-level + available-capability declaration are the typed enablement paths. + """ if basis is not None and (basis.target_symbols or basis.target_paths): return True - # Check direct payload and decision enablement if payload.get("anti_entropy_enabled") is True or decision.get("anti_entropy_enabled") is True: return True - - ae_payload = payload.get("anti_entropy") - if isinstance(ae_payload, Mapping) and ae_payload.get("enabled") is True: - return True - - ae_decision = decision.get("anti_entropy") - if isinstance(ae_decision, Mapping) and ae_decision.get("enabled") is True: - return True - - # Check route and turn_envelope boundary capabilities + for owner in (payload, decision): + value = owner.get("anti_entropy") + if isinstance(value, Mapping) and value.get("enabled") is True: + return True route = payload.get("route") if isinstance(route, Mapping) and route.get("anti_entropy_enabled") is True: return True - envelope = payload.get("turn_envelope") if isinstance(envelope, Mapping): boundary = envelope.get("boundary") if isinstance(boundary, Mapping): - caps = boundary.get("capabilities") - if isinstance(caps, Mapping): - ae_cap = caps.get("anti_entropy_governance") or caps.get("anti_entropy") - if isinstance(ae_cap, Mapping) and ae_cap.get("enabled") is True: + capabilities = boundary.get("capabilities") + if isinstance(capabilities, Mapping): + capability = capabilities.get("anti_entropy_governance") or capabilities.get( + "anti_entropy" + ) + if isinstance(capability, Mapping) and capability.get("enabled") is True: return True - if ae_cap is True: + if capability is True: return True - # Check selected todos for anti_entropy_enabled flag - todos_to_check = [ - payload.get("selected_todo"), - payload.get("route", {}).get("selected_todo") if isinstance(payload.get("route"), Mapping) else None, - payload.get("turn_envelope", {}).get("action", {}).get("selected_todo") - if isinstance(payload.get("turn_envelope"), Mapping) - and isinstance(payload.get("turn_envelope", {}).get("action"), Mapping) else None, - decision.get("selected_todo"), - decision.get("action", {}).get("selected_todo") if isinstance(decision.get("action"), Mapping) else None, - ] - for td in todos_to_check: - if isinstance(td, Mapping): - if td.get("anti_entropy_enabled") is True: - return True - meta = td.get("metadata") - if isinstance(meta, Mapping) and meta.get("anti_entropy_enabled") is True: - return True - params = td.get("params") - if isinstance(params, Mapping) and params.get("anti_entropy_enabled") is True: + for record in _selected_todo_records(payload, decision): + if record.get("anti_entropy_enabled") is True: + return True + for key in ("metadata", "params"): + value = record.get(key) + if isinstance(value, Mapping) and value.get("anti_entropy_enabled") is True: return True + if _record_required_capabilities(record) & _ENABLE_CAPABILITY_IDS: + return True + goal = _load_goal(registry_path, goal_id) + if goal is not None and _declared_capability_ids(goal) & _ENABLE_CAPABILITY_IDS: + return True return False +def _resolve_workspace( + payload: Mapping[str, Any], + *, + registry_path: Path | str | None, + goal_id: str | None, +) -> Path | None: + """Resolve the post-change workspace from the task or the Goal registry. + + The process working directory is never used: an unrelated checkout must not + change the verdict for the same patch. + """ + for key in ("workspace", "repo_path"): + value = payload.get(key) + if isinstance(value, str) and value.strip(): + path = Path(value).expanduser() + if path.is_dir(): + return path.resolve() + goal = _load_goal(registry_path, goal_id) + repo = str((goal or {}).get("repo") or "").strip() + if repo: + path = Path(repo).expanduser() + if path.is_dir(): + return path.resolve() + return None + + def anti_entropy_context_result( payload: Mapping[str, Any], *, goal_id: str, decision: Mapping[str, Any], workspace: Path | None = None, + resolved_basis: Any = _UNSET, + enabled: Any = _UNSET, ) -> dict[str, Any]: """Produce the bounded turn-context payload covering the four lifecycle states. - 1. Disabled: capability is inactive. - 2. Enabled but not applicable: capability is active but task has no retirement targets. - 3. New task: declared targets projected into context + inspect tool + delete-first guidance. - 4. Failure recovery: previous diff had blocking findings; progress facts guide remediation. + 1. Disabled: no readback slot and no injection. + 2. Enabled but not applicable: capability is active but the task has no + retirement targets. + 3. New task: declared targets, the inspect descriptor, and delete-first + guidance are projected into host input. + 4. Failure recovery: the previous inspect outcome in the payload produces + remediation guidance. """ - basis = _resolve_retirement_basis(payload, decision) - enabled = _is_anti_entropy_enabled(payload, decision, basis) + basis = ( + _resolve_retirement_basis(payload, decision) + if resolved_basis is _UNSET + else resolved_basis + ) + enabled_value = bool( + _is_anti_entropy_enabled(payload, decision, basis) + if enabled is _UNSET + else enabled + ) - # State 1: Disabled - if not enabled: + if not enabled_value: return { - "readback": {"anti_entropy_guidance": None}, + "readback": {}, "context_fields": {}, "instructions": [], "result_fields": {}, } - # State 2: Enabled but not applicable if basis is None or (not basis.target_symbols and not basis.target_paths): return { "readback": { @@ -219,7 +412,6 @@ def anti_entropy_context_result( "result_fields": {}, } - # Determine whether this is a failure recovery turn recovery_evidence = ( payload.get("anti_entropy_progress_fact") or payload.get("anti_entropy_last_result") @@ -227,24 +419,26 @@ def anti_entropy_context_result( ) is_recovery = bool(recovery_evidence) - # State 3 / 4: Active task or failure recovery - resolved_workspace = workspace or Path.cwd() context = build_anti_entropy_turn_context( basis=basis, goal_id=goal_id, - workspace=resolved_workspace, + workspace=workspace, ) if is_recovery: instructions = [ - "Anti-entropy recovery: previous diff check identified unretired symbols or diff hygiene issues. Address blocking findings before attempting delivery.", - "Run 'anti_entropy_inspect' or 'loopx anti-entropy inspect' to verify diff cleanliness; rejections are progress facts guiding the next correction.", + "Anti-entropy recovery: the previous diff check identified unretired symbols " + "or diff hygiene issues. Address blocking findings before attempting delivery.", + "Run 'anti_entropy_inspect' or 'loopx anti-entropy inspect' to verify diff " + "cleanliness; rejections are progress facts guiding the next correction.", ] status = "recovering" else: instructions = [ - "Anti-entropy code governance is active for this task: delete-first symbol and path retirement is required.", - "Call 'anti_entropy_inspect' or 'loopx anti-entropy inspect' to check diff cleanliness; blocking findings are progress facts to fix before delivery.", + "Anti-entropy code governance is active for this task: delete-first symbol " + "and path retirement is required.", + "Call 'anti_entropy_inspect' or 'loopx anti-entropy inspect' to check diff " + "cleanliness; blocking findings are progress facts to fix before delivery.", ] status = "active" @@ -255,6 +449,7 @@ def anti_entropy_context_result( "target_symbols": list(basis.target_symbols), "target_paths": list(basis.target_paths), "status": status, + "workspace_available": workspace is not None, } } @@ -265,9 +460,10 @@ def anti_entropy_context_result( "result_fields": {}, } - encoded = json.dumps(full_result, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + encoded = json.dumps(full_result, ensure_ascii=False, separators=(",", ":")).encode( + "utf-8" + ) if len(encoded) > ANTI_ENTROPY_CONTEXT_MAX_RESULT_BYTES: - # Bounded compaction: trim rationale or inspect tool descriptors if over budget compact_context = dict(context) compact_context.pop("inspect_tool", None) return { @@ -289,11 +485,22 @@ def anti_entropy_context_registration( decision: Mapping[str, Any], turn_instance_id: str, ) -> TurnContextHookRegistration: - """Create a TurnContextHookRegistration connecting anti-entropy into host input.""" - workspace = ( - Path(payload["workspace"]).resolve() - if "workspace" in payload - else (Path(payload["repo_path"]).resolve() if "repo_path" in payload else None) + """Create a TurnContextHookRegistration connecting anti-entropy into host input. + + The task intent and workspace are resolved once from the native Turn input + and the Goal registry. Disabled turns declare no readback slot, so no empty + guidance field is written into the host payload. + """ + workspace = _resolve_workspace( + payload, registry_path=registry_path, goal_id=goal_id + ) + basis = _resolve_retirement_basis(payload, decision) + enabled = _is_anti_entropy_enabled( + payload, + decision, + basis, + registry_path=registry_path, + goal_id=goal_id, ) def produce() -> Mapping[str, Any]: @@ -302,12 +509,14 @@ def produce() -> Mapping[str, Any]: goal_id=goal_id, decision=decision, workspace=workspace, + resolved_basis=basis, + enabled=enabled, ) return TurnContextHookRegistration( hook_id="anti-entropy.turn-context", - capability_id="anti-entropy-governance", - readback_slots=("anti_entropy_guidance",), + capability_id=ANTI_ENTROPY_CAPABILITY_ID, + readback_slots=("anti_entropy_guidance",) if enabled else (), producer=produce, max_result_bytes=ANTI_ENTROPY_CONTEXT_MAX_RESULT_BYTES, ) diff --git a/loopx/cli_commands/anti_entropy.py b/loopx/cli_commands/anti_entropy.py index 508c785a2a..a78587b0e7 100644 --- a/loopx/cli_commands/anti_entropy.py +++ b/loopx/cli_commands/anti_entropy.py @@ -8,7 +8,10 @@ from typing import Any from ..capabilities.anti_entropy.cli import run_anti_entropy_verify -from ..capabilities.anti_entropy.contract import ANTI_ENTROPY_CAPABILITY_ID +from ..capabilities.anti_entropy.contract import ( + ANTI_ENTROPY_CAPABILITY_ID, + RetirementBasis, +) PrintPayload = Callable[ [dict[str, Any], str, Callable[[dict[str, Any]], str]], str | None @@ -176,6 +179,20 @@ def handle_anti_entropy_command( workspace = _resolve_workspace(args) if command == "verify": + # A delivery-gate verification without a declared retirement basis + # would pass without checking anything; diff hygiene alone belongs + # to inspect and can never certify a retirement. + if basis_data is None: + raise ValueError( + "verify requires an explicit retirement basis " + "(--basis-json )" + ) + declared_basis = RetirementBasis.from_dict(basis_data) + if not declared_basis.target_symbols and not declared_basis.target_paths: + raise ValueError( + "verify requires at least one declared retirement target " + "(target_symbols or target_paths)" + ) payload = run_anti_entropy_verify( diff_text=diff_text, basis_data=basis_data, diff --git a/tests/capabilities/test_anti_entropy.py b/tests/capabilities/test_anti_entropy.py index b387690a3e..bcba474876 100644 --- a/tests/capabilities/test_anti_entropy.py +++ b/tests/capabilities/test_anti_entropy.py @@ -405,6 +405,45 @@ def old_api(): ) assert proc_ws.returncode == 2 + # 5. verify without any basis -> input error, never a silent pass + proc_no_basis = subprocess.run( + [ + sys.executable, + "-m", + "loopx.cli", + "anti-entropy", + "verify", + "--diff-file", + "-", + "--format", + "json", + ], + input=b"", + capture_output=True, + ) + assert proc_no_basis.returncode == 2 + assert b"retirement basis" in proc_no_basis.stderr + + # 6. verify with a basis that declares no retirement target -> input error + proc_no_target = subprocess.run( + [ + sys.executable, + "-m", + "loopx.cli", + "anti-entropy", + "verify", + "--diff-file", + "-", + "--basis-json", + "{}", + "--format", + "json", + ], + input=b"", + capture_output=True, + ) + assert proc_no_target.returncode == 2 + def test_docstring_example_deletion_does_not_retire_real_definition(tmp_path: Path) -> None: """Verify deleting a code example in a docstring does not falsely pass a retained real definition.""" @@ -1133,3 +1172,73 @@ def test_typescript_unclosed_syntax_reports_insufficient_evidence(tmp_path: Path assert receipt.verdict == "rejected" assert any(f.rule_id.startswith("AE004") for f in receipt.findings) assert "old_api" not in receipt.retired_symbols + +def test_typescript_static_computed_member_reference_blocks_retirement( + tmp_path: Path, +) -> None: + """A statically named computed member access is an active reference.""" + diff = """ +--- a/service.mjs ++++ b/service.mjs +@@ -1,2 +1,2 @@ +-export function old_api() { return 1; } ++export function new_api() { return 2; } +""" + basis = RetirementBasis(target_symbols=("old_api",)) + workspace = _make_workspace( + tmp_path / "ws_unmigrated", + { + "service.mjs": "export function new_api() { return 2; }\n", + "client.mjs": ( + "import * as api from './service.mjs';\n" + "const first = api['old_api']();\n" + 'const second = api?.["old_api"]();\n' + ), + }, + ) + + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) + assert receipt.verdict == "rejected" + assert any( + finding.rule_id == "AE002-ZOMBIE_SYMBOL_REINTRODUCED" + and "client.mjs" in finding.message + for finding in receipt.findings + ) + assert receipt.retired_symbols == () + + +def test_typescript_computed_member_migrated_and_harmless_strings_pass( + tmp_path: Path, +) -> None: + """Migrated computed calls and plain string/array text never block retirement.""" + diff = """ +--- a/service.mjs ++++ b/service.mjs +@@ -1,2 +1,2 @@ +-export function old_api() { return 1; } ++export function new_api() { return 2; } +--- a/client.mjs ++++ b/client.mjs +@@ -2,1 +2,3 @@ +-export function run() { return api.old_api(); } ++export function run() { return api['new_api'](); } ++export const allowed = ['old_api']; ++export const message = "old_api has been removed"; +""" + basis = RetirementBasis(target_symbols=("old_api",)) + workspace = _make_workspace( + tmp_path / "ws_migrated", + { + "service.mjs": "export function new_api() { return 2; }\n", + "client.mjs": ( + "import * as api from './service.mjs';\n" + "export function run() { return api['new_api'](); }\n" + "export const allowed = ['old_api'];\n" + 'export const message = "old_api has been removed";\n' + ), + }, + ) + + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) + assert receipt.verdict == "passed", f"findings: {receipt.findings}" + assert receipt.retired_symbols == ("old_api",) diff --git a/tests/capabilities/test_anti_entropy_feedback.py b/tests/capabilities/test_anti_entropy_feedback.py index 9375c1717c..6d50485a2f 100644 --- a/tests/capabilities/test_anti_entropy_feedback.py +++ b/tests/capabilities/test_anti_entropy_feedback.py @@ -386,7 +386,8 @@ def test_turn_context_hook_lifecycle_all_states(tmp_path: Path) -> None: res_disabled = anti_entropy_context_result( payload_disabled, goal_id="g-1", decision=decision_disabled ) - assert res_disabled["readback"]["anti_entropy_guidance"] is None + # Disabled turns declare no readback slot: the host payload is untouched. + assert res_disabled["readback"] == {} assert res_disabled["context_fields"] == {} assert res_disabled["instructions"] == [] @@ -646,3 +647,254 @@ def test_turn_context_extracts_native_route_and_envelope_selected_todos() -> Non res_note = anti_entropy_context_result(payload_note, goal_id="g1", decision={}) assert res_note["readback"]["anti_entropy_guidance"]["status"] == "active" assert res_note["readback"]["anti_entropy_guidance"]["target_symbols"] == ["old_note_func"] + +def test_turn_context_reads_native_work_context_todo_note() -> None: + """The hook reads the selected Todo's canonical note from the native work context.""" + basis = { + "retirement_basis": { + "target_symbols": ["old_api"], + "rationale": "replace old_api with new_api", + } + } + payload = { + "goal_id": "g1", + "route": {"selected_todo": {"todo_id": "t1"}}, + "turn_envelope": { + "action": { + "recommended_action": "Replace old_api with new_api", + "selected_todo": { + "todo_id": "t1", + "text_ref": "action.recommended_action", + }, + }, + "work_context": { + "complete": True, + "sources": [ + { + "source": "selected_todo", + "content": { + "todo": { + "todo_id": "t1", + "note": json.dumps(basis), + "required_capabilities": ["anti-entropy-governance"], + } + }, + } + ], + }, + }, + } + res = anti_entropy_context_result(payload, goal_id="g1", decision={}) + guidance = res["readback"]["anti_entropy_guidance"] + assert guidance["enabled"] is True + assert guidance["applicable"] is True + assert guidance["target_symbols"] == ["old_api"] + assert "anti_entropy_context" in res["context_fields"] + assert res["context_fields"]["anti_entropy_context"]["inspect_tool"]["name"] == ( + "anti_entropy_inspect" + ) + assert any("delete-first" in item.lower() for item in res["instructions"]) + + +def test_turn_context_reads_basis_from_action_recommended_action() -> None: + """A retirement declaration inside the task body is read from the native action.""" + instruction = ( + "Replace the legacy API and remove the old export.\n" + '{"retirement_basis": {"target_symbols": ["old_api"], "target_paths": []}}' + ) + payload = { + "turn_envelope": { + "action": { + "recommended_action": instruction, + "selected_todo": { + "todo_id": "t1", + "text_ref": "action.recommended_action", + }, + } + } + } + res = anti_entropy_context_result(payload, goal_id="g1", decision={}) + guidance = res["readback"]["anti_entropy_guidance"] + assert guidance["applicable"] is True + assert guidance["target_symbols"] == ["old_api"] + + +def test_turn_context_ignores_unrelated_json_in_task_text() -> None: + """Unrelated JSON in a task body does not opt a turn into retirement governance.""" + payload = { + "turn_envelope": { + "action": { + "recommended_action": 'Set {"timeout": 30} for the nightly run.', + "selected_todo": {"todo_id": "t1", "text_ref": "action.recommended_action"}, + } + } + } + res = anti_entropy_context_result(payload, goal_id="g1", decision={}) + assert res["readback"] == {} + assert res["context_fields"] == {} + + +def test_disabled_turn_writes_no_readback_slot(tmp_path: Path) -> None: + """A disabled turn declares no slot and leaves the host payload untouched.""" + from loopx.capabilities.anti_entropy.turn_context import ( + anti_entropy_context_registration, + ) + from loopx.control_plane.capability_hooks import dispatch_turn_context_hooks + + payload = {"goal_id": "g1", "agent_id": "a1"} + registration = anti_entropy_context_registration( + payload, + registry_path=tmp_path, + goal_id="g1", + agent_id="a1", + decision={}, + turn_instance_id="t1", + ) + assert registration.readback_slots == () + dispatched = dict(payload) + dispatch_turn_context_hooks(dispatched, (registration,)) + assert "anti_entropy_guidance" not in dispatched + assert "turn_context_contributions" not in dispatched + + +def test_turn_context_resolves_workspace_from_goal_registry(tmp_path: Path) -> None: + """The post-change workspace is the Goal repository, never the process CWD.""" + from loopx.capabilities.anti_entropy.turn_context import ( + anti_entropy_context_registration, + ) + from loopx.control_plane.capability_hooks import ( + dispatch_turn_context_hooks, + project_turn_context, + ) + + repo = tmp_path / "repo" + repo.mkdir() + registry = tmp_path / "registry.json" + registry.write_text( + json.dumps({"goals": [{"id": "g1", "repo": str(repo)}]}), encoding="utf-8" + ) + payload = { + "goal_id": "g1", + "agent_id": "a1", + "retirement_basis": {"target_symbols": ["old_api"]}, + } + registration = anti_entropy_context_registration( + payload, + registry_path=registry, + goal_id="g1", + agent_id="a1", + decision={}, + turn_instance_id="t1", + ) + dispatched = dict(payload) + dispatch_turn_context_hooks(dispatched, (registration,)) + guidance = dispatched["anti_entropy_guidance"] + assert guidance["workspace_available"] is True + context = project_turn_context(dispatched)["anti_entropy_context"] + assert context["workspace"] == str(repo.resolve()) + + +def _native_turn_decision_fixture() -> dict: + """Load the canonical Turn decision fixture used by the envelope tests.""" + import importlib.util + + fixture_path = Path(__file__).resolve().parents[1] / "test_turn_envelope.py" + spec = importlib.util.spec_from_file_location( + "_anti_entropy_turn_envelope_fixture", fixture_path + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module._full_decision() + + +def _dispatch_native_turn_plan(source: dict, registry: Path) -> dict: + """Build the real TS-owned Turn envelope, then dispatch the shared composer.""" + from loopx.capabilities.anti_entropy.turn_context import ( + anti_entropy_context_registration, + ) + from loopx.control_plane.capability_hooks import dispatch_turn_context_hooks + from loopx.control_plane.quota.turn_envelope import build_turn_envelope + + envelope = build_turn_envelope(source) + plan = { + "turn_envelope": envelope, + "route": {"selected_todo": envelope["action"]["selected_todo"]}, + } + registration = anti_entropy_context_registration( + plan, + registry_path=registry, + goal_id="fixture-goal", + agent_id="codex-fixture", + decision=source, + turn_instance_id="t1", + ) + dispatch_turn_context_hooks(plan, (registration,)) + return plan + + +def test_native_turn_envelope_work_context_note_reaches_host_input( + tmp_path: Path, +) -> None: + """The real Turn envelope carries the Todo note into host input.""" + from loopx.control_plane.capability_hooks import project_turn_context + + source = _native_turn_decision_fixture() + source["interaction_contract"]["agent_channel"]["work_context"] = { + "complete": True, + "selected_todo_ref": "selected_todo", + "sources": [ + { + "source": "selected_todo", + "content": { + "todo": { + "todo_id": "todo_fixture0001", + "note": json.dumps( + {"retirement_basis": {"target_symbols": ["old_api"]}} + ), + "required_capabilities": ["anti-entropy-governance"], + } + }, + } + ], + "instruction": "Read current sources before work.", + } + + plan = _dispatch_native_turn_plan(source, tmp_path) + + assert "turn_context_hook_failures" not in plan + guidance = plan["anti_entropy_guidance"] + assert guidance["enabled"] is True + assert guidance["applicable"] is True + assert guidance["target_symbols"] == ["old_api"] + projected = project_turn_context(plan) + assert "anti_entropy_context" in projected["capability_context_fields"] + assert any( + "delete-first" in item.lower() + for item in projected["capability_instructions"] + ) + + +def test_native_turn_envelope_task_text_reaches_host_input(tmp_path: Path) -> None: + """The task body carried by the native action is an intent source too.""" + from loopx.control_plane.capability_hooks import project_turn_context + + source = _native_turn_decision_fixture() + source["selected_todo"]["text"] = ( + "Replace old_api with new_api. " + + json.dumps({"retirement_basis": {"target_symbols": ["old_api"]}}) + ) + source["interaction_contract"]["agent_channel"]["work_context"] = { + "complete": True, + "selected_todo_ref": "selected_todo", + "instruction": "Read current sources before work.", + } + + plan = _dispatch_native_turn_plan(source, tmp_path) + + # The envelope elides a redundant work context and keeps the body in action. + assert "work_context" not in plan["turn_envelope"] + guidance = plan["anti_entropy_guidance"] + assert guidance["applicable"] is True + assert guidance["target_symbols"] == ["old_api"] + projected = project_turn_context(plan) + assert "anti_entropy_context" in projected["capability_context_fields"] From 11fd4326558e1e763eaf29b960fe05d30e626065 Mon Sep 17 00:00:00 2001 From: LoopDesk Date: Fri, 30 Oct 2026 18:00:22 +0800 Subject: [PATCH 7/7] test(anti-entropy): prove real Turn host stdin carries governance guidance Add an end-to-end regression that mirrors the review method: a canonical File-authority Goal, a Todo authored through the real CLI (task text and note carry an explicit retirement basis), then a real turn run-once --execute whose codex Host is a synthetic binary that records argv/stdin and exits 9. The captured Host stdin contains the delete-first guidance, the anti_entropy_inspect route, and the Turn request with anti_entropy_context and its inspect_tool descriptor, while effects.host_invoked=true proves the Host was actually invoked. Document the routing caveat found by this run: a Todo's required_capabilities entry only opts in when the Goal already declares the capability available; otherwise routing stops the Turn before the hook, so the explicit task declaration is the primary opt-in. Signed-off-by: LoopDesk --- loopx/capabilities/anti_entropy/README.md | 8 +- .../test_anti_entropy_native_host.py | 149 ++++++++++++++++++ 2 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 tests/capabilities/test_anti_entropy_native_host.py diff --git a/loopx/capabilities/anti_entropy/README.md b/loopx/capabilities/anti_entropy/README.md index 45af4d533d..fd5b2b32af 100644 --- a/loopx/capabilities/anti_entropy/README.md +++ b/loopx/capabilities/anti_entropy/README.md @@ -70,9 +70,11 @@ task is an explicit choice. When enabled, one closed loop per task is: task body when the compact display text was truncated), plus `turn_envelope.action.selected_todo.text` / `action.recommended_action`; - a task opts in by declaring an explicit `retirement_basis` / - `anti_entropy_basis` JSON object in that text, by listing - `anti-entropy-governance` in the Todo's `required_capabilities`, or by a - Goal-level available-capability declaration; + `anti_entropy_basis` JSON object in that text. A Todo may also list + `anti-entropy-governance` in `required_capabilities`, but only when the + Goal already declares that capability available: otherwise routing treats + the Todo as requiring an unavailable capability and the Turn stops before + the hook, so the explicit task declaration is the primary path; - the post-change workspace is the Goal repository from the registry (`load_goal_from_registry`), never the process working directory. diff --git a/tests/capabilities/test_anti_entropy_native_host.py b/tests/capabilities/test_anti_entropy_native_host.py new file mode 100644 index 0000000000..3093d9ff01 --- /dev/null +++ b/tests/capabilities/test_anti_entropy_native_host.py @@ -0,0 +1,149 @@ +"""Real Turn -> Host stdin regression for anti-entropy task intent. + +This drives the same path the reviewer used: a canonical File-authority Goal, +a Todo authored through the real CLI, and a real `turn run-once --execute` +whose Host is a synthetic `codex` binary that records argv/stdin and exits 9. +It proves the guidance and inspect descriptor reach Host input, not merely the +in-process composer. +""" + +from __future__ import annotations + +import importlib.util +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _load_canonical_fixture(): + fixture_path = REPO_ROOT / "tests/control_plane/canonical_authority_fixture.py" + spec = importlib.util.spec_from_file_location( + "_anti_entropy_native_host_canonical_fixture", fixture_path + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _run_cli(registry: Path, runtime: Path, *args: str, timeout: int = 240): + return subprocess.run( + [ + sys.executable, + "-m", + "loopx.cli", + "--registry", + str(registry), + "--runtime-root", + str(runtime), + *args, + ], + cwd=REPO_ROOT, + capture_output=True, + text=True, + timeout=timeout, + ) + + +@pytest.mark.skipif( + sys.platform == "win32", + reason="the synthetic Host fixture is a POSIX shell script", +) +def test_real_turn_host_stdin_carries_anti_entropy_guidance(tmp_path: Path) -> None: + """A declared retirement task reaches the real Host input of turn run-once.""" + if shutil.which("node") is None: + pytest.skip("canonical File authority fixture requires node") + + fixture = _load_canonical_fixture() + goal_id = "anti-entropy-native-host" + registry, runtime, _state = fixture.promoted_create_fixture( + tmp_path, goal_id=goal_id, registered_agents=["codex-fixture"] + ) + # The fixture Goal predates contract fields the status gate needs. + registry_payload = json.loads(registry.read_text(encoding="utf-8")) + registry_payload["goals"][0].update( + { + "status": "active", + "domain": "software-development", + "adapter": {"kind": "generic_project_goal_v0", "status": "connected"}, + } + ) + registry.write_text(json.dumps(registry_payload), encoding="utf-8") + + basis = {"retirement_basis": {"target_symbols": ["old_api"]}} + todo_text = ( + "Replace old_api with new_api and delete the legacy export. " + + json.dumps(basis) + ) + added = _run_cli( + registry, + runtime, + "todo", + "add", + "--goal-id", + goal_id, + "--role", + "agent", + "--text", + todo_text, + "--note", + json.dumps(basis), + "--execute", + "--format", + "json", + ) + assert added.returncode == 0, added.stdout + added.stderr + assert json.loads(added.stdout)["status"] == "applied" + + capture = tmp_path / "host_capture.txt" + fake_codex = tmp_path / "codex" + fake_codex.write_text( + "#!/bin/sh\n" + "{\n" + " echo '=== ARGV ==='\n" + " printf '%s\\n' \"$@\"\n" + " echo '=== STDIN ==='\n" + " cat\n" + f'}} > "{capture}"\n' + "exit 9\n", + encoding="utf-8", + ) + fake_codex.chmod(0o755) + + turn = _run_cli( + registry, + runtime, + "turn", + "run-once", + "--goal-id", + goal_id, + "--agent-id", + "codex-fixture", + "--host", + "codex-cli", + "--project", + str(tmp_path / "project"), + "--codex-bin", + str(fake_codex), + "--timeout-seconds", + "30", + "--execute", + "--format", + "json", + ) + payload = json.loads(turn.stdout) + # The Host failed on purpose (exit 9) after its input was captured. + assert payload["effects"]["host_invoked"] is True + assert payload["result_kind"] == "host_failure" + assert capture.is_file(), turn.stdout + turn.stderr + + host_input = capture.read_text(encoding="utf-8", errors="replace") + assert "Anti-entropy code governance is active for this task" in host_input + assert "loopx anti-entropy inspect" in host_input + assert '"anti_entropy_context"' in host_input + assert '"inspect_tool"' in host_input