diff --git a/docs/decisions/anti-entropy-benchmark-decisions.md b/docs/decisions/anti-entropy-benchmark-decisions.md
new file mode 100644
index 0000000000..6ce25664ed
--- /dev/null
+++ b/docs/decisions/anti-entropy-benchmark-decisions.md
@@ -0,0 +1,88 @@
+# 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, 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.
+
+---
+
+### 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).
+ - 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.
+
+---
+
+### 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`.
+ - 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.
+
+---
+
+## 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. 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.
+ - `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-governance-smoke.py b/examples/anti-entropy-governance-smoke.py
new file mode 100755
index 0000000000..edfc4e149a
--- /dev/null
+++ b/examples/anti-entropy-governance-smoke.py
@@ -0,0 +1,138 @@
+#!/usr/bin/env python3
+"""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 # 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 # 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:
+ 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
+"""
+ 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, 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:
+ 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
+"""
+ 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, 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)
+
+
+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
+"""
+ 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
+ assert "OldService" in payload["retired_symbols"]
+
+
+def main() -> int:
+ test_clean_delete_first()
+ test_diff_only_evidence_cannot_certify()
+ 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/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..d8c20fa620
--- /dev/null
+++ b/examples/anti_entropy/compare_arms.py
@@ -0,0 +1,237 @@
+"""Isolated end-to-end demonstration of anti-entropy governance on a fixed refactor task.
+
+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]
+CASE_STUDY_REL = "examples/anti_entropy/case_study"
+
+sys.path.insert(0, str(REPO_ROOT))
+
+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,
+ )
+ 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,
+ )
+ 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 = 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.get("retirement_basis", {})
+
+ objective = spec.get("objective", "")
+ target_symbols = basis.get("target_symbols", [])
+ target_paths = basis.get("target_paths", [])
+
+ print("================================================================================")
+ print("Anti-Entropy Governance Isolated Sandbox Demonstration")
+ print("================================================================================")
+ 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")
+
+ 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("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
+
+
+if __name__ == "__main__":
+ sys.exit(main())
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
new file mode 100644
index 0000000000..1c63436202
--- /dev/null
+++ b/loopx/capabilities/anti_entropy/README.md
@@ -0,0 +1,185 @@
+# 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. 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 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`).
+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. **Deterministic Receipt Construction (`AntiEntropyReceipt`)**:
+ Runs deterministic hygiene oracles against the exact git diff and returns a
+ per-run receipt recording verified retired symbols, findings, and the
+ evidence provenance (`basis_hash`, `source_root`, `source_evidence`). The
+ capability does not persist a receipt store; delivery receipts of record are
+ owned by change-quality.
+
+## 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** — `anti_entropy_context_registration` is composed into
+ 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 only by declaring an explicit `retirement_basis` /
+ `anti_entropy_basis` JSON object in a task-intent carrier (`text`, `note`,
+ `description`, `instruction`, including inside `metadata` / `params`).
+ Generic `target_paths` / `target_symbols` keys outside that reserved
+ namespace are ordinary task metadata and never authorize retirement;
+ - a Todo may also list `anti-entropy-governance` in `required_capabilities`;
+ that is a task-scoped typed enable, not a Goal-wide switch. A Goal-level
+ `available_capabilities` declaration is availability only and does not
+ activate unrelated or later-added Todos in the same Goal;
+ - the turn-context hook itself is composed unconditionally in the shared
+ `attach_turn_context` owner and is called by both `turn run-once
+ --execute` and the heartbeat `quota should-run` path; it decides
+ off/applicable per task and injects nothing when the task has not opted in;
+ - 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 `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
+ 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
+
+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
+```
+
+In-flight inspection with actionable feedback (progress-fact semantics):
+
+```bash
+loopx anti-entropy inspect --goal-id --diff-file --basis-json --workspace --format json
+```
+
+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
+{
+ "target_symbols": ["old_deprecated_calculator"],
+ "target_paths": ["legacy/old_module.py"],
+ "allow_compat_seam": false,
+ "rationale": "Superseded by modern_calculator implementation"
+}
+```
+
+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 task-scoped: a turn receives the retirement context only when its own task input declares a `retirement_basis` / `anti_entropy_basis` namespace or a task-scoped typed enable. The turn-context hook stays composed on the real turn and heartbeat paths; 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.
+- 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.
+
+## Known Limitations
+
+- Static computed member keys are resolved as a bounded expression subset: string/template literals, legal whitespace/comments/line terminators between tokens, parentheses, constant string `+` concatenation, `String(...)`, `String.raw`, and simple TypeScript `as`/`satisfies`/non-null wrappers. Malformed static forms fail closed with AE004.
+- Dynamic computed member keys (`api[dynamicKey]`, a dynamic concatenation/template operand, `Reflect.get`, `eval`) are not analyzed. Target-scoped detection needs import/alias provenance and remains a named follow-up; the tool must not claim an absence it cannot prove.
+- Static destructuring property keys are analyzed as references: string/template/computed/unquoted keys in declarations, assignments, function/arrow parameters, for-of/in, catch, and nested patterns. A target key blocks retirement (AE002). Dynamic destructuring keys (`const { [key]: fn } = api`) and contexts the bounded classifier cannot place produce AE004 with an actionable next action; they are never treated as absence.
+- Clear object-literal property keys are non-references and pass; a target key in an unclassifiable object/type context produces blocking AE004 rather than a silent pass. Class/type-only property positions can therefore be conservative false rejects.
+- Reference extraction is token-kind conservative: identifier object-literal keys, regex-literal text, type-only positions, JSX text and same-named properties on unrelated receivers can be reported as references (false reject). This is the opposite direction from the retirement false pass and is tracked for a token-kind-aware parser.
+- Regex-literal detection for escape normalization is heuristic; escape-like text inside a regex literal is treated as pattern text, not a reference.
+- A canonical work-context Todo record without `todo_id` is assumed to be the selected task's own record (the canonical producer emits the goal-state record and the selected Todo record). Direct-caller payloads with multiple id-less records should provide `todo_id`.
+- A malformed explicit `retirement_basis` value is inert and fails closed; it cannot activate partial policy and may shadow a later valid declaration in the same carrier.
+- Python identifier identity is NFKC-normalized in the candidate gate and AST matching; fullwidth, ligature and combining-mark names are covered. Other non-ASCII normalization forms should use the same AST-backed identity path.
+- Escaped definition names and escapes outside value-position member/identifier references are outside the reference model.
+- The hand-rolled lexer is not a complete JavaScript/TypeScript parser; closing arbitrary expression syntax requires a real parser and remains a bounded follow-up.
diff --git a/loopx/capabilities/anti_entropy/__init__.py b/loopx/capabilities/anti_entropy/__init__.py
new file mode 100644
index 0000000000..65eca84019
--- /dev/null
+++ b/loopx/capabilities/anti_entropy/__init__.py
@@ -0,0 +1,47 @@
+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 (
+ ANTI_ENTROPY_CAPABILITY_ID,
+ ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION,
+ ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION,
+ AntiEntropyFinding,
+ 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,
+ verify_delete_first,
+)
+from .turn_context import (
+ anti_entropy_context_registration,
+ anti_entropy_context_result,
+)
+
+__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",
+ "AntiEntropyReceipt",
+ "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",
+ "anti_entropy_context_registration",
+ "anti_entropy_context_result",
+]
diff --git a/loopx/capabilities/anti_entropy/agent_tool.py b/loopx/capabilities/anti_entropy/agent_tool.py
new file mode 100644
index 0000000000..d9dc13ffc9
--- /dev/null
+++ b/loopx/capabilities/anti_entropy/agent_tool.py
@@ -0,0 +1,29 @@
+from __future__ import annotations
+
+from pathlib import Path
+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,
+ 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. 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
new file mode 100644
index 0000000000..7111c2f8c8
--- /dev/null
+++ b/loopx/capabilities/anti_entropy/catalog_entry.py
@@ -0,0 +1,112 @@
+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 inspect --goal-id --diff-file "
+ "--basis-json --workspace --format json"
+ ),
+ "workflow_skill": {
+ "name": "loopx-anti-entropy",
+ "delivery": "turn_context_hook_and_shared_cli",
+ "activation": "explicit_task_scoped_enablement",
+ "project_copy_required": False,
+ },
+ "commands": [
+ {
+ "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": (
+ "Descriptor metadata for the read-only inspection entry; the "
+ "descriptor is projected as turn-context data and the callable "
+ "route is `loopx anti-entropy inspect`. LoopX has no "
+ "capability-owned host tool registry, so this is not a "
+ "host-registered tool."
+ ),
+ "write_boundary": "read-only verification; no host state mutation",
+ },
+ ],
+ "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 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 is composed unconditionally into the shared attach_turn_context owner (turn run-once --execute and heartbeat quota should-run paths) and injects declared targets, the inspect descriptor, and delete-first guidance only for a task that opted in; it grants no execution authority.",
+ "Task intent is an explicit retirement_basis / anti_entropy_basis namespace in the selected task's own text/note/description/instruction carriers, or a task-scoped typed enable (Todo required_capabilities / direct flag); generic target_paths or target_symbols in metadata/params are not intent, and a Goal-level available-capabilities declaration is availability, not activation.",
+ "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.",
+ "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.",
+ ],
+ "next_real_step": (
+ "Run verification: loopx anti-entropy verify --diff-file "
+ "--basis-json --workspace --format json"
+ ),
+}
diff --git a/loopx/capabilities/anti_entropy/cli.py b/loopx/capabilities/anti_entropy/cli.py
new file mode 100644
index 0000000000..346b404102
--- /dev/null
+++ b/loopx/capabilities/anti_entropy/cli.py
@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+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,
+ workspace: str | Path | None = None,
+) -> dict[str, Any]:
+ """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 = (
+ 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")
+ 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..9da7539453
--- /dev/null
+++ b/loopx/capabilities/anti_entropy/contract.py
@@ -0,0 +1,158 @@
+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 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__}"
+ )
+
+ 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.
+
+ 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="")
+ 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)
+ 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
+ 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,
+ "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..4589673b40
--- /dev/null
+++ b/loopx/capabilities/anti_entropy/feedback.py
@@ -0,0 +1,252 @@
+"""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
+from .oracles import normalize_repo_path
+
+_INSPECT_CLI_ROUTE = (
+ "loopx anti-entropy inspect --diff-file "
+ "--basis-json --workspace --format json"
+)
+
+_DELIVERY_ROUTE = (
+ "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"
+
+_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"):
+ message = finding.message.lower()
+ if "property key" in message or "destructuring" in message:
+ return (
+ "If this key is an unrelated object-literal or type definition, rename or "
+ "move it so the property-key context is unambiguous; if it is a destructuring "
+ "read, migrate the reader. Certification cannot proceed until the context is "
+ "parser-resolvable."
+ )
+ 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 []
+ 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"
+ 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/js_reference.py b/loopx/capabilities/anti_entropy/js_reference.py
new file mode 100644
index 0000000000..ea56253655
--- /dev/null
+++ b/loopx/capabilities/anti_entropy/js_reference.py
@@ -0,0 +1,1047 @@
+"""JS/TS reference lexing, static-key parsing and property-key classification.
+
+Bounded reference analysis for anti-entropy. This module does not import the
+oracle core, so it can be reviewed and tested independently of diff/evidence
+orchestration.
+"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+
+
+_TS_IDENTIFIER_RE = re.compile(r"[A-Za-z_$][A-Za-z0-9_$]*\Z")
+_TS_QUOTE_CHARS = ("\"", "'", chr(96))
+_HEX_DIGITS = frozenset("0123456789abcdefABCDEF")
+_JS_SIMPLE_ESCAPES = {
+ "\"": "\"",
+ "'": "'",
+ "\\": "\\",
+ "/": "/",
+ "b": "\b",
+ "f": "\f",
+ "n": "\n",
+ "r": "\r",
+ "t": "\t",
+ "v": "\v",
+ "0": "\0",
+}
+_JS_ESCAPE_HINT_RE = re.compile(
+ r"\\(?:u\{?[0-9a-fA-F]|x[0-9a-fA-F]|[0-7]|[_$A-Za-z]|\r?\n)"
+)
+_JS_ESCAPE_SEQUENCE_RE = re.compile(
+ r"\\(?:u\{[0-9a-fA-F]{1,6}\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[0-7]{1,3}"
+ r"|[_$A-Za-z]|\r?\n)"
+)
+_TS_ESCAPED_IDENTIFIER_RE = re.compile(
+ r"(?:[A-Za-z_$]|\\u\{[0-9a-fA-F]{1,6}\}|\\u[0-9a-fA-F]{4}|\\x[0-9a-fA-F]{2}|\\[0-7]{1,3}|\\[_$A-Za-z])"
+ r"(?:[A-Za-z0-9_$]|\\u\{[0-9a-fA-F]{1,6}\}|\\u[0-9a-fA-F]{4}|\\x[0-9a-fA-F]{2}|\\[0-7]{1,3}|\\[_$A-Za-z])*"
+)
+_TS_MEMBER_KEYWORDS = frozenset(
+ {
+ "return",
+ "throw",
+ "case",
+ "yield",
+ "await",
+ "typeof",
+ "void",
+ "delete",
+ "new",
+ "in",
+ "instanceof",
+ "export",
+ "default",
+ "const",
+ "let",
+ "var",
+ "import",
+ }
+)
+
+
+def _decode_js_escape_text(raw: str) -> str | None:
+ """Decode a JS/TS quoted-string body, including identifier escapes.
+
+ Returns ``None`` when the escape sequence is malformed or truncated. Callers
+ treat that as insufficient evidence, never as an absent reference.
+ """
+ output: list[str] = []
+ index = 0
+ length = len(raw)
+ while index < length:
+ char = raw[index]
+ if char != "\\":
+ output.append(char)
+ index += 1
+ continue
+ if index + 1 >= length:
+ return None
+ nxt = raw[index + 1]
+ if nxt == "u":
+ if index + 2 < length and raw[index + 2] == "{":
+ end = raw.find("}", index + 3)
+ if end == -1:
+ return None
+ digits = raw[index + 3 : end]
+ if not 1 <= len(digits) <= 6 or not set(digits) <= _HEX_DIGITS:
+ return None
+ code = int(digits, 16)
+ if code > 0x10FFFF or 0xD800 <= code <= 0xDFFF:
+ return None
+ output.append(chr(code))
+ index = end + 1
+ continue
+ digits = raw[index + 2 : index + 6]
+ if len(digits) != 4 or not set(digits) <= _HEX_DIGITS:
+ return None
+ code = int(digits, 16)
+ if 0xD800 <= code <= 0xDFFF:
+ return None
+ output.append(chr(code))
+ index += 6
+ continue
+ if nxt == "x":
+ digits = raw[index + 2 : index + 4]
+ if len(digits) != 2 or not set(digits) <= _HEX_DIGITS:
+ return None
+ output.append(chr(int(digits, 16)))
+ index += 4
+ continue
+ if nxt in _JS_SIMPLE_ESCAPES:
+ output.append(_JS_SIMPLE_ESCAPES[nxt])
+ index += 2
+ continue
+ if nxt == "\n":
+ index += 2
+ continue
+ if nxt == "\r":
+ index += 3 if index + 2 < length and raw[index + 2] == "\n" else 2
+ continue
+ if nxt in "01234567":
+ # Legacy octal escape (non-strict JS strings); \0 alone is NUL.
+ if nxt == "0" and (
+ index + 2 >= length or raw[index + 2] not in "01234567"
+ ):
+ output.append("\0")
+ index += 2
+ continue
+ cursor = index + 1
+ digits = ""
+ while cursor < length and len(digits) < 3 and raw[cursor] in "01234567":
+ digits += raw[cursor]
+ cursor += 1
+ code = int(digits, 8)
+ if code > 0xFF:
+ return None
+ output.append(chr(code))
+ index = cursor
+ continue
+ # NonEscapeCharacter: the escaped character has no special meaning.
+ output.append(nxt)
+ index += 2
+ return "".join(output)
+
+
+def _decode_js_escapes_lenient(text: str) -> str:
+ """Decode JS escapes where possible; leave malformed sequences unchanged."""
+
+ def replace(match: re.Match[str]) -> str:
+ decoded = _decode_js_escape_text(match.group(0))
+ return match.group(0) if decoded is None else decoded
+
+ return _JS_ESCAPE_SEQUENCE_RE.sub(replace, text)
+
+
+_REGEX_ALLOWED_AFTER = frozenset("=([{,:;!&|?+-*%^~<>")
+_REGEX_ALLOWED_AFTER_WORDS = frozenset(
+ {
+ "return",
+ "throw",
+ "case",
+ "typeof",
+ "delete",
+ "void",
+ "new",
+ "in",
+ "instanceof",
+ "yield",
+ "await",
+ "do",
+ "else",
+ }
+)
+
+
+def _regex_literal_spans(code: str) -> list[tuple[int, int]]:
+ """Return heuristic regex-literal spans in stripped code.
+
+ Only used to keep identifier-escape normalization out of pattern text; a
+ slash after an identifier, literal, ``)`` or ``]`` is division and stays code.
+ """
+ spans: list[tuple[int, int]] = []
+ index = 0
+ length = len(code)
+ while index < length:
+ if code[index] != "/":
+ index += 1
+ continue
+ prefix = code[:index].rstrip()
+ previous_char = prefix[-1] if prefix else ""
+ word_match = re.search(r"([A-Za-z_$][A-Za-z0-9_$]*)$", prefix)
+ previous_word = word_match.group(1) if word_match else ""
+ starts_regex = (
+ not prefix
+ or previous_char in _REGEX_ALLOWED_AFTER
+ or previous_word in _REGEX_ALLOWED_AFTER_WORDS
+ )
+ if not starts_regex:
+ index += 1
+ continue
+ cursor = index + 1
+ in_class = False
+ closed = False
+ while cursor < length:
+ char = code[cursor]
+ if char == "\\":
+ cursor += 2
+ continue
+ if char == "\n":
+ break
+ if char == "[":
+ in_class = True
+ elif char == "]":
+ in_class = False
+ elif char == "/" and not in_class:
+ closed = True
+ break
+ cursor += 1
+ if not closed:
+ index += 1
+ continue
+ spans.append((index, cursor + 1))
+ index = cursor + 1
+ return spans
+
+
+def _normalize_identifier_escapes(code: str) -> str:
+ """Decode escapes embedded in code identifiers so ``old\\u005fapi`` is visible."""
+ spans = _regex_literal_spans(code)
+
+ def replace(match: re.Match[str]) -> str:
+ token = match.group(0)
+ if "\\" not in token:
+ return token
+ if any(start <= match.start() < end for start, end in spans):
+ return token
+ decoded = _decode_js_escape_text(token)
+ return token if decoded is None else decoded
+
+ return _TS_ESCAPED_IDENTIFIER_RE.sub(replace, code)
+
+
+@dataclass(frozen=True, slots=True)
+class _ComputedMember:
+ """Result of parsing a static computed member access in value position."""
+
+ name: str
+ next_index: int
+ resolved: bool
+ newlines: int = 0
+
+
+@dataclass(frozen=True, slots=True)
+class _StaticKey:
+ """A statically evaluated computed-member key expression."""
+
+ value: str
+ next_index: int
+ resolved: bool
+
+
+_JS_EXTRA_WHITESPACE = "\u00a0\u2028\u2029\ufeff"
+_TS_ASSERTION_KEYWORDS = ("as", "satisfies")
+_TS_IDENTIFIER_AT_RE = re.compile(r"[A-Za-z_$][A-Za-z0-9_$]*")
+
+
+def _is_js_whitespace(char: str) -> bool:
+ return char.isspace() or char in _JS_EXTRA_WHITESPACE
+
+
+def _skip_js_trivia(text: str, index: int) -> tuple[int, bool]:
+ """Skip JS whitespace and comments between tokens.
+
+ Returns ``(index, True)`` on success and ``(index, False)`` for an unclosed
+ block comment; callers fail closed in that case.
+ """
+ length = len(text)
+ while index < length:
+ char = text[index]
+ if _is_js_whitespace(char):
+ index += 1
+ continue
+ if text.startswith("//", index):
+ cursor = index + 2
+ while cursor < length and text[cursor] not in "\r\n\u2028\u2029":
+ cursor += 1
+ index = cursor
+ continue
+ if text.startswith("/*", index):
+ close = text.find("*/", index + 2)
+ if close == -1:
+ return index, False
+ index = close + 2
+ continue
+ break
+ return index, True
+
+
+def _previous_receiver_char(out: list[str]) -> str:
+ """Return the code character before the member bracket, skipping ``!`` suffix."""
+ for position in range(len(out) - 1, -1, -1):
+ for char in reversed(out[position]):
+ if char.isspace() or char == "!":
+ continue
+ return char
+ return ""
+
+
+def _previous_receiver_word_info(out: list[str]) -> tuple[str, str]:
+ """Return the identifier before the member bracket and its prefix text."""
+ code = "".join(out)
+ end = len(code)
+ while end and (code[end - 1].isspace() or code[end - 1] == "!"):
+ end -= 1
+ start = end
+ while start and (code[start - 1].isalnum() or code[start - 1] in "_$"):
+ start -= 1
+ return code[start:end], code[:start]
+
+
+_TS_CONTEXTUAL_MEMBER_WORDS = frozenset({"of"})
+
+
+def _is_contextual_member_keyword(prefix: str, word: str) -> bool:
+ """Distinguish the ``for (... of ...)`` keyword from an identifier named ``of``."""
+ if word != "of":
+ return False
+ # ``for (const k of [ ... ])``: the current word is the first top-level
+ # ``of`` after a binding, not a later variable expression.
+ return (
+ re.search(
+ r"\bfor\s*\(\s*(?:(?:const|let|var)\s+)?"
+ r"[A-Za-z_$][A-Za-z0-9_$]*\s*$",
+ prefix,
+ re.S,
+ )
+ is not None
+ )
+
+
+def _parse_static_string(text: str, index: int) -> _StaticKey:
+ quote = text[index]
+ length = len(text)
+ cursor = index + 1
+ while cursor < length:
+ char = text[cursor]
+ if char == "\\":
+ if cursor + 1 >= length:
+ return _StaticKey("", length, False)
+ cursor += 2
+ continue
+ if char == quote:
+ decoded = _decode_js_escape_text(text[index + 1 : cursor])
+ if decoded is None:
+ return _StaticKey("", cursor + 1, False)
+ return _StaticKey(decoded, cursor + 1, True)
+ if char in "\r\n\u2028\u2029":
+ return _StaticKey("", cursor, False)
+ cursor += 1
+ return _StaticKey("", length, False)
+
+
+def _parse_static_template(
+ text: str, index: int, *, raw_mode: bool = False
+) -> _StaticKey | None:
+ length = len(text)
+ cursor = index + 1
+ literal_start = cursor
+ pieces: list[str] = []
+
+ def append_literal(end: int) -> _StaticKey | None:
+ segment = text[literal_start:end]
+ if raw_mode:
+ pieces.append(segment)
+ return None
+ decoded = _decode_js_escape_text(segment)
+ if decoded is None:
+ return _StaticKey("", end, False)
+ pieces.append(decoded)
+ return None
+
+ while cursor < length:
+ char = text[cursor]
+ if char == "\\":
+ if cursor + 1 >= length:
+ return _StaticKey("", length, False)
+ cursor += 2
+ continue
+ if char == "`":
+ failure = append_literal(cursor)
+ if failure is not None:
+ return failure
+ return _StaticKey("".join(pieces), cursor + 1, True)
+ if char == "$" and cursor + 1 < length and text[cursor + 1] == "{":
+ failure = append_literal(cursor)
+ if failure is not None:
+ return failure
+ inner = _parse_static_key_expression(text, cursor + 2)
+ if inner is None:
+ return None
+ if not inner.resolved:
+ return _StaticKey("", inner.next_index, False)
+ close, ok = _skip_js_trivia(text, inner.next_index)
+ if not ok:
+ return _StaticKey("", close, False)
+ if close >= length or text[close] != "}":
+ return None
+ pieces.append(inner.value)
+ cursor = close + 1
+ literal_start = cursor
+ continue
+ if char in "\r\n":
+ cursor += 1
+ continue
+ cursor += 1
+ return _StaticKey("", length, False)
+
+
+def _skip_ts_assertions(text: str, index: int) -> tuple[int, bool]:
+ """Skip simple TS type assertions/postfixes in a key expression."""
+ length = len(text)
+ while True:
+ index, ok = _skip_js_trivia(text, index)
+ if not ok:
+ return index, False
+ match = _TS_IDENTIFIER_AT_RE.match(text, index)
+ if match is not None and match.group(0) in _TS_ASSERTION_KEYWORDS:
+ index = match.end()
+ index, ok = _skip_js_trivia(text, index)
+ if not ok:
+ return index, False
+ type_match = _TS_IDENTIFIER_AT_RE.match(text, index)
+ if type_match is None:
+ return index, False
+ index = type_match.end()
+ # ``as string[]`` / ``as T[][]``: empty array suffixes keep the value.
+ while index + 1 < length and text[index] == "[" and text[index + 1] == "]":
+ index += 2
+ continue
+ if index < length and text[index] == "!":
+ index += 1
+ continue
+ return index, True
+
+
+def _skip_call_tail(text: str, index: int) -> int | None:
+ """Skip a call argument tail to the closing ``)`` (strings/comments aware)."""
+ length = len(text)
+ depth = 1
+ while index < length:
+ char = text[index]
+ if char in ('"', "'", "`"):
+ quote = char
+ index += 1
+ while index < length:
+ if text[index] == "\\":
+ index += 2
+ continue
+ if text[index] == quote:
+ index += 1
+ break
+ index += 1
+ continue
+ if text.startswith("//", index):
+ while index < length and text[index] not in "\r\n\u2028\u2029":
+ index += 1
+ continue
+ if text.startswith("/*", index):
+ close = text.find("*/", index + 2)
+ if close == -1:
+ return None
+ index = close + 2
+ continue
+ if char == "(":
+ depth += 1
+ elif char == ")":
+ depth -= 1
+ if depth == 0:
+ return index + 1
+ index += 1
+ return None
+
+
+def _parse_string_call(text: str, index: int) -> _StaticKey | None:
+ length = len(text)
+ index, ok = _skip_js_trivia(text, index)
+ if not ok:
+ return _StaticKey("", index, False)
+ if index < length and text[index] == ".":
+ index, ok = _skip_js_trivia(text, index + 1)
+ if not ok:
+ return _StaticKey("", index, False)
+ if not text.startswith("raw", index):
+ return None
+ index += 3
+ index, ok = _skip_js_trivia(text, index)
+ if not ok:
+ return _StaticKey("", index, False)
+ if index < length and text[index] == "`":
+ return _parse_static_template(text, index, raw_mode=True)
+ return None
+ if index < length and text[index] == "(":
+ inner = _parse_static_key_expression(text, index + 1)
+ if inner is None:
+ return None
+ if not inner.resolved:
+ return _StaticKey("", inner.next_index, False)
+ close, ok = _skip_js_trivia(text, inner.next_index)
+ if not ok:
+ return _StaticKey("", close, False)
+ if close < length and text[close] == ")":
+ return _StaticKey(inner.value, close + 1, True)
+ if close < length and text[close] == ",":
+ tail = _skip_call_tail(text, close + 1)
+ if tail is not None:
+ return _StaticKey(inner.value, tail, True)
+ return None
+
+
+def _parse_static_primary(text: str, index: int) -> _StaticKey | None:
+ index, ok = _skip_js_trivia(text, index)
+ if not ok:
+ return _StaticKey("", index, False)
+ if index >= len(text):
+ return None
+ char = text[index]
+ if char == "`":
+ return _parse_static_template(text, index)
+ if char in _TS_QUOTE_CHARS:
+ return _parse_static_string(text, index)
+ if char == "(":
+ inner = _parse_static_key_expression(text, index + 1)
+ if inner is None:
+ return None
+ if not inner.resolved:
+ return _StaticKey("", inner.next_index, False)
+ close, ok = _skip_js_trivia(text, inner.next_index)
+ if not ok:
+ return _StaticKey("", close, False)
+ if close < len(text) and text[close] == ")":
+ return _StaticKey(inner.value, close + 1, inner.resolved)
+ return _StaticKey("", close, False)
+ if text.startswith("String", index):
+ after = index + 6
+ if after >= len(text) or not (
+ text[after].isalnum() or text[after] in "_$"
+ ):
+ return _parse_string_call(text, after)
+ return None
+
+
+def _parse_static_key_expression(text: str, index: int) -> _StaticKey | None:
+ first = _parse_static_primary(text, index)
+ if first is None:
+ return None
+ if not first.resolved:
+ return first
+ value = first.value
+ cursor = first.next_index
+ while True:
+ cursor, ok = _skip_ts_assertions(text, cursor)
+ if not ok:
+ return _StaticKey("", cursor, False)
+ cursor, ok = _skip_js_trivia(text, cursor)
+ if not ok:
+ return _StaticKey("", cursor, False)
+ if cursor < len(text) and text[cursor] == "+":
+ right = _parse_static_primary(text, cursor + 1)
+ if right is None:
+ return None
+ if not right.resolved:
+ return _StaticKey("", right.next_index, False)
+ value += right.value
+ cursor = right.next_index
+ continue
+ return _StaticKey(value, cursor, True)
+
+
+def _static_computed_member(
+ text: str, start: int, out: list[str]
+) -> _ComputedMember | None:
+ """Preserve a static computed member access (api[key]) in value position.
+
+ The key is parsed as a bounded static expression: string/template literals,
+ parenthesized static keys, constant ``+`` concatenation, ``String(...)``
+ and simple TypeScript assertions. Any legal JS trivia (whitespace, line
+ terminators, comments) is skipped at every token boundary. A malformed
+ static form is reported as unresolved so the tokenizer fails closed with
+ AE004; a dynamic expression keeps the documented dynamic-key limitation.
+ """
+ length = len(text)
+ index, ok = _skip_js_trivia(text, start + 1)
+ if not ok:
+ return _ComputedMember("", length, False)
+ key = _parse_static_key_expression(text, index)
+ if key is None:
+ return None
+ if not key.resolved:
+ return _ComputedMember("", key.next_index, False)
+ after, ok = _skip_js_trivia(text, key.next_index)
+ if not ok:
+ return _ComputedMember("", length, False)
+ if after >= length or text[after] != "]":
+ return None
+ previous = _previous_receiver_char(out)
+ if not (previous.isalnum() or previous in "_$)]."):
+ return None
+ previous_word, prefix = _previous_receiver_word_info(out)
+ if previous_word in _TS_MEMBER_KEYWORDS:
+ return None
+ if previous_word in _TS_CONTEXTUAL_MEMBER_WORDS and _is_contextual_member_keyword(
+ prefix, previous_word
+ ):
+ return None
+ next_index = after + 1
+ name = key.value if _TS_IDENTIFIER_RE.match(key.value) else ""
+ return _ComputedMember(
+ name,
+ next_index,
+ True,
+ text.count("\n", start, next_index),
+ )
+
+
+
+@dataclass(slots=True)
+class _JsBracketGroup:
+ start: int
+ open_char: str
+ parent: int | None = None
+ end: int = -1
+ kind: str = "unknown"
+
+
+def _skip_js_string_literal(text: str, index: int) -> int:
+ quote = text[index]
+ length = len(text)
+ cursor = index + 1
+ while cursor < length:
+ char = text[cursor]
+ if char == "\\":
+ cursor += 2
+ continue
+ if char == quote:
+ return cursor + 1
+ if quote != "`" and char in "\r\n\u2028\u2029":
+ return cursor
+ cursor += 1
+ return length
+
+
+def _scan_js_bracket_groups(text: str) -> list[_JsBracketGroup]:
+ """Collect balanced bracket groups with parent links, skipping JS strings/comments."""
+ groups: list[_JsBracketGroup] = []
+ stack: list[int] = []
+ index = 0
+ length = len(text)
+ closing = {"}": "{", "]": "[", ")": "("}
+ while index < length:
+ char = text[index]
+ if text.startswith("//", index):
+ newline = index + 2
+ while newline < length and text[newline] not in "\r\n\u2028\u2029":
+ newline += 1
+ index = newline
+ continue
+ if text.startswith("/*", index):
+ close = text.find("*/", index + 2)
+ index = length if close == -1 else close + 2
+ continue
+ if char in _TS_QUOTE_CHARS:
+ index = _skip_js_string_literal(text, index)
+ continue
+ if char in "{[(":
+ group = _JsBracketGroup(
+ start=index,
+ open_char=char,
+ parent=stack[-1] if stack else None,
+ )
+ groups.append(group)
+ stack.append(len(groups) - 1)
+ index += 1
+ continue
+ if char in closing:
+ if stack:
+ top = groups[stack[-1]]
+ if top.open_char == closing[char]:
+ top.end = index
+ stack.pop()
+ index += 1
+ continue
+ index += 1
+ return groups
+
+
+def _token_after(text: str, index: int) -> tuple[str, str]:
+ index, ok = _skip_js_trivia(text, index)
+ if not ok or index >= len(text):
+ return "", ""
+ char = text[index]
+ if char.isalpha() or char in "_$":
+ match = _TS_IDENTIFIER_AT_RE.match(text, index)
+ if match is not None:
+ return "", match.group(0)
+ return char, ""
+
+
+def _token_before(text: str, index: int) -> tuple[str, str]:
+ cursor = index
+ while True:
+ while cursor > 0 and text[cursor - 1].isspace():
+ cursor -= 1
+ if cursor >= 2 and text[cursor - 2 : cursor] == "*/":
+ open_comment = text.rfind("/*", 0, cursor - 1)
+ cursor = 0 if open_comment == -1 else open_comment
+ continue
+ line_start = text.rfind("\n", 0, cursor) + 1
+ line_comment = text.rfind("//", line_start, cursor)
+ if line_comment != -1:
+ cursor = line_comment
+ continue
+ break
+ if cursor <= 0:
+ return "", ""
+ end = cursor
+ while cursor > 0 and (text[cursor - 1].isalnum() or text[cursor - 1] in "_$"):
+ cursor -= 1
+ if cursor < end:
+ return "", text[cursor:end]
+ return text[end - 1], ""
+
+
+_TS_CONTROL_PAREN_WORDS = frozenset({"if", "while", "switch", "with"})
+_TS_EXPRESSION_LITERAL_LEADS = frozenset("=([,:?!&|+-*/%^~<>")
+
+
+def _classify_js_group(
+ groups: list[_JsBracketGroup], group_index: int, text: str
+) -> str:
+ group = groups[group_index]
+ if group.kind != "unknown":
+ return group.kind
+ before_char, before_word = _token_before(text, group.start)
+ after_char, after_word = _token_after(text, group.end + 1 if group.end >= 0 else len(text))
+ parent_kind = (
+ groups[group.parent].kind
+ if group.parent is not None
+ else "unknown"
+ )
+ if parent_kind == "unknown" and group.parent is not None:
+ parent_kind = _classify_js_group(groups, group.parent, text)
+ if group.open_char == "(":
+ after_index, after_ok = _skip_js_trivia(
+ text, group.end + 1 if group.end >= 0 else len(text)
+ )
+ is_arrow_params = after_ok and text.startswith("=>", after_index)
+ if before_word == "for":
+ kind = "for_header"
+ elif before_word == "catch":
+ kind = "catch"
+ elif before_word in _TS_CONTROL_PAREN_WORDS:
+ kind = "expression"
+ elif is_arrow_params:
+ kind = "params"
+ elif after_char in ("{", ":") and before_word not in ("", "return"):
+ kind = "params"
+ else:
+ kind = "expression"
+ elif group.open_char in "{[":
+ if before_word in ("const", "let", "var"):
+ kind = "pattern"
+ elif before_char == "(" and parent_kind in ("params", "catch", "for_header"):
+ kind = "pattern"
+ elif after_char == "=":
+ kind = "pattern"
+ elif after_word in ("of", "in"):
+ kind = "pattern"
+ elif parent_kind == "pattern":
+ if before_char == "=":
+ kind = "literal"
+ else:
+ kind = "pattern"
+ elif parent_kind == "literal":
+ kind = "literal"
+ elif before_char in _TS_EXPRESSION_LITERAL_LEADS or before_word in (
+ "return",
+ "throw",
+ "case",
+ "yield",
+ "await",
+ "typeof",
+ "void",
+ "delete",
+ "new",
+ "instanceof",
+ "of",
+ "in",
+ "default",
+ ):
+ kind = "literal"
+ else:
+ kind = "ambiguous"
+ else:
+ kind = "ambiguous"
+ group.kind = kind
+ return kind
+
+
+def _enclosing_object_group(
+ groups: list[_JsBracketGroup], index: int
+) -> _JsBracketGroup | None:
+ best: _JsBracketGroup | None = None
+ for group in groups:
+ if (
+ group.open_char == "{"
+ and group.end > index
+ and group.start < index
+ and (best is None or group.start > best.start)
+ ):
+ best = group
+ return best
+
+
+def _extract_module_string_name_refs(
+ text: str, target_symbols: set[str]
+) -> set[str]:
+ """Collect static string names in import/export clauses.
+
+ ``import { 'old_api' as fn } from './service'`` and re-exports name the
+ imported binding with a string literal; the name is an executable reference
+ even though it is not a property-key token.
+ """
+ refs: set[str] = set()
+ index = 0
+ length = len(text)
+ while index < length:
+ if text.startswith("//", index):
+ index += 2
+ while index < length and text[index] not in "\r\n\u2028\u2029":
+ index += 1
+ continue
+ if text.startswith("/*", index):
+ close = text.find("*/", index + 2)
+ index = length if close == -1 else close + 2
+ continue
+ if text[index] in _TS_QUOTE_CHARS:
+ index = _skip_js_string_literal(text, index)
+ continue
+ keyword = None
+ for candidate in ("import", "export"):
+ if text.startswith(candidate, index):
+ after = index + len(candidate)
+ if after >= length or not (
+ text[after].isalnum() or text[after] in "_$"
+ ):
+ keyword = candidate
+ break
+ if keyword is None:
+ index += 1
+ continue
+ clause_start, ok = _skip_js_trivia(text, index + len(keyword))
+ if not ok or clause_start >= length or text[clause_start] != "{":
+ index += len(keyword)
+ continue
+ cursor = clause_start + 1
+ depth = 1
+ while cursor < length and depth:
+ char = text[cursor]
+ if char in _TS_QUOTE_CHARS:
+ parsed = (
+ _parse_static_template(text, cursor)
+ if char == "`"
+ else _parse_static_string(text, cursor)
+ )
+ if parsed is not None and parsed.resolved and parsed.value in target_symbols:
+ refs.add(parsed.value)
+ cursor = _skip_js_string_literal(text, cursor)
+ continue
+ if text.startswith("//", cursor):
+ cursor += 2
+ while cursor < length and text[cursor] not in "\r\n\u2028\u2029":
+ cursor += 1
+ continue
+ if text.startswith("/*", cursor):
+ close = text.find("*/", cursor + 2)
+ cursor = length if close == -1 else close + 2
+ continue
+ if char == "{":
+ depth += 1
+ elif char == "}":
+ depth -= 1
+ cursor += 1
+ index = cursor
+ return refs
+
+
+def _extract_static_property_key_refs(
+ text: str, target_symbols: set[str]
+) -> tuple[set[str], str]:
+ """Extract static property-key references and flag ambiguous executable contexts.
+
+ Clear destructuring/binding/parameter/assignment/for-of property keys are
+ references. Clear object-literal property keys are not. A static target key
+ in a context the bounded classifier cannot classify makes the file's source
+ evidence incomplete (AE004); dynamic destructuring keys do the same because
+ they are executable property reads we cannot resolve.
+ """
+ if not target_symbols:
+ return set(), ""
+ groups = _scan_js_bracket_groups(text)
+ if not groups:
+ return set(), ""
+ refs: set[str] = set()
+ incomplete_reason = ""
+ index = 0
+ length = len(text)
+ while index < length:
+ char = text[index]
+ if text.startswith("//", index):
+ newline = index + 2
+ while newline < length and text[newline] not in "\r\n\u2028\u2029":
+ newline += 1
+ index = newline
+ continue
+ if text.startswith("/*", index):
+ close = text.find("*/", index + 2)
+ index = length if close == -1 else close + 2
+ continue
+ if char in _TS_QUOTE_CHARS:
+ parsed = (
+ _parse_static_template(text, index)
+ if char == "`"
+ else _parse_static_string(text, index)
+ )
+ if parsed is None:
+ # Dynamic template: an executable static property read cannot be
+ # resolved; only relevant when it is a pattern property key.
+ next_index = _skip_js_string_literal(text, index)
+ group = _enclosing_object_group(groups, index)
+ if group is not None:
+ kind = _classify_js_group(groups, groups.index(group), text)
+ previous, _ = _token_before(text, index)
+ after, _ = _token_after(text, next_index)
+ if previous in "{," and after == ":":
+ if kind == "pattern":
+ incomplete_reason = (
+ "a dynamic destructuring property key cannot be resolved"
+ )
+ elif kind == "ambiguous":
+ incomplete_reason = (
+ "a static property key cannot be classified as object literal or destructuring read"
+ )
+ index = next_index
+ continue
+ next_index = parsed.next_index
+ group = _enclosing_object_group(groups, index)
+ if group is not None:
+ kind = _classify_js_group(groups, groups.index(group), text)
+ previous, _ = _token_before(text, index)
+ after, _ = _token_after(text, next_index)
+ if previous in "{," and after == ":":
+ is_target = parsed.resolved and parsed.value in target_symbols
+ if kind == "pattern" and is_target:
+ refs.add(parsed.value)
+ elif kind == "ambiguous" and is_target:
+ incomplete_reason = (
+ "a static property key cannot be classified as object literal or destructuring read"
+ )
+ elif kind == "pattern" and not parsed.resolved:
+ incomplete_reason = (
+ "a malformed static destructuring property key cannot be resolved"
+ )
+ index = next_index
+ continue
+ if char == "[":
+ group = _enclosing_object_group(groups, index)
+ if group is not None:
+ kind = _classify_js_group(groups, groups.index(group), text)
+ previous, _ = _token_before(text, index)
+ if previous in "{,":
+ parsed = _parse_static_key_expression(text, index + 1)
+ if parsed is None or not parsed.resolved:
+ if kind in ("pattern", "ambiguous"):
+ incomplete_reason = (
+ "a computed destructuring property key cannot be resolved"
+ )
+ index += 1
+ continue
+ close, ok = _skip_js_trivia(text, parsed.next_index)
+ if ok and close < length and text[close] == "]":
+ after, _ = _token_after(text, close + 1)
+ if after == ":":
+ if kind == "pattern" and parsed.value in target_symbols:
+ refs.add(parsed.value)
+ elif kind == "ambiguous" and parsed.value in target_symbols:
+ incomplete_reason = (
+ "a static property key cannot be classified as object literal or destructuring read"
+ )
+ index = close + 1
+ continue
+ index += 1
+ continue
+ index += 1
+ continue
+ index += 1
+ return refs, incomplete_reason
+
+
+def _has_static_member_hint(content: str) -> bool:
+ """True when a file contains a bracket followed by a static-key start.
+
+ Uses the same JS trivia skipper as the parser so non-ASCII whitespace and
+ line terminators (U+FEFF, U+2028/U+2029) cannot hide a composed key before
+ the candidate gate runs.
+ """
+ index = content.find("[")
+ while index != -1:
+ after, ok = _skip_js_trivia(content, index + 1)
+ if ok and after < len(content):
+ char = content[after]
+ if char in _TS_QUOTE_CHARS or char == "(":
+ return True
+ if content.startswith("String", after):
+ boundary = after + 6
+ if boundary >= len(content) or not (
+ content[boundary].isalnum() or content[boundary] in "_$"
+ ):
+ return True
+ index = content.find("[", index + 1)
+ return False
+
+
+def _has_property_key_hint(content: str) -> bool:
+ """True for an object/pattern property key start after ``{``.
+
+ Besides static keys this catches computed property keys such as
+ ``const { [key]: fn } = api`` that have no literal target in the source but
+ still perform an executable property read requiring completeness handling.
+ """
+ index = content.find("{")
+ while index != -1:
+ after, ok = _skip_js_trivia(content, index + 1)
+ if ok and after < len(content) and content[after] == "[":
+ return True
+ index = content.find("{", index + 1)
+ return False
diff --git a/loopx/capabilities/anti_entropy/oracles.py b/loopx/capabilities/anti_entropy/oracles.py
new file mode 100644
index 0000000000..1544f8efb3
--- /dev/null
+++ b/loopx/capabilities/anti_entropy/oracles.py
@@ -0,0 +1,1329 @@
+from __future__ import annotations
+
+import ast
+import codecs
+import hashlib
+import json
+import os
+import re
+import textwrap
+import unicodedata
+from collections.abc import Mapping
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+from .contract import (
+ AntiEntropyFinding,
+ AntiEntropyReceipt,
+ RetirementBasis,
+)
+from .js_reference import (
+ _JS_ESCAPE_HINT_RE,
+ _decode_js_escapes_lenient,
+ _extract_module_string_name_refs,
+ _extract_static_property_key_refs,
+ _has_property_key_hint,
+ _has_static_member_hint,
+ _normalize_identifier_escapes,
+ _static_computed_member,
+)
+
+_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 = async (
+ re.compile(
+ 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"}
+_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.
+
+ 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():
+ 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]
+ 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 == "/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:]
+ 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 _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)
+
+ # 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
+ 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:
+ # 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 = decode_git_diff_path(raw_path, is_source=True)
+ if clean_path:
+ deleted_paths.add(clean_path)
+
+ return deleted_paths
+
+
+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 _python_def_names(tree)
+ except SyntaxError:
+ try:
+ wrapped = "def _wrapper_():\n" + textwrap.indent(dedented, " ")
+ tree = ast.parse(wrapped)
+ return {
+ name
+ for name in _python_def_names(tree)
+ if name != "_wrapper_"
+ }
+ except SyntaxError:
+ 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)
+ 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:
+ 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:
+ candidate = m.group(1)
+ if candidate not in _TS_RESERVED_NAMES:
+ defs.add(candidate)
+ break
+ 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 text[i] == "[" and (not stack or stack[-1] != "template"):
+ computed = _static_computed_member(text, i, out)
+ if computed is not None:
+ if not computed.resolved:
+ return "".join(out), False
+ out.append(" ")
+ out.append(computed.name)
+ if computed.newlines:
+ out.append("\n" * computed.newlines)
+ i = computed.next_index
+ continue
+ 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, 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, str]:
+ """Extract real references to target symbols in TS/JS-like lines.
+
+ 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, True, ""
+
+ full_text = "\n".join(lines)
+ stripped_text, is_clean = _tokenize_ts_code(full_text)
+ stripped_lines = stripped_text.splitlines()
+ refs.update(_extract_module_string_name_refs(full_text, target_symbols))
+ property_refs, property_incomplete_reason = _extract_static_property_key_refs(
+ full_text, target_symbols
+ )
+ refs.update(property_refs)
+ if property_incomplete_reason:
+ is_clean = False
+
+ for i, raw_line in enumerate(lines):
+ stripped = stripped_lines[i] if i < len(stripped_lines) else ""
+ if not stripped.strip():
+ continue
+
+ 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 = _normalize_identifier_escapes(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)
+
+ if not is_clean:
+ return refs, False, property_incomplete_reason or "unclosed or unparseable JS/TS syntax"
+ return refs, True, ""
+
+
+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:
+ 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_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(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)
+ scan_incomplete: bool = False
+ incomplete_reason: str = ""
+ skipped_files: tuple[str, ...] = ()
+
+
+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 _normalized_python_symbols(symbols: set[str]) -> dict[str, str]:
+ """Map NFKC-normalized Python identifier to the declared target string."""
+ return {unicodedata.normalize("NFKC", symbol): symbol for symbol in symbols}
+
+
+def _content_may_reference(content: str, symbols: set[str], *, is_ts: bool) -> bool:
+ """Cheap gate before parsing a workspace file.
+
+ A literal hit always routes the file to the parser. A JS/TS file that spells
+ a target through an escape (``old\\u005fapi``) must also be parsed; if the
+ escape cannot be decoded leniently, route it to the parser so malformed
+ executable forms can fail closed with AE004 rather than be skipped.
+
+ Python identifiers are NFKC-normalized by the language. A raw substring
+ check loses identity for fullwidth/ligature/combining forms, so a non-ASCII
+ Python file is also checked against the NFKC-normalized source.
+ """
+ if any(symbol in content for symbol in symbols):
+ return True
+ if not is_ts:
+ normalized_symbols = _normalized_python_symbols(symbols)
+ if any(normalized in content for normalized in normalized_symbols):
+ return True
+ if content.isascii():
+ return False
+ normalized_content = unicodedata.normalize("NFKC", content)
+ return any(
+ normalized in normalized_content
+ for normalized in normalized_symbols
+ )
+ # A composed static member key (parentheses, concatenation, template,
+ # comments/newlines, String(...)) need not contain the literal symbol, so
+ # a literal-only prefilter would skip the file before the parser sees it.
+ if _has_static_member_hint(content) or _has_property_key_hint(content):
+ return True
+ if "\\" not in content:
+ return False
+ if any(symbol in _decode_js_escapes_lenient(content) for symbol in symbols):
+ return True
+ return bool(_JS_ESCAPE_HINT_RE.search(content))
+
+
+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
+ 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:
+ 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:
+ 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) as exc:
+ evidence.scan_incomplete = True
+ evidence.incomplete_reason = f"Failed to read source file '{rel}': {exc}"
+ skipped.append(rel)
+ continue
+
+ visited += 1
+ ext = file_path.suffix.lower()
+ if not _content_may_reference(
+ content, symbol_set, is_ts=ext in _TS_EXTENSIONS
+ ):
+ continue
+
+ 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. NFKC keeps
+ # the check aligned with Python identifier identity.
+ normalized_content = unicodedata.normalize("NFKC", content)
+ for normalized, target in _normalized_python_symbols(
+ symbol_set
+ ).items():
+ if normalized in normalized_content:
+ evidence.defined_symbols.setdefault(target, set()).add(rel)
+ continue
+ for normalized, target in _normalized_python_symbols(symbol_set).items():
+ if normalized in defs:
+ evidence.defined_symbols.setdefault(target, set()).add(rel)
+ if normalized in refs:
+ evidence.referenced_symbols.setdefault(target, set()).add(rel)
+ continue
+ else:
+ content_lines = content.splitlines()
+ defs = _extract_ts_defs(content_lines)
+ refs, syntax_ok, syntax_reason = _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 incomplete reference evidence: {syntax_reason}"
+ )
+ skipped.append(rel)
+ continue
+
+ 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
+
+
+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)
+ for f_diff in file_diffs:
+ if not f_diff.strip():
+ continue
+
+ file_path = ""
+ 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 = 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
+
+ lines = h_text.splitlines()[1:]
+ pre_lines: list[str] = []
+ post_lines: list[str] = []
+ deleted_lines: list[str] = []
+ added_lines: list[str] = []
+ context_lines: list[str] = []
+
+ for line in lines:
+ if line.startswith(" "):
+ 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("---"):
+ clean_line = line[1:]
+ pre_lines.append(clean_line)
+ deleted_lines.append(clean_line)
+ elif line.startswith("+") and not line.startswith("+++"):
+ 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)
+
+ for s in ctx_defs:
+ surviving_context_defs.add(s)
+
+ 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 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 _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
+
+
+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()
+
+ 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=_RULE_AE002,
+ severity="blocking",
+ message=(
+ f"Target symbol '{symbol}' was slated for retirement but reintroduced "
+ "as an active definition in the diff."
+ ),
+ location=symbol,
+ )
+ )
+ 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=_RULE_AE004,
+ severity="blocking",
+ message=(
+ 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=symbol,
+ )
+ )
+ 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
+ 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
+
+ # 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=_RULE_AE002,
+ severity="blocking",
+ message=(
+ f"Target symbol '{symbol}' was slated for retirement but is still "
+ f"referenced by post-change sources: {', '.join(ref_files)}."
+ ),
+ location=symbol,
+ )
+ )
+ 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=_RULE_AE001,
+ severity=severity,
+ message=(
+ 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 analysis.deleted_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 in RetirementBasis "
+ "but was not removed in the diff."
+ ),
+ 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
+
+
+def audit_ghost_code(diff_text: str) -> list[AntiEntropyFinding]:
+ findings: list[AntiEntropyFinding] = []
+ consecutive_commented_code = 0
+ start_line = ""
+
+ 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())
+
+ 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:
+ 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
+
+ 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=_RULE_AE003,
+ 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,
+ workspace: str | Path | None = None,
+) -> AntiEntropyReceipt:
+ diff_text = textwrap.dedent(diff_text)
+ diff_hash = _hash_diff(diff_text)
+ all_findings: list[AntiEntropyFinding] = []
+
+ basis: RetirementBasis | None = None
+ if retirement_basis is not None:
+ basis = (
+ retirement_basis
+ if isinstance(retirement_basis, RetirementBasis)
+ else RetirementBasis.from_dict(retirement_basis)
+ )
+
+ 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(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/capabilities/anti_entropy/turn_context.py b/loopx/capabilities/anti_entropy/turn_context.py
new file mode 100644
index 0000000000..dad628d192
--- /dev/null
+++ b/loopx/capabilities/anti_entropy/turn_context.py
@@ -0,0 +1,581 @@
+"""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 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",
+)
+_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 _basis_from_declared_value(value: Any) -> RetirementBasis | None:
+ """Parse an explicitly declared basis value; malformed declarations stay inert."""
+ if isinstance(value, RetirementBasis):
+ return value if (value.target_symbols or value.target_paths) else None
+ if not isinstance(value, Mapping):
+ return None
+ try:
+ basis = RetirementBasis.from_dict(value)
+ except Exception: # noqa: BLE001 - a malformed declaration is not an opt-in
+ return None
+ return basis if (basis.target_symbols or basis.target_paths) else None
+
+
+def _extract_basis_candidate(candidate: Any, *, _depth: int = 0) -> RetirementBasis | None:
+ """Find an explicitly declared retirement basis inside a task carrier.
+
+ Task text, notes, and generic metadata are carriers, never authorization:
+ a mapping is a basis only when it is reached through the reserved
+ ``retirement_basis`` / ``anti_entropy_basis`` namespace. A mapping that merely
+ contains ``target_symbols`` / ``target_paths`` is ordinary task metadata and
+ must stay inert, otherwise an unrelated task's path parameters would
+ activate delete-first governance (review R9 P1).
+ """
+ if candidate is None or _depth > 8:
+ return None
+ if isinstance(candidate, RetirementBasis):
+ return (
+ candidate
+ if (candidate.target_symbols or candidate.target_paths)
+ else None
+ )
+ if isinstance(candidate, str):
+ for embedded in _embedded_json_objects(candidate):
+ parsed = _extract_basis_candidate(embedded, _depth=_depth + 1)
+ if parsed is not None:
+ return parsed
+ return None
+ if not isinstance(candidate, Mapping):
+ return None
+
+ for key in _BASIS_KEYS:
+ if key in candidate:
+ return _basis_from_declared_value(candidate[key])
+
+ # An ``anti_entropy`` wrapper may hold an explicit basis namespace, but its
+ # bare target fields are an enablement/config surface, not a declaration.
+ anti_entropy = candidate.get("anti_entropy")
+ if isinstance(anti_entropy, Mapping):
+ for key in _BASIS_KEYS:
+ if key in anti_entropy:
+ return _basis_from_declared_value(anti_entropy[key])
+
+ # Generic containers are traversed only to locate the reserved namespace.
+ for container_key in _BASIS_CONTAINER_KEYS:
+ if container_key in candidate:
+ parsed = _extract_basis_candidate(
+ candidate[container_key], _depth=_depth + 1
+ )
+ if parsed is not None:
+ 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, _depth=_depth + 1)
+ 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 _selected_todo_ids(
+ payload: Mapping[str, Any],
+ decision: Mapping[str, Any],
+) -> set[str]:
+ """Return the explicit selected-Todo ids for this turn, if any."""
+ ids: set[str] = set()
+
+ def add(value: Any) -> None:
+ if isinstance(value, Mapping):
+ todo_id = value.get("todo_id")
+ if isinstance(todo_id, str) and todo_id.strip():
+ ids.add(todo_id.strip())
+
+ 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"))
+ route = payload.get("route")
+ if isinstance(route, Mapping):
+ add(route.get("selected_todo"))
+ add(payload.get("selected_todo"))
+ 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"))
+ return ids
+
+
+def _record_matches_selection(
+ record: Mapping[str, Any], selected_ids: set[str]
+) -> bool:
+ if not selected_ids:
+ return True
+ todo_id = record.get("todo_id")
+ # The canonical work-context Todo record can omit todo_id; it is still the
+ # selected task's own carrier. Only an explicitly different id is excluded.
+ if not isinstance(todo_id, str) or not todo_id.strip():
+ return True
+ return todo_id.strip() in selected_ids
+
+
+def _resolve_retirement_basis(
+ payload: Mapping[str, Any],
+ decision: Mapping[str, Any],
+) -> RetirementBasis | None:
+ """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,
+ decision,
+ 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("selected_todo"),
+ payload.get("task"),
+ 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_ids = _selected_todo_ids(payload, decision)
+ for candidate in candidates:
+ if (
+ selected_ids
+ and isinstance(candidate, Mapping)
+ and candidate.get("todo_id") is not None
+ and not _record_matches_selection(candidate, selected_ids)
+ ):
+ continue
+ 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):
+ selected = action.get("selected_todo")
+ text_ref = (
+ str(selected.get("text_ref") or "")
+ if isinstance(selected, Mapping)
+ else ""
+ )
+ selected_text = (
+ selected.get("text") if isinstance(selected, Mapping) else None
+ )
+ text_ref_names_action = text_ref.endswith(
+ ("recommended_action", "action.text")
+ ) or text_ref in {"recommended_action", "text"}
+ if not selected_ids or text_ref_names_action or not (
+ isinstance(selected_text, str) and selected_text.strip()
+ ):
+ 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):
+ if not _record_matches_selection(record, selected_ids):
+ continue
+ 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 workspace hint
+ return None
+
+
+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,
+) -> bool:
+ """Determine whether anti-entropy is enabled for this turn.
+
+ Every enablement source is task-scoped: a declared retirement basis, the
+ selected Todo's typed ``required_capabilities``, or a direct typed flag on
+ the Turn/Todo/envelope. A Goal-level capability declaration is availability,
+ not activation: it must not switch unrelated or later-added Todos in the same
+ Goal into retirement governance (review R9 scope requirement).
+ """
+ if basis is not None and (basis.target_symbols or basis.target_paths):
+ return True
+
+ if payload.get("anti_entropy_enabled") is True or decision.get("anti_entropy_enabled") is True:
+ return True
+ 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):
+ 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 capability is True:
+ return True
+
+ selected_ids = _selected_todo_ids(payload, decision)
+ for record in _selected_todo_records(payload, decision):
+ if not _record_matches_selection(record, selected_ids):
+ continue
+ if record.get("anti_entropy_enabled") is True:
+ return True
+ if _record_required_capabilities(record) & _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: 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)
+ if resolved_basis is _UNSET
+ else resolved_basis
+ )
+ enabled_value = bool(
+ _is_anti_entropy_enabled(payload, decision, basis)
+ if enabled is _UNSET
+ else enabled
+ )
+
+ if not enabled_value:
+ return {
+ "readback": {},
+ "context_fields": {},
+ "instructions": [],
+ "result_fields": {},
+ }
+
+ 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": {},
+ }
+
+ 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)
+
+ context = build_anti_entropy_turn_context(
+ basis=basis,
+ goal_id=goal_id,
+ workspace=workspace,
+ )
+
+ if is_recovery:
+ instructions = [
+ "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.",
+ ]
+ status = "active"
+
+ readback = {
+ "anti_entropy_guidance": {
+ "enabled": True,
+ "applicable": True,
+ "target_symbols": list(basis.target_symbols),
+ "target_paths": list(basis.target_paths),
+ "status": status,
+ "workspace_available": workspace is not None,
+ }
+ }
+
+ 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:
+ 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.
+
+ 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)
+
+ def produce() -> Mapping[str, Any]:
+ return anti_entropy_context_result(
+ payload,
+ 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_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/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/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.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..a78587b0e7
--- /dev/null
+++ b/loopx/cli_commands/anti_entropy.py
@@ -0,0 +1,219 @@
+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,
+ RetirementBasis,
+)
+
+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.",
+ )
+ 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:
+ 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')}`")
+ 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)}`")
+ 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}")
+ 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"
+
+
+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,
+ *,
+ 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
+ # 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":
+ # 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,
+ goal_id=args.goal_id,
+ workspace=workspace,
+ )
+ elif command == "inspect":
+ 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:
+ 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..99ecc88a49
--- /dev/null
+++ b/tests/capabilities/test_anti_entropy.py
@@ -0,0 +1,2279 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+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,
+ 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"]
+ tool = next(t for t in record["agent_tools"] if t["name"] == "anti_entropy_inspect")
+ assert "not" in tool["purpose"] and "host-registered" in tool["purpose"]
+ boundaries = " ".join(record["boundaries"])
+ assert "retirement_basis" in boundaries
+ assert "availability, not activation" in boundaries
+
+
+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
+@@ -1,5 +1,5 @@
+-def old_deprecated_calculator(x, y):
+- return x + y
++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, workspace=workspace)
+ assert len(findings) == 0
+
+ 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(tmp_path: Path) -> 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
+"""
+ 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, 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, workspace=workspace)
+ assert receipt.verdict == "rejected"
+
+
+def test_verify_delete_first_zombie_reintroduced() -> None:
+ """Verify rejection when symbol is deleted and reintroduced simultaneously in the diff."""
+ 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 (diff-only evidence is sufficient for paths)
+ 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(tmp_path: Path) -> 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
+"""
+ 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, 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 = """
+--- 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(tmp_path: Path) -> 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
+"""
+ 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
+
+ # 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(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
+
+ 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
+@@ -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\"]}",
+ "--workspace",
+ str(workspace),
+ "--format",
+ "json",
+ ],
+ input=diff_fail.encode("utf-8"),
+ capture_output=True,
+ )
+ 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
+@@ -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\"]}",
+ "--workspace",
+ str(clean_workspace),
+ "--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
+
+ # 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
+
+ # 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."""
+ 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"
+ )
+ 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, 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
+
+
+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(tmp_path: Path) -> 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
+"""
+ 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, 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(tmp_path: Path) -> 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; }
+"""
+ workspace = _make_workspace(
+ tmp_path,
+ {"control_plane.ts": "export function newLeaseAcquire(): boolean { return true; }\n"},
+ )
+ basis = RetirementBasis(
+ target_symbols=(
+ "oldLeaseAcquire",
+ "OldScheduler",
+ "OldLeaseConfig",
+ "OldLeaseState",
+ "oldArrowHelper",
+ ),
+ )
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ 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(tmp_path: Path) -> 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();
+ }
+"""
+ 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, 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
+
+
+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_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
+@@ -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 == "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(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
+
+ diff = """
+--- a/service.ts
++++ b/service.ts
+@@ -1,4 +1,2 @@
+-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"
+ 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) -> 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 = """
+--- a/service.py
++++ b/service.py
+@@ -3,2 +3,0 @@
+- def old_api():
+- pass
+"""
+ basis = RetirementBasis(target_symbols=("old_api",))
+
+ 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
+
+
+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)
+
+
+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
+
+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",)
+
+
+_ESCAPED_MEMBER_FORMS = (
+ r"old\u005fapi",
+ r"old\u{5f}api",
+ r"old\x5fapi",
+ r"old\137api",
+ r"old\_api",
+ "old_" + "\\\n" + "api",
+ "old_" + "\\\r\n" + "api",
+)
+_ESCAPED_IDENTIFIER_FORMS = tuple(
+ name for name in _ESCAPED_MEMBER_FORMS if "\n" not in name
+)
+
+
+@pytest.mark.parametrize("escaped_name", _ESCAPED_MEMBER_FORMS)
+def test_typescript_escaped_static_computed_member_blocks_retirement(
+ tmp_path: Path, escaped_name: str
+) -> None:
+ """An escaped static member name is the same executable 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_escaped_member",
+ {
+ "service.mjs": "export function new_api() { return 2; }\n",
+ "client.mjs": (
+ "import * as api from './service.mjs';\n"
+ f"const value = api['{escaped_name}']();\n"
+ ),
+ },
+ )
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "rejected", f"findings: {receipt.findings}"
+ assert any(f.rule_id.startswith("AE002") for f in receipt.findings)
+ assert receipt.retired_symbols == ()
+
+
+@pytest.mark.parametrize("escaped_name", _ESCAPED_IDENTIFIER_FORMS)
+def test_typescript_escaped_bare_identifier_blocks_retirement(
+ tmp_path: Path, escaped_name: str
+) -> None:
+ """Escapes in a bare identifier reference are equally executable."""
+ 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_escaped_identifier",
+ {
+ "service.mjs": "export function new_api() { return 2; }\n",
+ "client.mjs": (
+ "import { old_api } from './service.mjs';\n"
+ f"const value = {escaped_name}();\n"
+ ),
+ },
+ )
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "rejected", f"findings: {receipt.findings}"
+ assert any(f.rule_id.startswith("AE002") for f in receipt.findings)
+ assert receipt.retired_symbols == ()
+
+
+@pytest.mark.parametrize(
+ "malformed",
+ (r"old\u12api", r"old\u{110000}api", r"old\x5papi"),
+)
+def test_typescript_malformed_escape_requires_insufficient_evidence(
+ tmp_path: Path, malformed: str
+) -> None:
+ """A malformed executable escape cannot be certified as absent evidence."""
+ 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_malformed_escape",
+ {
+ "service.mjs": "export function new_api() { return 2; }\n",
+ "client.mjs": (
+ "import * as api from './service.mjs';\n"
+ f"const value = api['{malformed}']();\n"
+ ),
+ },
+ )
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "rejected", f"findings: {receipt.findings}"
+ assert any(f.rule_id.startswith("AE004") for f in receipt.findings)
+ assert "old_api" not in receipt.retired_symbols
+
+
+def test_typescript_escaped_member_migrated_and_harmless_text_pass(
+ tmp_path: Path,
+) -> None:
+ """Migrated escaped calls and ordinary escaped strings stay non-blocking."""
+ 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_escaped_harmless",
+ {
+ "service.mjs": "export function new_api() { return 2; }\n",
+ "client.mjs": (
+ "import * as api from './service.mjs';\n"
+ r"const value = api['new\u005fapi']();" + "\n"
+ r"const message = 'old\u005fapi has been removed';" + "\n"
+ r"const names = ['old\u005fapi'];" + "\n"
+ "export function allNames() { return ['old_api']; }\n"
+ ),
+ },
+ )
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "passed", f"findings: {receipt.findings}"
+ assert receipt.retired_symbols == ("old_api",)
+
+
+def test_typescript_escaped_call_real_cli_exit_code(tmp_path: Path) -> None:
+ """The real CLI rejects the escaped caller with a non-zero exit code."""
+ import subprocess
+ import sys
+
+ diff = """
+--- a/service.mjs
++++ b/service.mjs
+@@ -1,2 +1,2 @@
+-export function old_api() { return 1; }
++export function new_api() { return 2; }
+"""
+ workspace = _make_workspace(
+ tmp_path / "ws_cli_escaped",
+ {
+ "service.mjs": "export function new_api() { return 2; }\n",
+ "client.mjs": (
+ "import * as api from './service.mjs';\n"
+ r"const value = api['old\u005fapi']();" + "\n"
+ ),
+ },
+ )
+ proc = subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "loopx.cli",
+ "anti-entropy",
+ "verify",
+ "--diff-file",
+ "-",
+ "--basis-json",
+ '{"target_symbols": ["old_api"]}',
+ "--workspace",
+ str(workspace),
+ "--format",
+ "json",
+ ],
+ input=diff.encode("utf-8"),
+ capture_output=True,
+ )
+ assert proc.returncode == 1, proc.stdout + proc.stderr
+
+
+def test_typescript_escaped_regex_pattern_is_not_a_reference(tmp_path: Path) -> None:
+ """Escape-like text inside a regex literal is pattern text, not a call."""
+ 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_regex_pattern",
+ {
+ "service.mjs": "export function new_api() { return 2; }\n",
+ "client.mjs": (
+ "import * as api from './service.mjs';\n"
+ r"const pattern = /old\u005fapi/;" + "\n"
+ ),
+ },
+ )
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "passed", f"findings: {receipt.findings}"
+ assert receipt.retired_symbols == ("old_api",)
+
+
+_STATIC_MEMBER_TRIVIA = (
+ "",
+ " ",
+ "\t",
+ "\n",
+ "\r\n",
+ "/* c */",
+ "// c\n",
+ "/*\n c\n*/",
+)
+
+
+@pytest.mark.parametrize("before", _STATIC_MEMBER_TRIVIA)
+@pytest.mark.parametrize("after", _STATIC_MEMBER_TRIVIA)
+def test_typescript_static_member_trivia_blocks_retirement(
+ tmp_path: Path, before: str, after: str
+) -> None:
+ """Legal JS whitespace/comments between member tokens are not an escape hatch."""
+ 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_trivia",
+ {
+ "service.mjs": "export function new_api() { return 2; }\n",
+ "client.mjs": (
+ "import * as api from './service.mjs';\n"
+ f"const value = api[{before}'old_api'{after}]();\n"
+ ),
+ },
+ )
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "rejected", f"findings: {receipt.findings}"
+ assert any(f.rule_id.startswith("AE002") for f in receipt.findings)
+ assert receipt.retired_symbols == ()
+
+
+@pytest.mark.parametrize("trivia", ("\n", "\r\n", "/* c */", "// c\n"))
+def test_typescript_escaped_key_with_trivia_blocks_retirement(
+ tmp_path: Path, trivia: str
+) -> None:
+ 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_escaped_trivia",
+ {
+ "service.mjs": "export function new_api() { return 2; }\n",
+ "client.mjs": (
+ "import * as api from './service.mjs';\n"
+ f"const value = api[{trivia}'old\\u005fapi'{trivia}]();\n"
+ ),
+ },
+ )
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "rejected", f"findings: {receipt.findings}"
+ assert any(f.rule_id.startswith("AE002") for f in receipt.findings)
+ assert receipt.retired_symbols == ()
+
+
+@pytest.mark.parametrize(
+ "key_expression",
+ (
+ "('old_api')",
+ "( 'old_api' )",
+ "('old' + '_api')",
+ "('old' + '\\u005fapi')",
+ "`old${'_api'}`",
+ "`old_api`",
+ "String('old_api')",
+ "String.raw`old_api`",
+ "('old_api' + '')",
+ ),
+)
+def test_typescript_static_key_expression_blocks_retirement(
+ tmp_path: Path, key_expression: str
+) -> None:
+ """A static key expression is the same executable member 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_key_expr",
+ {
+ "service.mjs": "export function new_api() { return 2; }\n",
+ "client.mjs": (
+ "import * as api from './service.mjs';\n"
+ f"const value = api[{key_expression}]();\n"
+ ),
+ },
+ )
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "rejected", f"findings: {receipt.findings}"
+ assert any(f.rule_id.startswith("AE002") for f in receipt.findings)
+ assert receipt.retired_symbols == ()
+
+
+def test_typescript_optional_chain_with_member_trivia_blocks_retirement(
+ tmp_path: Path,
+) -> None:
+ 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_optional_trivia",
+ {
+ "service.mjs": "export function new_api() { return 2; }\n",
+ "client.mjs": (
+ "import * as api from './service.mjs';\n"
+ "const value = api?.[ /* key */ (\n'old_api'\n) ]();\n"
+ ),
+ },
+ )
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "rejected", f"findings: {receipt.findings}"
+ assert receipt.retired_symbols == ()
+
+
+def test_typescript_type_level_member_forms_block_retirement(
+ tmp_path: Path,
+) -> None:
+ """TS non-null/assertion wrappers are erased at runtime but the call still runs."""
+ cases = (
+ "api!['old_api']()",
+ "api[('old_api' as const)]()",
+ "api['old_api' as const]()",
+ "api['old_api' as string[]]()",
+ "api[('old_api' satisfies string)]()",
+ "api['old_api'!]()",
+ )
+ for index, caller in enumerate(cases):
+ diff = """
+--- a/service.ts
++++ b/service.ts
+@@ -1,2 +1,2 @@
+-export function old_api(): number { return 1; }
++export function new_api(): number { return 2; }
+"""
+ basis = RetirementBasis(target_symbols=("old_api",))
+ workspace = _make_workspace(
+ tmp_path / f"ws_ts_member_{index}",
+ {
+ "service.ts": "export function new_api(): number { return 2; }\n",
+ "client.ts": (
+ "import * as api from './service';\n"
+ f"declare const value: number;\nconst x = {caller}\n"
+ ),
+ },
+ )
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "rejected", (caller, receipt.findings)
+ assert receipt.retired_symbols == ()
+
+
+def test_typescript_static_member_trivia_migrated_and_harmless_pass(
+ tmp_path: Path,
+) -> None:
+ 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_trivia_harmless",
+ {
+ "service.mjs": "export function new_api() { return 2; }\n",
+ "client.mjs": (
+ "import * as api from './service.mjs';\n"
+ "const migrated = api[ /* key */ (\n'new_api'\n) ]();\n"
+ "const harmless = obj[\n'hello world'\n];\n"
+ "export function names() { return [\n'old_api'\n]; }\n"
+ ),
+ },
+ )
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "passed", f"findings: {receipt.findings}"
+ assert receipt.retired_symbols == ("old_api",)
+
+
+def test_typescript_unclosed_member_comment_fails_closed(tmp_path: Path) -> None:
+ 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_unclosed_member_comment",
+ {
+ "service.mjs": "export function new_api() { return 2; }\n",
+ "client.mjs": (
+ "import * as api from './service.mjs';\n"
+ "const value = api[ /* unclosed 'old_api']();\n"
+ ),
+ },
+ )
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "rejected", f"findings: {receipt.findings}"
+ assert any(f.rule_id.startswith("AE004") for f in receipt.findings)
+ assert "old_api" not in receipt.retired_symbols
+
+
+def test_typescript_multiline_member_preserves_later_direct_reference(
+ tmp_path: Path,
+) -> None:
+ """Consuming member newlines must not shift later line mapping."""
+ 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_line_mapping",
+ {
+ "service.mjs": "export function new_api() { return 2; }\n",
+ "client.mjs": (
+ "import * as api from './service.mjs';\n"
+ "const harmless = obj[\n 'hello world'\n];\n"
+ "const direct = old_api();\n"
+ ),
+ },
+ )
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "rejected", f"findings: {receipt.findings}"
+ assert any(f.rule_id.startswith("AE002") for f in receipt.findings)
+ assert receipt.retired_symbols == ()
+
+
+def test_typescript_member_trivia_real_cli_exit_code(tmp_path: Path) -> None:
+ """Reviewer-shaped real CLI cases: newline, block comment, line comment."""
+ import subprocess
+ import sys
+
+ cases = (
+ "api[\n'old_api'\n]()",
+ "api[ /* key */ 'old_api']()",
+ "api[ // key\n'old_api']()",
+ )
+ for index, caller in enumerate(cases):
+ diff = """
+--- a/service.mjs
++++ b/service.mjs
+@@ -1,2 +1,2 @@
+-export function old_api() { return 1; }
++export function new_api() { return 2; }
+"""
+ workspace = _make_workspace(
+ tmp_path / f"ws_cli_trivia_{index}",
+ {
+ "service.mjs": "export function new_api() { return 2; }\n",
+ "client.mjs": (
+ "import * as api from './service.mjs';\n"
+ f"console.log({caller});\n"
+ ),
+ },
+ )
+ proc = subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "loopx.cli",
+ "anti-entropy",
+ "verify",
+ "--diff-file",
+ "-",
+ "--basis-json",
+ '{"target_symbols": ["old_api"]}',
+ "--workspace",
+ str(workspace),
+ "--format",
+ "json",
+ ],
+ input=diff.encode("utf-8"),
+ capture_output=True,
+ )
+ assert proc.returncode == 1, (caller, proc.stdout, proc.stderr)
+
+
+@pytest.mark.parametrize(
+ "before,after",
+ (
+ ("\ufeff", ""),
+ ("", "\ufeff"),
+ ("// c\u2028", "\u2028"),
+ ("// c\u2029", "\u2029"),
+ ("/* c */\ufeff", "\u2028"),
+ ),
+)
+def test_typescript_unicode_trivia_static_member_blocks_retirement(
+ tmp_path: Path, before: str, after: str
+) -> None:
+ """Legal non-ASCII whitespace/line terminators are trivia, not an escape hatch."""
+ 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_unicode_trivia",
+ {
+ "service.mjs": "export function new_api() { return 2; }\n",
+ "client.mjs": (
+ "import * as api from './service.mjs';\n"
+ f"const value = api[{before}('old' + '_api'){after}]();\n"
+ ),
+ },
+ )
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "rejected", f"findings: {receipt.findings}"
+ assert receipt.retired_symbols == ()
+
+
+@pytest.mark.parametrize(
+ "key_expression",
+ (
+ "String('old_api', 'ignored')",
+ "String('old'+'_api',)",
+ "String(('old' + '_api'))",
+ ),
+)
+def test_typescript_string_key_extra_arguments_blocks_retirement(
+ tmp_path: Path, key_expression: str
+) -> None:
+ """String() returns its first static argument; extra args do not change the key."""
+ 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_string_extra_args",
+ {
+ "service.mjs": "export function new_api() { return 2; }\n",
+ "client.mjs": (
+ "import * as api from './service.mjs';\n"
+ f"const value = api[{key_expression}]();\n"
+ ),
+ },
+ )
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "rejected", f"findings: {receipt.findings}"
+ assert receipt.retired_symbols == ()
+
+
+def test_typescript_for_of_array_literal_is_not_member_access(tmp_path: Path) -> None:
+ """A contextual keyword before an array literal must not become member access."""
+ 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_for_of",
+ {
+ "service.mjs": "export function new_api() { return 2; }\n",
+ "client.mjs": (
+ "import * as api from './service.mjs';\n"
+ "for (const key of ['old'+'_api']) { console.log(key); }\n"
+ ),
+ },
+ )
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "passed", f"findings: {receipt.findings}"
+ assert receipt.retired_symbols == ("old_api",)
+
+
+def test_typescript_contextual_keyword_identifiers_are_valid_receivers(
+ tmp_path: Path,
+) -> None:
+ """`of`/`as`/`satisfies` are legal identifiers when not used as keywords."""
+ 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_contextual_receivers",
+ {
+ "service.mjs": "export function new_api() { return 2; }\n",
+ "client.mjs": (
+ "import * as api from './service.mjs';\n"
+ "const of = api;\n"
+ "of['old_api']();\n"
+ "const as = api;\n"
+ "as['old'+'_api']();\n"
+ "const satisfies = api;\n"
+ "satisfies[('old_api')]();\n"
+ "function read(of) { return of['old_api'](); }\n"
+ "for (const item of of['old_api']()) { console.log(item); }\n"
+ "for (const item of as['old_api']) { console.log(item); }\n"
+ "function readAs() { return as['old_api'](); }\n"
+ ),
+ },
+ )
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "rejected", f"findings: {receipt.findings}"
+ assert any(f.rule_id.startswith("AE002") for f in receipt.findings)
+ assert receipt.retired_symbols == ()
+
+
+def test_typescript_asi_contextual_identifier_receiver_blocks_retirement(
+ tmp_path: Path,
+) -> None:
+ """ASI/no-semicolon identifiers named as/satisfies are still receivers."""
+ 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_asi_receivers",
+ {
+ "service.mjs": "export function new_api() { return 2; }\n",
+ "client.mjs": (
+ "import * as api from './service.mjs'\n"
+ "const as = api\n"
+ "as['old_api']()\n"
+ "const satisfies = api\n"
+ "satisfies['old' + '_api']()\n"
+ ),
+ },
+ )
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "rejected", f"findings: {receipt.findings}"
+ assert receipt.retired_symbols == ()
+
+
+def test_python_nfkc_identifiers_block_retirement(tmp_path: Path) -> None:
+ """Python NFKC identifiers must reach AST analysis, not be skipped by raw substring."""
+ fullwidth = "old_\uff41\uff50\uff49" # old_ + fullwidth a p i; NFKC -> old_api
+ diff = """
+--- a/service.py
++++ b/service.py
+@@ -1,2 +1,2 @@
+-def old_api():
+- return 1
++def new_api():
++ return 2
+"""
+ basis = RetirementBasis(target_symbols=("old_api",))
+ workspace = _make_workspace(
+ tmp_path / "ws_python_nfkc",
+ {
+ "service.py": "def new_api():\n return 2\n",
+ "legacy.py": f"def {fullwidth}():\n return 3\n",
+ "client.py": f"import legacy\nprint(legacy.{fullwidth}())\n",
+ "consumer.py": f"from legacy import {fullwidth}\nprint({fullwidth}())\n",
+ },
+ )
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "rejected", f"findings: {receipt.findings}"
+ assert any(f.rule_id.startswith("AE002") for f in receipt.findings)
+ assert receipt.retired_symbols == ()
+
+
+def test_python_nfkc_correct_deletion_passes(tmp_path: Path) -> None:
+ """Normalized identity must not block a correct deletion with no retained reference."""
+ diff = """
+--- a/service.py
++++ b/service.py
+@@ -1,2 +1,2 @@
+-def old_api():
+- return 1
++def new_api():
++ return 2
+"""
+ basis = RetirementBasis(target_symbols=("old_api",))
+ workspace = _make_workspace(
+ tmp_path / "ws_python_nfkc_clean",
+ {
+ "service.py": "def new_api():\n return 2\n",
+ "client.py": "import service\nprint(service.new_api())\n",
+ },
+ )
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "passed", f"findings: {receipt.findings}"
+ assert receipt.retired_symbols == ("old_api",)
+
+
+@pytest.mark.parametrize(
+ "target,source_name",
+ (
+ ("old_fi", "old_\ufb01"), # Latin small ligature fi
+ ("old_\u00e1", "old_a\u0301"), # a + combining acute
+ ),
+)
+def test_python_nfkc_ligature_and_combining_identifiers(
+ tmp_path: Path, target: str, source_name: str
+) -> None:
+ """NFKC-equivalent identifiers are the same Python name for reference analysis."""
+ diff = """
+--- a/service.py
++++ b/service.py
+@@ -1,2 +1,2 @@
+-def old_api():
+- return 1
++def new_api():
++ return 2
+"""
+ # The target symbol is intentionally different from the surrounding
+ # old_api wrapper: source_name normalizes to `target`.
+ workspace = _make_workspace(
+ tmp_path / "ws_python_nfkc_variant",
+ {
+ "service.py": "def new_api():\n return 2\n",
+ "legacy.py": f"def {source_name}():\n return 3\n",
+ "client.py": f"import legacy\nprint(legacy.{source_name}())\n",
+ },
+ )
+ variant_basis = RetirementBasis(target_symbols=(target,))
+ receipt = evaluate_diff_hygiene(diff, variant_basis, workspace=workspace)
+ assert receipt.verdict == "rejected", f"findings: {receipt.findings}"
+ assert receipt.retired_symbols == ()
+
+
+_DESTRUCTURING_FORMS = (
+ "const { ['old_api']: fn } = api; fn();",
+ "const { [`old_api`]: fn } = api; fn();",
+ "const { 'old_api': fn } = api; fn();",
+ "const { ['old'+'_api']: fn } = api; fn();",
+ "const { nested: { ['old_api']: fn } } = api; fn();",
+ "const { ['old_api']: fn = () => 0 } = api; fn();",
+ "function run({ ['old_api']: fn }) { return fn(); } run(api);",
+ "const run = ({ ['old_api']: fn }) => fn(); run(api);",
+ "let fn; ({ ['old_api']: fn } = api); fn();",
+ "for (const { ['old_api']: fn } of [api]) { fn(); }",
+ "const [{ ['old_api']: fn }] = [api]; fn();",
+ "const { /* key */ ['old_api']: fn } = api; fn();",
+ "const { ['old_api'] /* key */ : fn } = api; fn();",
+ "const {\n ['old_api']: fn\n} = api; fn();",
+ "const { // key\n ['old_api']: fn } = api; fn();",
+ "let { 'old_api': fn } = api; fn();",
+ "const note = \"r\";\nlet { 'old_api': fn } = api; fn();",
+ "let { ['old\u005fapi']: fn } = api; fn();",
+)
+
+
+@pytest.mark.parametrize("caller", _DESTRUCTURING_FORMS)
+def test_typescript_destructuring_static_key_blocks_retirement(
+ tmp_path: Path, caller: str
+) -> None:
+ """A static destructuring property read is a reference in every common pattern context."""
+ 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_destructuring",
+ {
+ "service.mjs": "export function new_api() { return 2; }\n",
+ "client.mjs": "import * as api from './service.mjs';\n" + caller + "\n",
+ },
+ )
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "rejected", f"findings: {receipt.findings}"
+ assert any(f.rule_id.startswith("AE002") for f in receipt.findings)
+ assert receipt.retired_symbols == ()
+
+
+_OBJECT_LITERAL_FORMS = (
+ "const config = { 'old_api': 1 };",
+ "const config = { ['old_api']: 1 };",
+ "function f(x) { return x; } f({ 'old_api': 1 });",
+ "function f() { return { 'old_api': 1 }; }",
+ "const xs = [{ 'old_api': 1 }];",
+ "const v = true ? { 'old_api': 1 } : {};",
+ "const v = !{ 'old_api': 1 };",
+ "const v = true && { 'old_api': 1 };",
+ "const config = { 'new_api': 1 };",
+ "export default { 'old_api': 1 };",
+)
+
+
+@pytest.mark.parametrize("body", _OBJECT_LITERAL_FORMS)
+def test_typescript_object_literal_static_key_is_not_reference(
+ tmp_path: Path, body: str
+) -> None:
+ """A clear object-literal key is a definition, not a destructuring read."""
+ 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_object_literal",
+ {
+ "service.mjs": "export function new_api() { return 2; }\n",
+ "client.mjs": "import * as api from './service.mjs';\n" + body + "\n",
+ },
+ )
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "passed", f"findings: {receipt.findings}"
+ assert receipt.retired_symbols == ("old_api",)
+
+
+def test_typescript_ambiguous_static_property_key_reports_ae004(
+ tmp_path: Path,
+) -> None:
+ """A target property key the bounded classifier cannot place must fail closed."""
+ 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_ambiguous_property_key",
+ {
+ "service.mjs": "export function new_api() { return 2; }\n",
+ "client.ts": "class Config { 'old_api': number = 1; }\n",
+ },
+ )
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "rejected", f"findings: {receipt.findings}"
+ ae004 = next(f for f in receipt.findings if f.rule_id.startswith("AE004"))
+ assert "property key" in ae004.message.lower()
+ assert "old_api" not in receipt.retired_symbols
+
+
+def test_typescript_string_import_name_blocks_retirement(tmp_path: Path) -> None:
+ """A static string import name is an executable reference to the exported name."""
+ 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_string_import",
+ {
+ "service.mjs": "export function new_api() { return 2; }\n",
+ "client.mjs": (
+ "import { 'old_api' as fn } from './service.mjs';\n"
+ "console.log(fn());\n"
+ ),
+ },
+ )
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "rejected", f"findings: {receipt.findings}"
+ assert any(f.rule_id.startswith("AE002") for f in receipt.findings)
+ assert receipt.retired_symbols == ()
+
+
+def test_python_nfkc_target_variant_matches_ascii_source(tmp_path: Path) -> None:
+ """A declared NFKC-variant target still matches the ASCII AST identity."""
+ target = "old_\uff41\uff50\uff49"
+ diff = """
+--- a/service.py
++++ b/service.py
+@@ -1,2 +1,2 @@
+-def old_api():
+- return 1
++def new_api():
++ return 2
+"""
+ basis = RetirementBasis(target_symbols=(target,))
+ workspace = _make_workspace(
+ tmp_path / "ws_python_nfkc_target",
+ {
+ "service.py": "def new_api():\n return 2\n",
+ "legacy.py": "def old_api():\n return 3\n",
+ "client.py": "import legacy\nprint(legacy.old_api())\n",
+ },
+ )
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "rejected", f"findings: {receipt.findings}"
+ assert any(f.rule_id.startswith("AE002") for f in receipt.findings)
+ assert receipt.retired_symbols == ()
+
+
+def test_typescript_dynamic_destructuring_key_reports_ae004(tmp_path: Path) -> None:
+ """A computed destructuring key is an executable read we cannot resolve."""
+ 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_dynamic_destructuring",
+ {
+ "service.mjs": "export function new_api() { return 2; }\n",
+ "client.mjs": (
+ "import * as api from './service.mjs';\n"
+ "const { [key]: fn } = api;\nfn();\n"
+ ),
+ },
+ )
+
+ receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace)
+ assert receipt.verdict == "rejected", f"findings: {receipt.findings}"
+ assert any(f.rule_id.startswith("AE004") for f in receipt.findings)
+ assert receipt.retired_symbols == ()
+
+
+@pytest.mark.parametrize(
+ "caller",
+ (
+ "const { ['old_api']: fn } = api; fn();",
+ "const { [`old_api`]: fn } = api; fn();",
+ "const { 'old_api': fn } = api; fn();",
+ "const { ['old'+'_api']: fn } = api; fn();",
+ ),
+)
+def test_typescript_destructuring_real_cli_exit_code(
+ tmp_path: Path, caller: str
+) -> None:
+ """Reviewer-shaped real CLI cases: static destructuring reads reject."""
+ import subprocess
+ import sys
+
+ diff = """
+--- a/service.mjs
++++ b/service.mjs
+@@ -1,2 +1,2 @@
+-export function old_api() { return 1; }
++export function new_api() { return 2; }
+"""
+ workspace = _make_workspace(
+ tmp_path / "ws_destructuring_cli",
+ {
+ "service.mjs": "export function new_api() { return 2; }\n",
+ "client.mjs": "import * as api from './service.mjs';\n" + caller + "\n",
+ },
+ )
+ proc = subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "loopx.cli",
+ "anti-entropy",
+ "verify",
+ "--diff-file",
+ "-",
+ "--basis-json",
+ '{"target_symbols": ["old_api"]}',
+ "--workspace",
+ str(workspace),
+ "--format",
+ "json",
+ ],
+ input=diff.encode("utf-8"),
+ capture_output=True,
+ )
+ assert proc.returncode == 1, proc.stdout + proc.stderr
diff --git a/tests/capabilities/test_anti_entropy_feedback.py b/tests/capabilities/test_anti_entropy_feedback.py
new file mode 100644
index 0000000000..73d5bd5b81
--- /dev/null
+++ b/tests/capabilities/test_anti_entropy_feedback.py
@@ -0,0 +1,1281 @@
+from __future__ import annotations
+
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+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,
+)
+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
++++ 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
+
+
+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
+ )
+ # Disabled turns declare no readback slot: the host payload is untouched.
+ assert res_disabled["readback"] == {}
+ 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"]
+
+
+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. A bare target mapping in a note is ordinary task metadata, never an opt-in.
+ 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"] == {}
+ assert res_note["context_fields"] == {}
+
+@pytest.mark.parametrize(
+ "container",
+ ("params", "metadata", "task_spec", "task_parameters", "config", "options", "foo"),
+)
+def test_generic_container_target_fields_never_authorize_retirement(
+ container: str,
+) -> None:
+ """Only the reserved basis namespace authorizes retirement (review R9 P1)."""
+ payload = {
+ "route": {
+ "selected_todo": {
+ "todo_id": "todo-generic-1",
+ container: {
+ "target_paths": ["src/current.py"],
+ "target_symbols": ["old_api"],
+ "operation": "read_and_summarize",
+ },
+ }
+ }
+ }
+ result = anti_entropy_context_result(payload, goal_id="g1", decision={})
+ assert result["readback"] == {}
+ assert result["context_fields"] == {}
+ assert result["instructions"] == []
+
+
+def test_nested_generic_containers_never_authorize_retirement() -> None:
+ """Deep nesting does not turn generic target fields into a declaration."""
+ payload = {
+ "turn_envelope": {
+ "work_context": {
+ "sources": [
+ {
+ "content": {
+ "todo": {
+ "note": json.dumps(
+ {
+ "params": {
+ "nested": {
+ "metadata": {
+ "target_paths": ["src/current.py"]
+ }
+ }
+ }
+ }
+ )
+ }
+ }
+ }
+ ]
+ }
+ }
+ }
+ result = anti_entropy_context_result(payload, goal_id="g1", decision={})
+ assert result["readback"] == {}
+ assert result["context_fields"] == {}
+
+
+@pytest.mark.parametrize(
+ "container", ("params", "metadata", "task_spec", "task_parameters")
+)
+def test_explicit_namespace_inside_generic_containers_still_authorizes(
+ container: str,
+) -> None:
+ """The reserved namespace remains a valid carrier inside generic containers."""
+ payload = {
+ "route": {
+ "selected_todo": {
+ "todo_id": "todo-ns-1",
+ container: {
+ "retirement_basis": {"target_symbols": ["old_api"]},
+ "target_paths": ["unrelated.py"],
+ },
+ }
+ }
+ }
+ result = anti_entropy_context_result(payload, goal_id="g1", decision={})
+ guidance = result["readback"]["anti_entropy_guidance"]
+ assert guidance["status"] == "active"
+ assert guidance["target_symbols"] == ["old_api"]
+ assert guidance["target_paths"] == []
+
+
+@pytest.mark.parametrize(
+ "payload",
+ (
+ {"anti_entropy": {"target_symbols": ["old_api"]}},
+ {"anti_entropy": {"target_paths": ["legacy.py"]}},
+ {"turn_envelope": {"boundary": {"anti_entropy": {"target_symbols": ["old_api"]}}}},
+ ),
+)
+def test_anti_entropy_wrapper_bare_targets_do_not_authorize(payload: dict) -> None:
+ """The enablement wrapper is not a basis declaration."""
+ result = anti_entropy_context_result(payload, goal_id="g1", decision={})
+ assert result["readback"] == {}
+ assert result["context_fields"] == {}
+
+
+def test_malformed_declared_basis_stays_inert() -> None:
+ """A malformed explicit declaration must not inject partial policy."""
+ payload = {
+ "route": {
+ "selected_todo": {
+ "note": '{"retirement_basis": {"target_symbols": 5}}',
+ }
+ }
+ }
+ result = anti_entropy_context_result(payload, goal_id="g1", decision={})
+ assert result["readback"] == {}
+ assert result["context_fields"] == {}
+
+
+def test_evidence_and_rationale_fields_are_not_activation_carriers() -> None:
+ """Reporting fields do not opt a turn into retirement governance."""
+ declared = {"retirement_basis": {"target_symbols": ["old_api"]}}
+ payload = {
+ "route": {
+ "selected_todo": {
+ "evidence": json.dumps(declared),
+ "rationale": json.dumps(declared),
+ }
+ }
+ }
+ result = anti_entropy_context_result(payload, goal_id="g1", decision={})
+ assert result["readback"] == {}
+ assert result["context_fields"] == {}
+
+
+def test_goal_capability_declaration_does_not_activate(tmp_path: Path) -> None:
+ """Goal availability is not task activation: unrelated turns stay off."""
+ from loopx.capabilities.anti_entropy.turn_context import (
+ _load_goal,
+ anti_entropy_context_registration,
+ )
+ from loopx.control_plane.capability_hooks import dispatch_turn_context_hooks
+
+ registry = tmp_path / "registry.json"
+ registry.write_text(
+ json.dumps(
+ {
+ "version": 1,
+ "goals": [
+ {
+ "id": "g1",
+ "repo": str(tmp_path),
+ "available_capabilities": ["anti-entropy-governance"],
+ }
+ ],
+ }
+ ),
+ encoding="utf-8",
+ )
+ goal = _load_goal(registry, "g1")
+ assert goal is not None
+ assert goal.get("available_capabilities") == ["anti-entropy-governance"]
+
+ payload = {"goal_id": "g1", "agent_id": "a1"}
+ registration = anti_entropy_context_registration(
+ payload,
+ registry_path=registry,
+ 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_shared_turn_context_composition_still_injects_explicit_task(
+ tmp_path: Path,
+) -> None:
+ """Removing the Goal gate must not remove the turn-context hook itself."""
+ from loopx.capabilities.turn_context import attach_turn_context
+
+ payload = {
+ "goal_id": "g1",
+ "agent_id": "a1",
+ "retirement_basis": {"target_symbols": ["old_api"]},
+ "workspace": str(tmp_path),
+ }
+ attach_turn_context(
+ payload,
+ registry_path=tmp_path,
+ goal_id="g1",
+ agent_id="a1",
+ decision={},
+ turn_instance_id="turn-1",
+ )
+ assert payload["anti_entropy_guidance"]["status"] == "active"
+ contributions = payload.get("turn_context_contributions", [])
+ assert any(
+ "anti_entropy_context" in item.get("context_fields", {})
+ for item in contributions
+ )
+
+
+def test_heartbeat_should_run_routes_into_shared_turn_context(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
+) -> None:
+ """The heartbeat production path still calls the shared turn-context owner."""
+ from loopx.cli_commands import quota_reward_memory
+
+ captured: dict[str, object] = {}
+
+ def fake_attach(payload: dict, **kwargs: object) -> None:
+ captured["payload"] = payload
+ captured["kwargs"] = kwargs
+
+ monkeypatch.setattr(quota_reward_memory, "attach_turn_context", fake_attach)
+ payload = {
+ "ok": True,
+ "should_run": True,
+ "heartbeat_receipt": {"status": "committed"},
+ "retirement_basis": {"target_symbols": ["old_api"]},
+ }
+ quota_reward_memory.attach_reward_memory_recall_after_should_run(
+ payload,
+ quota_command="should-run",
+ heartbeat_turn_id="hb-1",
+ registry_path=tmp_path,
+ goal_id="g1",
+ agent_id="a1",
+ )
+ assert captured["payload"] is payload
+ assert captured["kwargs"] == {
+ "registry_path": tmp_path,
+ "goal_id": "g1",
+ "agent_id": "a1",
+ "decision": payload,
+ "turn_instance_id": "hb-1",
+ }
+
+
+def test_heartbeat_without_should_run_does_not_route(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
+) -> None:
+ """The heartbeat routing guard is unchanged by this fix."""
+ from loopx.cli_commands import quota_reward_memory
+
+ called = False
+
+ def fake_attach(payload: dict, **kwargs: object) -> None:
+ nonlocal called
+ called = True
+
+ monkeypatch.setattr(quota_reward_memory, "attach_turn_context", fake_attach)
+ quota_reward_memory.attach_reward_memory_recall_after_should_run(
+ {"ok": True, "should_run": False, "heartbeat_receipt": {"status": "committed"}},
+ quota_command="should-run",
+ heartbeat_turn_id="hb-1",
+ registry_path=tmp_path,
+ goal_id="g1",
+ agent_id="a1",
+ )
+ assert called is False
+
+
+def test_canonical_work_context_todo_without_id_is_selected_carrier() -> None:
+ """The canonical Todo read record can omit todo_id; its note still declares intent."""
+ payload = {
+ "turn_envelope": {
+ "action": {
+ "selected_todo": {
+ "todo_id": "todo-selected",
+ "text": "Replace old_api with new_api and delete the legacy export.",
+ },
+ },
+ "work_context": {
+ "sources": [
+ {
+ "source": "selected_todo",
+ "content": {
+ "todo": {
+ "note": (
+ '{"retirement_basis": '
+ '{"target_symbols": ["old_api"]}}'
+ ),
+ "schema_version": "loopx_coordination_todo_record_v0",
+ "title": "Replace old_api",
+ "updated_at": "2026-01-01T00:00:00Z",
+ }
+ },
+ }
+ ]
+ },
+ }
+ }
+ result = anti_entropy_context_result(payload, goal_id="g1", decision={})
+ guidance = result["readback"]["anti_entropy_guidance"]
+ assert guidance["status"] == "active"
+ assert guidance["target_symbols"] == ["old_api"]
+
+
+def test_stale_recommended_action_from_another_todo_does_not_activate() -> None:
+ """A selected Todo's own text governs; a stale action recommendation does not."""
+ payload = {
+ "turn_envelope": {
+ "action": {
+ "recommended_action": (
+ "Replace old_api with new_api. "
+ '{"retirement_basis": {"target_symbols": ["old_api"]}}'
+ ),
+ "selected_todo": {
+ "todo_id": "todo-later",
+ "text": "Summarize the remaining files without retiring anything.",
+ },
+ }
+ }
+ }
+ result = anti_entropy_context_result(payload, goal_id="g1", decision={})
+ assert result["readback"] == {}
+ assert result["context_fields"] == {}
+
+
+def test_unselected_sibling_todo_basis_does_not_activate_selected_todo() -> None:
+ """Only the selected Todo's record contributes intent to this turn."""
+ payload = {
+ "turn_envelope": {
+ "action": {
+ "selected_todo": {
+ "todo_id": "todo-selected",
+ "text": "Summarize the remaining files without retiring anything.",
+ },
+ },
+ "work_context": {
+ "sources": [
+ {
+ "content": {
+ "todo": {
+ "todo_id": "todo-retirement",
+ "note": (
+ '{"retirement_basis": '
+ '{"target_symbols": ["old_api"]}}'
+ ),
+ }
+ }
+ }
+ ]
+ },
+ }
+ }
+ result = anti_entropy_context_result(payload, goal_id="g1", decision={})
+ assert result["readback"] == {}
+ assert result["context_fields"] == {}
+
+
+def test_inspect_ambiguous_property_key_reports_actionable_ae004(tmp_path: Path) -> None:
+ """AE004 ambiguity carries a concrete next action, not just a generic block."""
+ diff = """
+--- a/service.mjs
++++ b/service.mjs
+@@ -1,2 +1,2 @@
+-export function old_api() { return 1; }
++export function new_api() { return 2; }
+"""
+ workspace = tmp_path / "ws_ambiguous_feedback"
+ workspace.mkdir(parents=True, exist_ok=True)
+ (workspace / "service.mjs").write_text(
+ "export function new_api() { return 2; }\n", encoding="utf-8"
+ )
+ (workspace / "client.ts").write_text(
+ "class Config { 'old_api': number = 1; }\n", encoding="utf-8"
+ )
+ result = run_anti_entropy_feedback_cycle(
+ diff_text=diff,
+ basis=RetirementBasis(target_symbols=("old_api",)),
+ workspace=workspace,
+ )
+ assert result["verdict"] == "rejected"
+ ae004 = next(
+ item for item in result["actionable_feedback"] if item["rule_id"].startswith("AE004")
+ )
+ assert "property key" in ae004["message"].lower()
+ assert "object-literal" in ae004["next_action"].lower()
+ assert "destructuring" in ae004["next_action"].lower()
+
+
+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"]
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..66317aa558
--- /dev/null
+++ b/tests/capabilities/test_anti_entropy_native_host.py
@@ -0,0 +1,394 @@
+"""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
+
+
+def _prepare_goal(
+ tmp_path: Path,
+ goal_id: str,
+ *,
+ available_capabilities: tuple[str, ...] = (),
+) -> tuple[Path, Path]:
+ fixture = _load_canonical_fixture()
+ registry, runtime, _state = fixture.promoted_create_fixture(
+ tmp_path, goal_id=goal_id, registered_agents=["codex-fixture"]
+ )
+ 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"},
+ }
+ )
+ if available_capabilities:
+ registry_payload["goals"][0]["available_capabilities"] = list(
+ available_capabilities
+ )
+ registry.write_text(json.dumps(registry_payload), encoding="utf-8")
+ return registry, runtime
+
+
+def _add_todo(
+ registry: Path,
+ runtime: Path,
+ goal_id: str,
+ *,
+ text: str,
+ note: str,
+) -> str:
+ added = _run_cli(
+ registry,
+ runtime,
+ "todo",
+ "add",
+ "--goal-id",
+ goal_id,
+ "--role",
+ "agent",
+ "--text",
+ text,
+ "--note",
+ note,
+ "--execute",
+ "--format",
+ "json",
+ )
+ assert added.returncode == 0, added.stdout + added.stderr
+ payload = json.loads(added.stdout)
+ assert payload["status"] == "applied", payload
+ return str(payload["todo_id"])
+
+
+def _make_capture_host(tmp_path: Path) -> tuple[Path, Path]:
+ 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)
+ return capture, fake_codex
+
+
+def _capture_turn_stdin(
+ registry: Path,
+ runtime: Path,
+ goal_id: str,
+ *,
+ todo_id: str | None,
+ capture: Path,
+ fake_codex: Path,
+ project: Path,
+) -> str:
+ args = [
+ "turn",
+ "run-once",
+ "--goal-id",
+ goal_id,
+ "--agent-id",
+ "codex-fixture",
+ "--host",
+ "codex-cli",
+ "--project",
+ str(project),
+ "--codex-bin",
+ str(fake_codex),
+ "--timeout-seconds",
+ "30",
+ "--execute",
+ "--format",
+ "json",
+ ]
+ if todo_id is not None:
+ args += ["--todo-id", todo_id]
+ capture.unlink(missing_ok=True)
+ turn = _run_cli(registry, runtime, *args)
+ payload = json.loads(turn.stdout)
+ assert payload["effects"]["host_invoked"] is True, turn.stdout + turn.stderr
+ assert payload["result_kind"] == "host_failure", turn.stdout + turn.stderr
+ assert capture.is_file(), turn.stdout + turn.stderr
+ return capture.read_text(encoding="utf-8", errors="replace")
+
+
+@pytest.mark.skipif(
+ sys.platform == "win32",
+ reason="the synthetic Host fixture is a POSIX shell script",
+)
+def test_native_turn_activation_scope_matrix(tmp_path: Path) -> None:
+ """Ordinary tasks stay off; explicit retirement stays on; scope is per-Todo."""
+ if shutil.which("node") is None:
+ pytest.skip("canonical File authority fixture requires node")
+
+ goal_id = "anti-entropy-native-scope"
+ registry, runtime = _prepare_goal(
+ tmp_path,
+ goal_id,
+ available_capabilities=("anti-entropy-governance",),
+ )
+ capture, fake_codex = _make_capture_host(tmp_path)
+ project = tmp_path / "project"
+
+ plain_text = (
+ "Check these files and summarize their responsibilities; "
+ "do not retire symbols or delete files."
+ )
+ ordinary_note = json.dumps(
+ {
+ "params": {
+ "target_paths": ["src/current.py"],
+ "operation": "read_and_summarize",
+ }
+ }
+ )
+ plain_todo = _add_todo(
+ registry, runtime, goal_id, text=plain_text, note=ordinary_note
+ )
+ host_input = _capture_turn_stdin(
+ registry,
+ runtime,
+ goal_id,
+ todo_id=plain_todo,
+ capture=capture,
+ fake_codex=fake_codex,
+ project=project,
+ )
+ assert '"anti_entropy_context"' not in host_input
+ assert "Anti-entropy code governance is active" not in host_input
+ assert "delete-first" not in host_input.lower()
+ assert "anti_entropy_guidance" not in host_input
+ assert plain_text[:20] in host_input
+
+ symbol_note = json.dumps({"params": {"target_symbols": ["old_api"]}})
+ symbol_todo = _add_todo(
+ registry,
+ runtime,
+ goal_id,
+ text="Inspect src/current.py and list its exported symbols; do not retire anything.",
+ note=symbol_note,
+ )
+ host_input = _capture_turn_stdin(
+ registry,
+ runtime,
+ goal_id,
+ todo_id=symbol_todo,
+ capture=capture,
+ fake_codex=fake_codex,
+ project=project,
+ )
+ assert '"anti_entropy_context"' not in host_input
+ assert "Anti-entropy code governance is active" not in host_input
+
+ basis = {"retirement_basis": {"target_symbols": ["old_api"]}}
+ retirement_todo = _add_todo(
+ registry,
+ runtime,
+ goal_id,
+ text="Replace old_api with new_api and delete the legacy export. "
+ + json.dumps(basis),
+ note=json.dumps(basis),
+ )
+ host_input = _capture_turn_stdin(
+ registry,
+ runtime,
+ goal_id,
+ todo_id=retirement_todo,
+ capture=capture,
+ fake_codex=fake_codex,
+ project=project,
+ )
+ assert "Anti-entropy code governance is active for this task" in host_input
+ assert '"anti_entropy_context"' in host_input
+ assert "loopx anti-entropy inspect" in host_input
+
+ note_only_todo = _add_todo(
+ registry,
+ runtime,
+ goal_id,
+ text="Replace old_api with new_api and delete the legacy export.",
+ note=json.dumps(basis),
+ )
+ host_input = _capture_turn_stdin(
+ registry,
+ runtime,
+ goal_id,
+ todo_id=note_only_todo,
+ capture=capture,
+ fake_codex=fake_codex,
+ project=project,
+ )
+ assert "Anti-entropy code governance is active for this task" in host_input
+ assert '"anti_entropy_context"' in host_input
+ assert '"old_api"' in host_input
+
+ later_todo = _add_todo(
+ registry,
+ runtime,
+ goal_id,
+ text="Summarize the remaining files without retiring anything.",
+ note=ordinary_note,
+ )
+ host_input = _capture_turn_stdin(
+ registry,
+ runtime,
+ goal_id,
+ todo_id=later_todo,
+ capture=capture,
+ fake_codex=fake_codex,
+ project=project,
+ )
+ assert '"anti_entropy_context"' not in host_input
+ assert "Anti-entropy code governance is active" not in host_input
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",
]