From 70387cbea309342778b8c0aa1f27eb29777436c6 Mon Sep 17 00:00:00 2001 From: LoopDesk Date: Wed, 28 Oct 2026 11:24:53 +0800 Subject: [PATCH 01/21] feat(capability): introduce opt-in anti-entropy code governance Signed-off-by: LoopDesk --- examples/anti-entropy-governance-smoke.py | 102 +++++ loopx/capabilities/anti_entropy/README.md | 71 +++ loopx/capabilities/anti_entropy/__init__.py | 31 ++ .../anti_entropy/catalog_entry.py | 82 ++++ loopx/capabilities/anti_entropy/cli.py | 38 ++ loopx/capabilities/anti_entropy/contract.py | 144 +++++++ loopx/capabilities/anti_entropy/oracles.py | 378 ++++++++++++++++ loopx/capabilities/catalog.py | 2 + loopx/cli.py | 16 + loopx/cli_commands/anti_entropy.py | 127 ++++++ tests/capabilities/test_anti_entropy.py | 403 ++++++++++++++++++ .../test_capability_extension_registry.py | 1 + 12 files changed, 1395 insertions(+) create mode 100755 examples/anti-entropy-governance-smoke.py create mode 100644 loopx/capabilities/anti_entropy/README.md create mode 100644 loopx/capabilities/anti_entropy/__init__.py create mode 100644 loopx/capabilities/anti_entropy/catalog_entry.py create mode 100644 loopx/capabilities/anti_entropy/cli.py create mode 100644 loopx/capabilities/anti_entropy/contract.py create mode 100644 loopx/capabilities/anti_entropy/oracles.py create mode 100644 loopx/cli_commands/anti_entropy.py create mode 100644 tests/capabilities/test_anti_entropy.py diff --git a/examples/anti-entropy-governance-smoke.py b/examples/anti-entropy-governance-smoke.py new file mode 100755 index 0000000000..0f8ab206b4 --- /dev/null +++ b/examples/anti-entropy-governance-smoke.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +"""Smoke test for anti-entropy governance: AST-based delete-first verification and diff hygiene.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) + +from loopx.capabilities.anti_entropy.cli import run_anti_entropy_verify +from loopx.capabilities.anti_entropy.contract import ( + ANTI_ENTROPY_CAPABILITY_ID, + RetirementBasis, +) +from loopx.capabilities.anti_entropy.oracles import evaluate_diff_hygiene + + +def test_clean_delete_first() -> None: + diff = """ +--- a/calculator.py ++++ b/calculator.py +@@ -1,5 +1,5 @@ +-def old_add(a: int, b: int) -> int: +- return a + b ++def new_add(a: int, b: int) -> int: ++ return a + b +""" + basis = RetirementBasis( + target_symbols=("old_add",), + rationale="Replaced by new_add", + ) + receipt = evaluate_diff_hygiene(diff, basis) + assert receipt.verdict == "passed", f"expected passed, got {receipt.verdict}" + assert "old_add" in receipt.retired_symbols + + +def test_delete_first_violation() -> None: + diff = """ +--- a/calculator.py ++++ b/calculator.py +@@ -1,3 +1,5 @@ ++def new_add(a: int, b: int) -> int: ++ return a + b + def old_add(a: int, b: int) -> int: + return a + b +""" + basis = RetirementBasis( + target_symbols=("old_add",), + allow_compat_seam=False, + ) + receipt = evaluate_diff_hygiene(diff, basis) + assert receipt.verdict == "rejected", f"expected rejected, got {receipt.verdict}" + assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt.findings) + + +def test_zombie_reintroduced() -> None: + diff = """ +--- a/calculator.py ++++ b/calculator.py +@@ -1,3 +1,3 @@ +-def legacy_sink(): ++def legacy_sink(): # wrapper + pass +""" + basis = RetirementBasis(target_symbols=("legacy_sink",)) + receipt = evaluate_diff_hygiene(diff, basis) + assert receipt.verdict == "rejected", f"expected rejected, got {receipt.verdict}" + assert any(f.rule_id == "AE002-ZOMBIE_SYMBOL_REINTRODUCED" for f in receipt.findings) + + +def test_cli_runner() -> None: + diff = """ +--- a/module.py ++++ b/module.py +@@ -1,2 +1,2 @@ +-class OldService: ++class NewService: + pass +""" + payload = run_anti_entropy_verify( + diff_text=diff, + basis_data={"target_symbols": ["OldService"]}, + goal_id="test-goal-1", + ) + assert payload["ok"] is True + assert payload["capability_id"] == ANTI_ENTROPY_CAPABILITY_ID + assert "OldService" in payload["retired_symbols"] + + +def main() -> int: + test_clean_delete_first() + test_delete_first_violation() + test_zombie_reintroduced() + test_cli_runner() + print("anti-entropy-governance-smoke: ok") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/loopx/capabilities/anti_entropy/README.md b/loopx/capabilities/anti_entropy/README.md new file mode 100644 index 0000000000..5abac687b4 --- /dev/null +++ b/loopx/capabilities/anti_entropy/README.md @@ -0,0 +1,71 @@ +# Anti-Entropy & Code Retirement Governance + +Anti-Entropy Governance provides LoopX with an automated, deterministic +code-retirement and diff-hygiene verification contract. It is **default-off** +and designed to support agent-driven refactoring and feature decommissioning workflows. + +## Overview + +When autonomous agents perform refactoring or feature replacement tasks, they +frequently exhibit an "entropy growth" anti-pattern: layering yet another wrapper +or fallback branch without safely deleting superseded implementations. Over +long-horizon tasks, this results in accumulated ghost code, redundant complexity, +and bloated execution contexts. + +The Anti-Entropy capability addresses this through: + +1. **AST-Based Delete-First Enforcement (`verify_delete_first`)**: + Uses Python's syntax tree parser to verify that declared retirement targets + (functions and classes) are genuinely removed from the codebase rather than + merely bypassed or wrapped. +2. **Zombie Symbol Prevention**: + Detects if a symbol marked for retirement is reintroduced as a new definition + in newly added code lines. +3. **Target Paths Verification**: + Ensures that declared legacy file paths slated for deletion are completely + removed in the unified diff (`/dev/null`). +4. **Ghost Code Auditing (`audit_ghost_code`)**: + Identifies commented-out code blocks left behind, surfacing them as advisory + quality findings without blocking legitimate documentation or explanatory comments. +5. **Durable Receipt Generation (`AntiEntropyReceipt`)**: + Runs deterministic hygiene oracles against the exact git diff and produces + an immutable receipt recording verified retired symbols and findings. + +## Attribution & Acknowledgments + +This capability adopts and operationalizes the core engineering principles of +**delete-first retirement**, **ghost code prevention**, and **minimality verification** +inspired by the **Aegis** engineering methodology. + +## Usage + +Inspect the capability and its registered protocols: + +```bash +loopx capability show anti-entropy-governance --format json +``` + +Verify diff hygiene against a declared retirement basis: + +```bash +loopx anti-entropy verify --goal-id --diff-file --basis-json --format json +``` + +Example `basis.json`: + +```json +{ + "target_symbols": ["old_deprecated_calculator"], + "target_paths": ["legacy/old_module.py"], + "allow_compat_seam": false, + "rationale": "Superseded by modern_calculator implementation" +} +``` + +## Boundaries + +- The capability is default-off and operates as an opt-in code hygiene verifier via CLI and typed receipts. +- Delete-first requires target old logic to be cleanly decommissioned in the diff without re-introducing wrappers or zombie symbols. +- AST-based diff hygiene verifies exact code diffs without mutating repository code or performing speculative git writes. +- Target paths declared for decommissioning must be cleanly removed in the unified diff. +- Advisory findings highlight non-blocking hygiene opportunities (such as dead commented code) without causing false positive rejections of documentation or explanations. diff --git a/loopx/capabilities/anti_entropy/__init__.py b/loopx/capabilities/anti_entropy/__init__.py new file mode 100644 index 0000000000..5b004cef88 --- /dev/null +++ b/loopx/capabilities/anti_entropy/__init__.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from .catalog_entry import ANTI_ENTROPY_CATALOG_ENTRY +from .cli import run_anti_entropy_verify +from .contract import ( + ANTI_ENTROPY_CAPABILITY_ID, + ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION, + ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION, + AntiEntropyFinding, + AntiEntropyReceipt, + RetirementBasis, +) +from .oracles import ( + audit_ghost_code, + evaluate_diff_hygiene, + verify_delete_first, +) + +__all__ = [ + "ANTI_ENTROPY_CAPABILITY_ID", + "ANTI_ENTROPY_CATALOG_ENTRY", + "ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION", + "ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION", + "AntiEntropyFinding", + "AntiEntropyReceipt", + "RetirementBasis", + "audit_ghost_code", + "evaluate_diff_hygiene", + "run_anti_entropy_verify", + "verify_delete_first", +] diff --git a/loopx/capabilities/anti_entropy/catalog_entry.py b/loopx/capabilities/anti_entropy/catalog_entry.py new file mode 100644 index 0000000000..b3b1858a3a --- /dev/null +++ b/loopx/capabilities/anti_entropy/catalog_entry.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from typing import Any + +from .contract import ( + ANTI_ENTROPY_CAPABILITY_ID, + ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION, + ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION, +) + +_README = "loopx/capabilities/anti_entropy/README.md" + +ANTI_ENTROPY_CATALOG_ENTRY: dict[str, Any] = { + "id": ANTI_ENTROPY_CAPABILITY_ID, + "origin": "builtin", + "visibility": "public", + "provider_id": "loopx-core", + "documentation": { + "source_root": "loopx/capabilities/anti_entropy", + "site_root": "capabilities/anti-entropy-governance", + "canonical": "README.md", + }, + "title": "Anti-entropy and code retirement governance", + "status": "experimental", + "default_enabled": False, + "real_world_anchor": ( + "provider-neutral AST-based delete-first verification and diff hygiene for agent refactoring" + ), + "user_value": ( + "Verify that declared retirement targets are cleanly deleted without " + "re-introducing wrappers, zombie symbols, or ghost code." + ), + "entry_command": ( + "loopx anti-entropy verify --goal-id --diff-file --format json" + ), + "workflow_skill": { + "name": "loopx-anti-entropy", + "delivery": "standalone_cli", + "activation": "explicit_command_execution", + "project_copy_required": False, + }, + "commands": [ + { + "command": "loopx anti-entropy verify --goal-id --diff-file --format json", + "purpose": "Verify exact diff hygiene and delete-first compliance against a declared retirement basis.", + "write_boundary": "read-only diff verification and receipt generation; no repository mutation", + }, + { + "command": "loopx capability show anti-entropy-governance --format json", + "purpose": "Inspect anti-entropy capability metadata, commands, and protocols.", + "write_boundary": "read-only metadata projection", + }, + ], + "implemented_protocols": [ + { + "schema_version": ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION, + "module": "loopx.capabilities.anti_entropy.contract", + "doc": _README, + }, + { + "schema_version": ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION, + "module": "loopx.capabilities.anti_entropy.contract", + "doc": _README, + }, + ], + "smokes": [ + "python3 examples/anti-entropy-governance-smoke.py", + "python3 -m pytest tests/capabilities/test_anti_entropy.py -q", + ], + "docs": [_README], + "boundaries": [ + "The capability is default-off and operates as an opt-in code hygiene verifier via CLI and typed receipts.", + "Delete-first requires target old logic to be cleanly decommissioned in the diff without re-introducing wrappers or zombie symbols.", + "AST-based diff hygiene verifies exact code diffs without mutating repository code or performing speculative git writes.", + "Target paths declared for decommissioning must be cleanly removed in the unified diff.", + "Advisory findings highlight non-blocking hygiene opportunities (such as dead commented code) without causing false positive rejections of documentation or explanations.", + "Inspired by and adopting core engineering principles of delete-first code retirement from Aegis.", + ], + "next_real_step": ( + "Run verification: loopx anti-entropy verify --diff-file --basis-json " + ), +} diff --git a/loopx/capabilities/anti_entropy/cli.py b/loopx/capabilities/anti_entropy/cli.py new file mode 100644 index 0000000000..883ec0eb9a --- /dev/null +++ b/loopx/capabilities/anti_entropy/cli.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from .contract import ( + ANTI_ENTROPY_CAPABILITY_ID, + RetirementBasis, +) +from .oracles import evaluate_diff_hygiene + + +def run_anti_entropy_verify( + *, + diff_text: str, + basis_data: Any = None, + goal_id: str | None = None, +) -> dict[str, Any]: + """Verify exact diff hygiene and delete-first compliance, returning structured payload.""" + basis = RetirementBasis.from_dict(basis_data) if basis_data is not None else None + receipt = evaluate_diff_hygiene(diff_text, basis) + + blocking_count = sum(1 for f in receipt.findings if f.severity == "blocking") + advisory_count = sum(1 for f in receipt.findings if f.severity == "advisory") + + return { + "ok": receipt.verdict == "passed", + "command": "verify", + "capability_id": ANTI_ENTROPY_CAPABILITY_ID, + "goal_id": goal_id, + "verdict": receipt.verdict, + "receipt": receipt.to_dict(), + "finding_count": len(receipt.findings), + "blocking_finding_count": blocking_count, + "advisory_finding_count": advisory_count, + "retired_symbols": list(receipt.retired_symbols), + } diff --git a/loopx/capabilities/anti_entropy/contract.py b/loopx/capabilities/anti_entropy/contract.py new file mode 100644 index 0000000000..0c94c60bb8 --- /dev/null +++ b/loopx/capabilities/anti_entropy/contract.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import hashlib +import time +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any + +ANTI_ENTROPY_CAPABILITY_ID = "anti-entropy-governance" +ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION = "loopx_anti_entropy_receipt_v0" +ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION = "loopx_anti_entropy_retirement_basis_v0" + + +@dataclass(frozen=True, slots=True) +class RetirementBasis: + """Declared code symbols and paths slated for safe decommissioning.""" + + schema_version: str = ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION + target_symbols: tuple[str, ...] = () + target_paths: tuple[str, ...] = () + allow_compat_seam: bool = False + rationale: str = "" + + def to_dict(self) -> dict[str, Any]: + return { + "schema_version": self.schema_version, + "target_symbols": list(self.target_symbols), + "target_paths": list(self.target_paths), + "allow_compat_seam": self.allow_compat_seam, + "rationale": self.rationale, + } + + @classmethod + def from_dict(cls, data: Any) -> RetirementBasis: + if not isinstance(data, Mapping): + raise TypeError( + f"RetirementBasis data must be a JSON object (mapping), got {type(data).__name__}" + ) + + schema_version = data.get("schema_version") + if schema_version is not None and not isinstance(schema_version, str): + raise TypeError( + f"schema_version must be a string, got {type(schema_version).__name__}" + ) + + symbols_raw = data.get("target_symbols") + target_symbols: list[str] = [] + if symbols_raw is not None: + if not isinstance(symbols_raw, (list, tuple, set)): + raise TypeError( + f"target_symbols must be a list of strings, got {type(symbols_raw).__name__}" + ) + for s in symbols_raw: + if not isinstance(s, str): + raise TypeError( + f"target_symbols elements must be strings, got {type(s).__name__}" + ) + clean_s = s.strip() + if clean_s: + target_symbols.append(clean_s) + + paths_raw = data.get("target_paths") + target_paths: list[str] = [] + if paths_raw is not None: + if not isinstance(paths_raw, (list, tuple, set)): + raise TypeError( + f"target_paths must be a list of strings, got {type(paths_raw).__name__}" + ) + for p in paths_raw: + if not isinstance(p, str): + raise TypeError( + f"target_paths elements must be strings, got {type(p).__name__}" + ) + clean_p = p.strip() + if clean_p: + target_paths.append(clean_p) + + allow_compat = data.get("allow_compat_seam", False) + if not isinstance(allow_compat, bool): + raise TypeError( + f"allow_compat_seam must be a boolean, got {type(allow_compat).__name__} (value: {allow_compat!r})" + ) + + rationale = data.get("rationale", "") + if rationale is not None and not isinstance(rationale, str): + raise TypeError( + f"rationale must be a string, got {type(rationale).__name__}" + ) + + return cls( + schema_version=str( + schema_version or ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION + ), + target_symbols=tuple(target_symbols), + target_paths=tuple(target_paths), + allow_compat_seam=allow_compat, + rationale=str(rationale or ""), + ) + + +@dataclass(frozen=True, slots=True) +class AntiEntropyFinding: + """One actionable defect where entropy or ghost code was observed.""" + + rule_id: str + severity: str # "blocking" | "advisory" + message: str + location: str | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "rule_id": self.rule_id, + "severity": self.severity, + "message": self.message, + "location": self.location, + } + + +@dataclass(frozen=True, slots=True) +class AntiEntropyReceipt: + """Durable receipt verifying diff hygiene and delete-first enforcement.""" + + schema_version: str = ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION + receipt_id: str = field(default="") + diff_hash: str = field(default="") + verdict: str = "passed" # "passed" | "rejected" + findings: tuple[AntiEntropyFinding, ...] = () + retired_symbols: tuple[str, ...] = () + timestamp_utc: float = field(default_factory=time.time) + + def to_dict(self) -> dict[str, Any]: + receipt_id = self.receipt_id + if not receipt_id: + payload = f"{self.diff_hash}:{self.verdict}:{self.timestamp_utc}" + receipt_id = f"aer_{hashlib.sha256(payload.encode()).hexdigest()[:16]}" + return { + "schema_version": self.schema_version, + "receipt_id": receipt_id, + "diff_hash": self.diff_hash, + "verdict": self.verdict, + "findings": [f.to_dict() for f in self.findings], + "retired_symbols": list(self.retired_symbols), + "timestamp_utc": self.timestamp_utc, + } diff --git a/loopx/capabilities/anti_entropy/oracles.py b/loopx/capabilities/anti_entropy/oracles.py new file mode 100644 index 0000000000..f41b9b00dd --- /dev/null +++ b/loopx/capabilities/anti_entropy/oracles.py @@ -0,0 +1,378 @@ +from __future__ import annotations + +import ast +import codecs +import hashlib +import re +import textwrap +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from .contract import ( + AntiEntropyFinding, + AntiEntropyReceipt, + RetirementBasis, +) + +_DEF_OR_CLASS_RE = re.compile(r"^\s*(?:async\s+def|def|class)\s+([A-Za-z_][A-Za-z0-9_]*)") +_COMMENT_LINE_RE = re.compile(r"^\s*(?:#|//)\s*(.+)$") + + +def _hash_diff(diff_text: str) -> str: + return hashlib.sha256(diff_text.encode("utf-8")).hexdigest() + + +def unquote_git_path(path_str: str) -> str: + """Unquote and normalize Git C-style quoted paths with octal or hex escapes.""" + path_str = path_str.strip() + if path_str.startswith('"') and path_str.endswith('"') and len(path_str) >= 2: + inner = path_str[1:-1] + try: + raw_bytes = codecs.escape_decode(inner.encode("latin1"))[0] + path_str = raw_bytes.decode("utf-8") + except Exception: + path_str = inner + path_str = path_str.replace("\\", "/").strip() + if path_str.startswith("a/") or path_str.startswith("b/"): + path_str = path_str[2:] + return path_str.lstrip("./").lstrip("/") + + +def _extract_deleted_paths(diff_text: str) -> set[str]: + deleted_paths: set[str] = set() + diff_text = textwrap.dedent(diff_text) + + # 1. Parse sections starting with diff --git + diff_sections = re.split(r"(?=^diff --git )", diff_text, flags=re.MULTILINE) + for sec in diff_sections: + if not sec.startswith("diff --git "): + continue + header_match = re.match(r"^diff --git\s+(.+?)\s+(.+?)(?:\n|$)", sec) + if not header_match: + continue + src_raw = header_match.group(1).strip() + is_deleted = bool( + re.search(r"^deleted file mode \d+", sec, re.MULTILINE) + or re.search(r"^\+\+\+\s+/dev/null", sec, re.MULTILINE) + ) + if is_deleted: + clean_path = unquote_git_path(src_raw) + if clean_path: + deleted_paths.add(clean_path) + + # 2. Also match standard --- \n+++ /dev/null for diffs without diff --git + for match in re.finditer(r"^---\s+(.+?)\n\+\+\+\s+/dev/null", diff_text, re.MULTILINE): + raw_path = match.group(1).strip() + clean_path = unquote_git_path(raw_path) + if clean_path: + deleted_paths.add(clean_path) + + return deleted_paths + + +def _extract_defs_from_code(code: str) -> set[str]: + if not code.strip(): + return set() + dedented = textwrap.dedent(code) + try: + tree = ast.parse(dedented) + return { + node.name + for node in ast.walk(tree) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) + } + except SyntaxError: + try: + wrapped = "def _wrapper_():\n" + textwrap.indent(dedented, " ") + tree = ast.parse(wrapped) + return { + node.name + for node in ast.walk(tree) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) + and node.name != "_wrapper_" + } + except SyntaxError: + return set() + + +def _extract_signature_defs(lines: list[str]) -> set[str]: + defs: set[str] = set() + for line in lines: + m = _DEF_OR_CLASS_RE.match(line) + if m: + name = m.group(1) + try: + tree = ast.parse(f"{line.strip()}\n pass") + for node in ast.iter_child_nodes(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + if node.name == name: + defs.add(name) + except SyntaxError: + pass + return defs + + +def _analyze_diff_symbols(diff_text: str) -> tuple[set[str], set[str], set[str]]: + deleted_symbols: set[str] = set() + reintroduced_symbols: set[str] = set() + still_defined_on_disk: set[str] = set() + + diff_text = textwrap.dedent(diff_text) + file_diffs = re.split(r"(?=^diff --git |^--- )", diff_text, flags=re.MULTILINE) + for f_diff in file_diffs: + if not f_diff.strip(): + continue + + file_path = "" + m_git = re.search(r"^diff --git\s+(.+?)\s+(.+?)(?:\n|$)", f_diff, re.MULTILINE) + if m_git: + file_path = unquote_git_path(m_git.group(2).strip()) + else: + m_patch = re.search(r"^\+\+\+\s+(.+?)(?:\n|$)", f_diff, re.MULTILINE) + if m_patch: + file_path = unquote_git_path(m_patch.group(1).strip()) + + hunk_texts = re.split(r"(?=^@@\s+-\d+.*?\s+\+\d+.*?\s+@@)", f_diff, flags=re.MULTILINE) + for h_text in hunk_texts: + if not h_text.startswith("@@"): + continue + + lines = h_text.splitlines()[1:] + pre_lines: list[str] = [] + post_lines: list[str] = [] + deleted_lines: list[str] = [] + added_lines: list[str] = [] + + for line in lines: + if line.startswith(" "): + pre_lines.append(line[1:]) + post_lines.append(line[1:]) + elif line.startswith("-") and not line.startswith("---"): + pre_lines.append(line[1:]) + deleted_lines.append(line[1:]) + elif line.startswith("+") and not line.startswith("+++"): + post_lines.append(line[1:]) + added_lines.append(line[1:]) + + pre_defs = _extract_defs_from_code("\n".join(pre_lines)) + post_defs = _extract_defs_from_code("\n".join(post_lines)) + added_defs = _extract_defs_from_code("\n".join(added_lines)) | _extract_signature_defs(added_lines) + deleted_defs = _extract_defs_from_code("\n".join(deleted_lines)) | _extract_signature_defs(deleted_lines) + + for s in pre_defs: + if s not in post_defs: + deleted_symbols.add(s) + + for s in deleted_defs: + if s not in post_defs and s not in added_defs: + deleted_symbols.add(s) + + for s in added_defs: + reintroduced_symbols.add(s) + for s in post_defs: + if s in added_defs or s in deleted_defs: + reintroduced_symbols.add(s) + + if file_path and file_path != "/dev/null": + disk_p = Path(file_path) + if disk_p.is_file(): + try: + content = disk_p.read_text(encoding="utf-8") + disk_defs = _extract_defs_from_code(content) + for s in disk_defs: + still_defined_on_disk.add(s) + if s in deleted_symbols: + deleted_symbols.remove(s) + except Exception: + pass + + return deleted_symbols, reintroduced_symbols, still_defined_on_disk + + +def verify_delete_first( + diff_text: str, + retirement_basis: RetirementBasis | Mapping[str, Any] | None = None, +) -> list[AntiEntropyFinding]: + findings: list[AntiEntropyFinding] = [] + + if retirement_basis is None: + return findings + + basis = ( + retirement_basis + if isinstance(retirement_basis, RetirementBasis) + else RetirementBasis.from_dict(retirement_basis) + ) + + if not basis.target_symbols and not basis.target_paths: + return findings + + diff_text = textwrap.dedent(diff_text) + if not diff_text.strip(): + for symbol in basis.target_symbols: + findings.append( + AntiEntropyFinding( + rule_id="AE001-DELETE_FIRST_VIOLATION", + severity="blocking" if not basis.allow_compat_seam else "advisory", + message=( + f"Target symbol '{symbol}' was declared for retirement but diff is empty. " + "Delete-first requires explicit removal of superseded logic." + ), + location=symbol, + ) + ) + for path in basis.target_paths: + findings.append( + AntiEntropyFinding( + rule_id="AE001-DELETE_FIRST_VIOLATION", + severity="blocking" if not basis.allow_compat_seam else "advisory", + message=( + f"Target path '{path}' was declared for retirement but diff is empty." + ), + location=path, + ) + ) + return findings + + deleted_symbols, reintroduced_symbols, still_defined_on_disk = _analyze_diff_symbols(diff_text) + deleted_paths = _extract_deleted_paths(diff_text) + + for symbol in basis.target_symbols: + if symbol in reintroduced_symbols or symbol in still_defined_on_disk: + findings.append( + AntiEntropyFinding( + rule_id="AE002-ZOMBIE_SYMBOL_REINTRODUCED", + severity="blocking", + message=( + f"Target symbol '{symbol}' was slated for retirement but reintroduced " + "or retained as an active definition in the diff or repository." + ), + location=symbol, + ) + ) + elif symbol not in deleted_symbols: + findings.append( + AntiEntropyFinding( + rule_id="AE001-DELETE_FIRST_VIOLATION", + severity="blocking" if not basis.allow_compat_seam else "advisory", + message=( + f"Target symbol '{symbol}' was declared for retirement in RetirementBasis " + "but its definition was not removed from the diff. Delete-first requires deleting " + "superseded definitions rather than merely removing calls, altering docs, or adding wrappers." + ), + location=symbol, + ) + ) + + for path in basis.target_paths: + norm_path = unquote_git_path(path) + if norm_path not in deleted_paths: + findings.append( + AntiEntropyFinding( + rule_id="AE001-DELETE_FIRST_VIOLATION", + severity="blocking" if not basis.allow_compat_seam else "advisory", + message=( + f"Target path '{path}' was declared for retirement in RetirementBasis " + "but was not removed in the diff." + ), + location=path, + ) + ) + + return findings + + +def audit_ghost_code(diff_text: str) -> list[AntiEntropyFinding]: + findings: list[AntiEntropyFinding] = [] + consecutive_commented_code = 0 + start_line = "" + + diff_text = textwrap.dedent(diff_text) + for line in diff_text.splitlines(): + if not line.startswith("+") or line.startswith("+++"): + consecutive_commented_code = 0 + continue + + raw = line[1:].strip() + comment_match = _COMMENT_LINE_RE.match(raw) + if comment_match: + comment_content = comment_match.group(1).strip() + is_code_stmt = False + if _DEF_OR_CLASS_RE.match(comment_content): + is_code_stmt = True + else: + try: + ast.parse(comment_content) + is_code_stmt = True + except SyntaxError: + is_code_stmt = False + + if is_code_stmt: + consecutive_commented_code += 1 + if consecutive_commented_code == 1: + start_line = raw + else: + consecutive_commented_code = 0 + else: + consecutive_commented_code = 0 + + if consecutive_commented_code >= 3: + findings.append( + AntiEntropyFinding( + rule_id="AE003-COMMENTED_CODE_ADVISORY", + severity="advisory", + message=( + "Detected 3+ consecutive lines of commented-out code. " + "Consider complete removal under version control rather than ghost commenting." + ), + location=start_line[:80], + ) + ) + consecutive_commented_code = 0 + + return findings + + +def evaluate_diff_hygiene( + diff_text: str, + retirement_basis: RetirementBasis | Mapping[str, Any] | None = None, +) -> AntiEntropyReceipt: + diff_text = textwrap.dedent(diff_text) + diff_hash = _hash_diff(diff_text) + all_findings: list[AntiEntropyFinding] = [] + + all_findings.extend(verify_delete_first(diff_text, retirement_basis)) + all_findings.extend(audit_ghost_code(diff_text)) + + has_blocking = any(f.severity == "blocking" for f in all_findings) + verdict = "rejected" if has_blocking else "passed" + + retired: list[str] = [] + if retirement_basis: + basis = ( + retirement_basis + if isinstance(retirement_basis, RetirementBasis) + else RetirementBasis.from_dict(retirement_basis) + ) + if basis.target_symbols and diff_text.strip(): + deleted_symbols, reintroduced_symbols, still_defined_on_disk = _analyze_diff_symbols(diff_text) + failing_symbols = { + f.location for f in all_findings + if f.location and f.rule_id in ("AE001-DELETE_FIRST_VIOLATION", "AE002-ZOMBIE_SYMBOL_REINTRODUCED") + } + for s in basis.target_symbols: + if ( + s in deleted_symbols + and s not in reintroduced_symbols + and s not in still_defined_on_disk + and s not in failing_symbols + ): + retired.append(s) + + return AntiEntropyReceipt( + diff_hash=diff_hash, + verdict=verdict, + findings=tuple(all_findings), + retired_symbols=tuple(sorted(retired)), + ) diff --git a/loopx/capabilities/catalog.py b/loopx/capabilities/catalog.py index f7d61b1db6..13b7571ac1 100644 --- a/loopx/capabilities/catalog.py +++ b/loopx/capabilities/catalog.py @@ -31,6 +31,7 @@ from .reliability_diagnostics.catalog_entry import RELIABILITY_DIAGNOSTICS_CATALOG_ENTRY from .progress_review.catalog_entry import PROGRESS_REVIEW_CATALOG_ENTRY from .goal_capability_organization.catalog_entry import GOAL_CAPABILITY_ORGANIZATION_CATALOG_ENTRY +from .anti_entropy.catalog_entry import ANTI_ENTROPY_CATALOG_ENTRY from .registry import CapabilityRegistry CAPABILITY_CATALOG_SCHEMA_VERSION = "loopx_capability_catalog_v0" @@ -62,6 +63,7 @@ RELIABILITY_DIAGNOSTICS_CATALOG_ENTRY, PROGRESS_REVIEW_CATALOG_ENTRY, GOAL_CAPABILITY_ORGANIZATION_CATALOG_ENTRY, + ANTI_ENTROPY_CATALOG_ENTRY, ) # Preserve the original import surface while routing all reads through the registry. CAPABILITIES = BUILTIN_CAPABILITIES diff --git a/loopx/cli.py b/loopx/cli.py index 7fd25b2f08..374a83fa07 100644 --- a/loopx/cli.py +++ b/loopx/cli.py @@ -186,6 +186,10 @@ handle_goal_amendment_proposal_command, register_goal_amendment_proposal_command, ) +from .cli_commands.anti_entropy import ( + handle_anti_entropy_command, + register_anti_entropy_commands, +) from .cli_commands.reliability_diagnostics import ( handle_reliability_diagnostics_command, register_reliability_diagnostics_commands, @@ -286,6 +290,8 @@ def build_parser() -> LoopXArgumentParser: register_capability_commands(sub, add_subcommand_format) + register_anti_entropy_commands(sub, add_subcommand_format) + register_reliability_diagnostics_commands(sub, add_subcommand_format) register_extension_commands(sub, add_subcommand_format) @@ -493,6 +499,16 @@ def _main(argv: list[str] | None = None) -> int: if capability_result is not None: return capability_result + anti_entropy_result = handle_anti_entropy_command( + args, + registry_path=registry_path, + runtime_root_arg=args.runtime_root, + output_format=output_format, + print_payload=print_payload, + ) + if anti_entropy_result is not None: + return anti_entropy_result + reliability_diagnostics_result = handle_reliability_diagnostics_command( args, registry_path=registry_path, diff --git a/loopx/cli_commands/anti_entropy.py b/loopx/cli_commands/anti_entropy.py new file mode 100644 index 0000000000..ae80430952 --- /dev/null +++ b/loopx/cli_commands/anti_entropy.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import argparse +import json +import sys +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from ..capabilities.anti_entropy.cli import run_anti_entropy_verify +from ..capabilities.anti_entropy.contract import ANTI_ENTROPY_CAPABILITY_ID + +PrintPayload = Callable[ + [dict[str, Any], str, Callable[[dict[str, Any]], str]], str | None +] +FormatSelector = Callable[..., str] +AddFormat = Callable[[argparse.ArgumentParser], None] + + +def register_anti_entropy_commands( + subparsers: argparse._SubParsersAction[argparse.ArgumentParser], + add_subcommand_format: AddFormat, +) -> None: + parser = subparsers.add_parser( + "anti-entropy", + help="Verify diff hygiene and delete-first code retirement governance.", + ) + commands = parser.add_subparsers( + dest="anti_entropy_command", required=True + ) + verify = commands.add_parser( + "verify", + help="Verify diff against a declared retirement basis and audit diff hygiene.", + ) + add_subcommand_format(verify) + verify.add_argument("--goal-id", help="Associated LoopX goal identifier.") + verify.add_argument( + "--diff-file", + help="Path to unified diff patch file, or - for stdin.", + ) + verify.add_argument( + "--basis-json", + help="JSON string or file path containing RetirementBasis specification.", + ) + + +def _render(payload: dict[str, Any]) -> str: + lines = [f"# Anti-Entropy Governance ({payload.get('command')})", ""] + lines.append(f"- verdict: `{payload.get('verdict')}`") + lines.append(f"- ok: `{payload.get('ok')}`") + if payload.get("goal_id"): + lines.append(f"- goal_id: `{payload.get('goal_id')}`") + lines.append(f"- finding_count: `{payload.get('finding_count', 0)}`") + lines.append(f"- blocking_finding_count: `{payload.get('blocking_finding_count', 0)}`") + lines.append(f"- advisory_finding_count: `{payload.get('advisory_finding_count', 0)}`") + + receipt = payload.get("receipt", {}) + if receipt: + lines.append("") + lines.append("## Receipt") + lines.append(f"- diff_hash: `{receipt.get('diff_hash')}`") + retired = receipt.get("retired_symbols", []) + if retired: + lines.append(f"- retired_symbols: `{', '.join(retired)}`") + findings = receipt.get("findings", []) + if findings: + lines.append("") + lines.append("## Findings") + for item in findings: + sev = item.get("severity", "").upper() + rule = item.get("rule_id") + msg = item.get("message") + lines.append(f"- [{sev}] {rule}: {msg}") + return "\n".join(lines) + "\n" + + +def handle_anti_entropy_command( + args: argparse.Namespace, + *, + registry_path: Path, + runtime_root_arg: str | None, + output_format: FormatSelector, + print_payload: PrintPayload, +) -> int | None: + if args.command != "anti-entropy": + return None + try: + command = args.anti_entropy_command + if command == "verify": + diff_text = "" + if args.diff_file == "-": + diff_text = sys.stdin.read() + elif args.diff_file: + diff_path = Path(args.diff_file).expanduser() + if diff_path.is_file(): + diff_text = diff_path.read_text(encoding="utf-8") + else: + raise ValueError(f"diff file not found: {args.diff_file}") + + basis_data = None + if args.basis_json is not None: + raw_basis = args.basis_json.strip() + if raw_basis.startswith("{") or raw_basis.startswith("["): + basis_data = json.loads(raw_basis) + else: + path = Path(raw_basis).expanduser() + if path.is_file(): + basis_data = json.loads(path.read_text(encoding="utf-8")) + else: + try: + basis_data = json.loads(raw_basis) + except Exception: + raise ValueError(f"basis file not found: {raw_basis}") + + payload = run_anti_entropy_verify( + diff_text=diff_text, + basis_data=basis_data, + goal_id=args.goal_id, + ) + else: + raise ValueError(f"unknown anti-entropy command `{command}`") + except (OSError, ValueError, TypeError) as exc: + print(f"error: {ANTI_ENTROPY_CAPABILITY_ID}: {exc}", file=sys.stderr) + return 2 + + print_payload(payload, output_format(args), _render) + return 0 if payload.get("ok") else 1 diff --git a/tests/capabilities/test_anti_entropy.py b/tests/capabilities/test_anti_entropy.py new file mode 100644 index 0000000000..62be6ecc96 --- /dev/null +++ b/tests/capabilities/test_anti_entropy.py @@ -0,0 +1,403 @@ +from __future__ import annotations + +import json +from typing import Any + +from loopx.capabilities.anti_entropy import ( + ANTI_ENTROPY_CAPABILITY_ID, + ANTI_ENTROPY_CATALOG_ENTRY, + ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION, + ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION, + AntiEntropyReceipt, + RetirementBasis, + audit_ghost_code, + evaluate_diff_hygiene, + run_anti_entropy_verify, + verify_delete_first, +) +from loopx.capabilities.catalog import BUILTIN_CAPABILITIES + + +def test_catalog_registration() -> None: + """Verify anti-entropy capability is registered in builtin capabilities catalog.""" + matching = [ + c for c in BUILTIN_CAPABILITIES if c["id"] == ANTI_ENTROPY_CAPABILITY_ID + ] + assert len(matching) == 1 + record = matching[0] + assert record["origin"] == "builtin" + assert record["status"] == "experimental" + assert record["default_enabled"] is False + assert "delete-first" in record["real_world_anchor"] or "refactoring" in record["real_world_anchor"] + + +def test_verify_delete_first_passed() -> None: + """Verify clean retirement where old symbol is cleanly deleted.""" + diff = """ +--- a/legacy_module.py ++++ b/legacy_module.py +@@ -1,5 +1,5 @@ +-def old_deprecated_calculator(x, y): +- return x + y ++def modern_calculator(x: int, y: int) -> int: ++ return x + y +""" + basis = RetirementBasis( + target_symbols=("old_deprecated_calculator",), + rationale="Replaced by modern_calculator", + ) + findings = verify_delete_first(diff, basis) + assert len(findings) == 0 + + receipt = evaluate_diff_hygiene(diff, basis) + assert receipt.verdict == "passed" + assert "old_deprecated_calculator" in receipt.retired_symbols + + +def test_verify_delete_first_violation() -> None: + """Verify rejection when old symbol is kept instead of delete-first.""" + diff = """ +--- a/legacy_module.py ++++ b/legacy_module.py +@@ -1,3 +1,6 @@ ++def modern_calculator(x: int, y: int) -> int: ++ return x + y + def old_deprecated_calculator(x, y): + return x + y +""" + basis = RetirementBasis( + target_symbols=("old_deprecated_calculator",), + allow_compat_seam=False, + ) + findings = verify_delete_first(diff, basis) + assert len(findings) == 1 + assert findings[0].rule_id == "AE001-DELETE_FIRST_VIOLATION" + assert findings[0].severity == "blocking" + + receipt = evaluate_diff_hygiene(diff, basis) + assert receipt.verdict == "rejected" + + +def test_verify_delete_first_zombie_reintroduced() -> None: + """Verify rejection when symbol is deleted and reintroduced simultaneously.""" + diff = """ +--- a/legacy_module.py ++++ b/legacy_module.py +@@ -1,3 +1,3 @@ +-def legacy_sink(): ++def legacy_sink(): # wrapper + pass +""" + basis = RetirementBasis(target_symbols=("legacy_sink",)) + findings = verify_delete_first(diff, basis) + assert any(f.rule_id == "AE002-ZOMBIE_SYMBOL_REINTRODUCED" for f in findings) + + +def test_target_paths_deletion() -> None: + """Verify target_paths require complete file deletion headers in the diff.""" + # File cleanly deleted -> pass + deleted_diff = """ +--- a/legacy/old_file.py ++++ /dev/null +@@ -1,3 +0,0 @@ +-def dead(): +- pass +""" + basis = RetirementBasis(target_paths=("legacy/old_file.py",)) + receipt = evaluate_diff_hygiene(deleted_diff, basis) + assert receipt.verdict == "passed" + assert len(receipt.findings) == 0 + + # File only partially modified instead of deleted -> fail + partial_diff = """ +--- a/legacy/old_file.py ++++ b/legacy/old_file.py +@@ -1,3 +1,3 @@ +-def dead(): ++def modified(): + pass +""" + receipt_partial = evaluate_diff_hygiene(partial_diff, basis) + assert receipt_partial.verdict == "rejected" + assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt_partial.findings) + + +def test_compat_seam_does_not_falsely_retire() -> None: + """Verify allow_compat_seam allows passing but does NOT claim un-deleted symbols as retired.""" + diff = """ +--- a/legacy_module.py ++++ b/legacy_module.py +@@ -1,3 +1,4 @@ ++# some edit + def old_api(): + return 1 +""" + basis = RetirementBasis( + target_symbols=("old_api",), + allow_compat_seam=True, + ) + receipt = evaluate_diff_hygiene(diff, basis) + assert receipt.verdict == "passed" + # old_api was NOT deleted, so it must NOT be in retired_symbols! + assert "old_api" not in receipt.retired_symbols + + +def test_false_positive_prevention() -> None: + """Ensure normal English comments or documentation mentioning words like fallback are not blocked.""" + diff = """ +--- a/README.md ++++ b/README.md +@@ -10,3 +10,5 @@ ++# if the connection is closed, the next call reconnects. ++# Please see documentation for details. ++# Delete fallback_v4 after migrating all callers. +""" + basis = RetirementBasis() + receipt = evaluate_diff_hygiene(diff, basis) + assert receipt.verdict == "passed" + # Should not produce blocking findings + assert all(f.severity != "blocking" for f in receipt.findings) + + +def test_audit_ghost_code_commented_block() -> None: + """Verify advisory finding when dead code blocks are left behind as comments.""" + diff = """ +--- a/service.py ++++ b/service.py +@@ -10,3 +10,8 @@ ++def active_fn(): ++ # def old_worker(): ++ # x = 1 ++ # return x ++ return True +""" + findings = audit_ghost_code(diff) + assert len(findings) == 1 + assert findings[0].rule_id == "AE003-COMMENTED_CODE_ADVISORY" + assert findings[0].severity == "advisory" + + +def test_cli_runner() -> None: + """Verify CLI verify function generates a complete, valid payload.""" + clean_diff = """ +--- a/service.py ++++ b/service.py +@@ -1,3 +1,3 @@ +-def legacy_call(): ++def modern_call(): + return 42 +""" + payload = run_anti_entropy_verify( + diff_text=clean_diff, + basis_data={"target_symbols": ["legacy_call"]}, + goal_id="test-goal-id", + ) + assert payload["ok"] is True + assert payload["verdict"] == "passed" + assert payload["blocking_finding_count"] == 0 + assert "legacy_call" in payload["retired_symbols"] + +def test_retirement_basis_type_validation() -> None: + """Verify strict type validation on RetirementBasis.from_dict to prevent truthiness loopholes.""" + import pytest + + # allow_compat_seam must be a real boolean, not truthy string "false" + with pytest.raises(TypeError, match="allow_compat_seam must be a boolean"): + RetirementBasis.from_dict({"allow_compat_seam": "false"}) + + with pytest.raises(TypeError, match="allow_compat_seam must be a boolean"): + RetirementBasis.from_dict({"allow_compat_seam": "true"}) + + with pytest.raises(TypeError, match="allow_compat_seam must be a boolean"): + RetirementBasis.from_dict({"allow_compat_seam": 1}) + + # basis data must be a Mapping, not an array + with pytest.raises(TypeError, match="must be a JSON object"): + RetirementBasis.from_dict([]) + + with pytest.raises(TypeError, match="must be a JSON object"): + RetirementBasis.from_dict("not_a_dict") + + # target_symbols must be a collection of strings + with pytest.raises(TypeError, match="target_symbols must be a list of strings"): + RetirementBasis.from_dict({"target_symbols": "old_api"}) + + with pytest.raises(TypeError, match="target_symbols elements must be strings"): + RetirementBasis.from_dict({"target_symbols": [123]}) + + # target_paths must be a collection of strings + with pytest.raises(TypeError, match="target_paths must be a list of strings"): + RetirementBasis.from_dict({"target_paths": "legacy.py"}) + + with pytest.raises(TypeError, match="target_paths elements must be strings"): + RetirementBasis.from_dict({"target_paths": [456]}) + + # schema_version and rationale must be strings + with pytest.raises(TypeError, match="schema_version must be a string"): + RetirementBasis.from_dict({"schema_version": 123}) + + with pytest.raises(TypeError, match="rationale must be a string"): + RetirementBasis.from_dict({"rationale": 123}) + + +def test_cli_exit_code_and_subprocess() -> None: + """Verify CLI returns exit code 1 on rejection, 0 on pass, and 2 on invalid basis JSON.""" + import subprocess + import sys + + # 1. Rejected verification -> exit code 1 + diff_fail = """ +--- a/service.py ++++ b/service.py +@@ -1,3 +1,3 @@ ++# keep old_api + def old_api(): + return 1 +""" + proc_fail = subprocess.run( + [ + sys.executable, + "-m", + "loopx.cli", + "anti-entropy", + "verify", + "--diff-file", + "-", + "--basis-json", + "{\"target_symbols\": [\"old_api\"]}", + "--format", + "json", + ], + input=diff_fail.encode("utf-8"), + capture_output=True, + ) + assert proc_fail.returncode == 1 + + # 2. Passed verification -> exit code 0 + diff_pass = """ +--- a/service.py ++++ b/service.py +@@ -1,3 +1,3 @@ +-def old_api(): +- return 1 ++def new_api(): ++ return 2 +""" + proc_pass = subprocess.run( + [ + sys.executable, + "-m", + "loopx.cli", + "anti-entropy", + "verify", + "--diff-file", + "-", + "--basis-json", + "{\"target_symbols\": [\"old_api\"]}", + "--format", + "json", + ], + input=diff_pass.encode("utf-8"), + capture_output=True, + ) + assert proc_pass.returncode == 0 + + # 3. Invalid basis JSON array -> exit code 2 + proc_err = subprocess.run( + [ + sys.executable, + "-m", + "loopx.cli", + "anti-entropy", + "verify", + "--diff-file", + "-", + "--basis-json", + "[]", + "--format", + "json", + ], + input=b"", + capture_output=True, + ) + assert proc_err.returncode == 2 + + +def test_docstring_example_deletion_does_not_retire_real_definition() -> None: + """Verify deleting a code example in a docstring does not falsely pass a retained real definition.""" + diff = ( + "--- a/api.py\n" + "+++ b/api.py\n" + "@@ -1,6 +1,3 @@\n" + " \"\"\"\n" + "-Example:\n" + "- def old_api():\n" + "- return 1\n" + " \"\"\"\n" + " def old_api():\n" + " return 2\n" + ) + basis = RetirementBasis(target_symbols=("old_api",)) + receipt = evaluate_diff_hygiene(diff, basis) + assert receipt.verdict == "rejected" + assert any(f.rule_id in ("AE001-DELETE_FIRST_VIOLATION", "AE002-ZOMBIE_SYMBOL_REINTRODUCED") for f in receipt.findings) + assert "old_api" not in receipt.retired_symbols + + +def test_exact_git_path_confirmation_and_encoding() -> None: + """Verify exact path matching without suffix guesswork, plus empty and quoted path deletion.""" + # 1. Suffix mismatch: deleting legacy_api.py must NOT satisfy target_paths=("api.py",) + diff_suffix = """ +--- a/legacy_api.py ++++ /dev/null +@@ -1,2 +0,0 @@ +-def f(): pass +""" + basis_suffix = RetirementBasis(target_paths=("api.py",)) + receipt_suffix = evaluate_diff_hygiene(diff_suffix, basis_suffix) + assert receipt_suffix.verdict == "rejected" + assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt_suffix.findings) + + # 2. Empty file deletion metadata (deleted file mode 100644 without +/- hunks) + diff_empty = """ +diff --git a/legacy/__init__.py b/legacy/__init__.py +deleted file mode 100644 +index e69de29..0000000 +""" + basis_empty = RetirementBasis(target_paths=("legacy/__init__.py",)) + receipt_empty = evaluate_diff_hygiene(diff_empty, basis_empty) + assert receipt_empty.verdict == "passed" + assert len(receipt_empty.findings) == 0 + + # 3. Quoted path with C-style octal escaping for non-ASCII paths + diff_quoted = """ +diff --git "a/legacy/\\346\\227\\247\\345\\256\\236\\347\\214\\233.py" "b/legacy/\\346\\227\\247\\345\\256\\236\\347\\214\\233.py" +deleted file mode 100644 +--- "a/legacy/\\346\\227\\247\\345\\256\\236\\347\\214\\233.py" ++++ /dev/null +""" + basis_quoted = RetirementBasis(target_paths=("legacy/旧实猛.py",)) + receipt_quoted = evaluate_diff_hygiene(diff_quoted, basis_quoted) + assert receipt_quoted.verdict == "passed" + assert len(receipt_quoted.findings) == 0 + + +def test_reintroduced_symbol_excluded_from_retired_symbols_with_partial_retirement() -> None: + """Verify modified/reintroduced symbol is excluded from retired_symbols while cleanly retired symbol is kept.""" + diff = """ +--- a/service.py ++++ b/service.py +@@ -1,6 +1,4 @@ +-def old_helper(): +- return 1 +-def old_api(x): +- return x ++def old_api(x=1): ++ return x +""" + basis = RetirementBasis(target_symbols=("old_helper", "old_api")) + receipt = evaluate_diff_hygiene(diff, basis) + assert receipt.verdict == "rejected" + assert any(f.rule_id == "AE002-ZOMBIE_SYMBOL_REINTRODUCED" and f.location == "old_api" for f in receipt.findings) + # old_api is excluded from retired_symbols, but old_helper is accurately certified! + assert receipt.retired_symbols == ("old_helper",) diff --git a/tests/capabilities/test_capability_extension_registry.py b/tests/capabilities/test_capability_extension_registry.py index bf99c46780..ddef80c25b 100644 --- a/tests/capabilities/test_capability_extension_registry.py +++ b/tests/capabilities/test_capability_extension_registry.py @@ -47,6 +47,7 @@ "reliability-diagnostics", "progress-review-sentinel", "goal-capability-organization", + "anti-entropy-governance", ] From baed1b1f9bbef8dcf9ebcd22b5b8b288ce0e13bd Mon Sep 17 00:00:00 2001 From: LoopDesk Date: Thu, 29 Oct 2026 12:13:47 +0800 Subject: [PATCH 02/21] fix(anti-entropy): resolve review blockers on path normalization, spaces, and docstring checks - Separate Git header decoding from target path normalization via normalize_repo_path and decode_git_diff_path to distinguish a/api.py vs api.py and .api.py. - Parse unquoted git diff headers for empty file deletions with spaces in filename. - Bind repository root via find_repo_root to verify post-change AST across subdirectories, preventing false retirement certification when definitions remain on disk outside diff hunks. - Add structured anti_entropy_inspect agent tool for execution-phase self-checks. - Add standalone multi-file case study and three-arm factorial comparison runner in examples/anti_entropy/. - Add design decisions record in docs/decisions/anti-entropy-benchmark-decisions.md. - Expand unit tests in tests/capabilities/test_anti_entropy.py to 22 passing tests. Signed-off-by: LoopDesk --- .../anti-entropy-benchmark-decisions.md | 78 ++++ examples/anti_entropy/case_study/client.py | 19 + .../anti_entropy/case_study/legacy_parser.py | 28 ++ .../anti_entropy/case_study/stream_parser.py | 20 + .../anti_entropy/case_study/task_spec.json | 12 + .../case_study/tests/test_client.py | 11 + examples/anti_entropy/compare_arms.py | 190 +++++++++ loopx/capabilities/anti_entropy/__init__.py | 2 + loopx/capabilities/anti_entropy/agent_tool.py | 24 ++ loopx/capabilities/anti_entropy/oracles.py | 363 ++++++++++++++---- tests/capabilities/test_anti_entropy.py | 202 ++++++++++ 11 files changed, 870 insertions(+), 79 deletions(-) create mode 100644 docs/decisions/anti-entropy-benchmark-decisions.md create mode 100644 examples/anti_entropy/case_study/client.py create mode 100644 examples/anti_entropy/case_study/legacy_parser.py create mode 100644 examples/anti_entropy/case_study/stream_parser.py create mode 100644 examples/anti_entropy/case_study/task_spec.json create mode 100644 examples/anti_entropy/case_study/tests/test_client.py create mode 100644 examples/anti_entropy/compare_arms.py create mode 100644 loopx/capabilities/anti_entropy/agent_tool.py diff --git a/docs/decisions/anti-entropy-benchmark-decisions.md b/docs/decisions/anti-entropy-benchmark-decisions.md new file mode 100644 index 0000000000..fb1a3d9245 --- /dev/null +++ b/docs/decisions/anti-entropy-benchmark-decisions.md @@ -0,0 +1,78 @@ +# Architecture Decision Record: Anti-Entropy Governance Benchmark & Evaluation Harness + +- **Status**: Accepted (Design Grill Complete) +- **Date**: 2026-10-29 +- **Scope**: Out-of-band evaluation harness for quantifying anti-entropy (Aegis delete-first) impact +- **Target Constraint**: 100% zero-intrusion to LoopX core control plane (`loopx/control_plane/`), default-off opt-in capability + +--- + +## 1. Context & Problem Statement + +When autonomous coding agents execute refactoring, feature replacement, or protocol migration tasks, they suffer from systematic "entropy growth": +1. **Fallback/Wrapper Addiction**: Instead of cleanly deleting old classes/functions, agents keep them as backwards-compatibility shims or wrap them inside new logic. +2. **Ghost Code Residue**: Agents frequently comment out superseded blocks (`# old_logic()`) rather than deleting them. +3. **Zombie Reintroduction**: When tests fail, agents often recreate the deprecated symbol in the newly modified files. + +LoopX possesses an existing deterministic AST-based anti-entropy engine (`loopx/capabilities/anti_entropy/`). The core question was: +- Should test/benchmark scenarios overlay a full real-world development task? +- How should the anti-entropy capability be injected into commands to measure the delta between having vs. not having anti-entropy governance? +- What task scenario is most appropriate to evaluate this without compromising LoopX core stability? + +--- + +## 2. Decision Points & Trade-Off Analysis + +### Decision 1: Loading Mechanism & Interception Timing +- **Considered Alternatives**: + - *Option 1*: Pure prompt guidance (soft constraint). Zero tooling overhead, but models consistently regress under multi-file pressure. + - *Option 2*: Post-hoc terminal gate only. Verifies diff at submission; fails late when the agent has already exhausted budget, leading to blind rollback loops. + - *Option 3 (Selected)*: **Two-Tier Closed Loop (Execution Tool + Submission Gate)**. + - Exposes `anti_entropy_inspect` as an advisory tool during execution so the agent can self-audit diff cleanliness. + - Chains `loopx anti-entropy verify` into the delivery evaluation command, failing the run if `AntiEntropyReceipt.verdict != "passed"`. +- **Trade-off**: Increases tool-call rounds and slight token overhead per run, in exchange for deterministic first-pass cleanliness and eliminating expensive blind rollbacks. + +--- + +### Decision 2: Zero-Intrusion Packaging & Retirement Basis Injection +- **Considered Alternatives**: + - *Option 1*: Hardwire anti-entropy check into `loopx/control_plane/turn_driver/`. Causes direct intrusion into generic mainline scheduling, violating domain-neutrality. + - *Option 2*: Dynamic runtime AST inference without declaration. Prone to false positives when agents refactor internal helper functions. + - *Option 3 (Selected)*: **Declarative Sidecar / Task-Envelope Hook Pattern**. + - The task specification explicitly provides an `anti_entropy_basis` (target symbols and target paths to retire). + - The benchmark/test runner wraps the task externally: conditionally injects the inspect tool into the agent envelope and appends the verify CLI command to the test command. + - `loopx/control_plane/` remains completely unmodified (0-line diff). +- **Trade-off**: Requires test cases to declare their target retirement list in advance, rather than inferring intent magically. + +--- + +### Decision 3: Evaluation Benchmark Scenario (Testbed Task) Selection +- **Considered Alternatives**: + - *Option 1*: In-repo dogfooding on live LoopX modules. High risk of breaking the developer active branch during failed baseline runs; violates mainline safety. + - *Option 2*: External SWE-bench Docker benchmark. Requires heavyweight containers and environment setups; feedback loop too slow for local iterative testing. + - *Option 3 (Selected)*: **Self-Contained Realistic Engineering Playground in `examples/anti_entropy/`**. + - A multi-module Python service (protocol parser + streaming receiver + client caller + unit test suite). + - Real task: Migrate all callers from `LegacyProtocolParser` to `StreamMessageParser` and physically delete `legacy_parser.py`. + - Automated comparator evaluates Arm 1 (Baseline), Arm 2 (Prompt Only), and Arm 3 (Two-Tier Anti-Entropy). +- **Trade-off**: Tests on a standalone engineering playground rather than mutating production LoopX code directly. + +--- + +## 3. First-Principles Minimality Justification (Aegis Integration) + +1. **Change Necessity**: Reuses the existing `loopx/capabilities/anti_entropy/` AST engine without creating redundant verification abstractions. +2. **Fallback Ceiling**: Refuses to add fallback adapters; enforces physical deletion of deprecated symbols (`/dev/null` diff + AST symbol absence). +3. **Delete-First & Anti-Entropy**: The evaluation suite directly measures net negative lines of code, absence of zombie wrappers, and 0 ghost code blocks. +4. **Mainline Purity**: All new harness scripts live in `examples/anti_entropy/` and `docs/decisions/`. No files under `loopx/control_plane/` are touched. + +--- + +## 4. Impacted System Components & Retired Paths + +- **Impacted Components**: + - `docs/decisions/anti-entropy-benchmark-decisions.md`: Canonical ADR record. + - `examples/anti_entropy/case_study/`: Standalone realistic multi-file testbed. + - `examples/anti_entropy/compare_arms.py`: Out-of-band evaluation comparator for measuring differential. +- **Retired / Disallowed Paths**: + - Disallowed: Modifying `loopx/control_plane/turn_driver/executor.py` for benchmark gates. + - Disallowed: Ad-hoc heuristic AST diff guesswork without a declared basis. diff --git a/examples/anti_entropy/case_study/client.py b/examples/anti_entropy/case_study/client.py new file mode 100644 index 0000000000..f2440f94fb --- /dev/null +++ b/examples/anti_entropy/case_study/client.py @@ -0,0 +1,19 @@ +"""Consumer client currently bound to legacy parser.""" + +from typing import Any +from .legacy_parser import LegacyMessageParser + + +class DataConsumer: + """High-level data consumer.""" + + def __init__(self) -> None: + self._parser = LegacyMessageParser() + + def process_message(self, raw_input: str) -> dict[str, Any]: + result = self._parser.decode(raw_input) + return { + "status": "ok", + "content": result["payload"], + "length": result["size"], + } diff --git a/examples/anti_entropy/case_study/legacy_parser.py b/examples/anti_entropy/case_study/legacy_parser.py new file mode 100644 index 0000000000..da0f6da804 --- /dev/null +++ b/examples/anti_entropy/case_study/legacy_parser.py @@ -0,0 +1,28 @@ +"""Legacy message parser slated for retirement.""" + +from typing import Any + + +class LegacyMessageParser: + """Old-style frame decoder with delimiter separation.""" + + def __init__(self, delimiter: str = "|") -> None: + self.delimiter = delimiter + + def decode(self, raw_data: str) -> dict[str, Any]: + parts = raw_data.strip().split(self.delimiter) + if len(parts) < 3: + raise ValueError("Malformed legacy frame") + header, payload, checksum = parts[0], parts[1], parts[2] + return { + "version": "v1", + "header": header, + "payload": payload, + "checksum": checksum, + "size": len(payload), + } + + +def parse_legacy_frame(raw_data: str) -> dict[str, Any]: + """Standalone legacy helper.""" + return LegacyMessageParser().decode(raw_data) diff --git a/examples/anti_entropy/case_study/stream_parser.py b/examples/anti_entropy/case_study/stream_parser.py new file mode 100644 index 0000000000..26877d9b4b --- /dev/null +++ b/examples/anti_entropy/case_study/stream_parser.py @@ -0,0 +1,20 @@ +"""Modern stream message parser replacing legacy parser.""" + +from typing import Any + + +class StreamMessageParser: + """Modern stream parser parsing structured payload with validation.""" + + def parse(self, raw_data: str) -> dict[str, Any]: + # Accepts both pipe format and key-value formats + parts = raw_data.strip().split("|") + if len(parts) >= 3: + return { + "version": "v2", + "header": parts[0], + "payload": parts[1], + "checksum": parts[2], + "size": len(parts[1]), + } + raise ValueError("Invalid stream payload") diff --git a/examples/anti_entropy/case_study/task_spec.json b/examples/anti_entropy/case_study/task_spec.json new file mode 100644 index 0000000000..4ffa9a7a9c --- /dev/null +++ b/examples/anti_entropy/case_study/task_spec.json @@ -0,0 +1,12 @@ +{ + "task_id": "case_study_parser_retirement", + "objective": "Migrate DataConsumer in client.py from LegacyMessageParser to StreamMessageParser. Fully retire LegacyMessageParser and delete legacy_parser.py cleanly without leaving backward-compatibility shims or dead code.", + "functional_test_command": "pytest examples/anti_entropy/case_study/tests/test_client.py -q", + "retirement_basis": { + "schema_version": "loopx_anti_entropy_retirement_basis_v0", + "target_symbols": ["LegacyMessageParser", "parse_legacy_frame"], + "target_paths": ["examples/anti_entropy/case_study/legacy_parser.py"], + "allow_compat_seam": false, + "rationale": "Superseded by StreamMessageParser in stream_parser.py" + } +} diff --git a/examples/anti_entropy/case_study/tests/test_client.py b/examples/anti_entropy/case_study/tests/test_client.py new file mode 100644 index 0000000000..d9e1e7670c --- /dev/null +++ b/examples/anti_entropy/case_study/tests/test_client.py @@ -0,0 +1,11 @@ +"""Tests for DataConsumer.""" + +from examples.anti_entropy.case_study.client import DataConsumer + + +def test_consumer_normal_flow() -> None: + consumer = DataConsumer() + res = consumer.process_message("HDR|hello world|CRC32") + assert res["status"] == "ok" + assert res["content"] == "hello world" + assert res["length"] == 11 diff --git a/examples/anti_entropy/compare_arms.py b/examples/anti_entropy/compare_arms.py new file mode 100644 index 0000000000..b0ac36ac51 --- /dev/null +++ b/examples/anti_entropy/compare_arms.py @@ -0,0 +1,190 @@ +"""Comparator script evaluating Arms on the realistic refactoring case study. + +Measures the concrete difference between: +- Arm 1 (Baseline): No governance (leaves legacy files/wrappers; passes functional tests, fails cleanliness) +- Arm 2 (Control): Prompt-only soft constraint (leaves ghost commented code / shims) +- Arm 3 (Treatment): Anti-entropy two-tier closed loop (AST delete-first + verification gate) +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO_ROOT)) + +from loopx.capabilities.anti_entropy.cli import run_anti_entropy_verify + + +def generate_arm1_diff() -> str: + """Arm 1: Agent modifies client.py but keeps legacy_parser.py as unused dead code.""" + return ( + "diff --git a/examples/anti_entropy/case_study/client.py b/examples/anti_entropy/case_study/client.py\n" + "--- a/examples/anti_entropy/case_study/client.py\n" + "+++ b/examples/anti_entropy/case_study/client.py\n" + "@@ -2,7 +2,7 @@\n" + " from typing import Any\n" + "-from .legacy_parser import LegacyMessageParser\n" + "+from .stream_parser import StreamMessageParser\n" + " \n" + " \n" + " class DataConsumer:\n" + "@@ -10,6 +10,6 @@\n" + " def __init__(self) -> None:\n" + "- self._parser = LegacyMessageParser()\n" + "+ self._parser = StreamMessageParser()\n" + " \n" + " def process_message(self, raw_input: str) -> dict[str, Any]:\n" + "- result = self._parser.decode(raw_input)\n" + "+ result = self._parser.parse(raw_input)\n" + " return {\n" + ) + + +def generate_arm2_diff() -> str: + """Arm 2: Agent edits legacy_parser.py but leaves ghost commented-out code and a shim.""" + return ( + "diff --git a/examples/anti_entropy/case_study/client.py b/examples/anti_entropy/case_study/client.py\n" + "--- a/examples/anti_entropy/case_study/client.py\n" + "+++ b/examples/anti_entropy/case_study/client.py\n" + "@@ -2,7 +2,7 @@\n" + " from typing import Any\n" + "-from .legacy_parser import LegacyMessageParser\n" + "+from .stream_parser import StreamMessageParser\n" + " \n" + " \n" + " class DataConsumer:\n" + "@@ -10,6 +10,6 @@\n" + " def __init__(self) -> None:\n" + "- self._parser = LegacyMessageParser()\n" + "+ self._parser = StreamMessageParser()\n" + " \n" + " def process_message(self, raw_input: str) -> dict[str, Any]:\n" + "- result = self._parser.decode(raw_input)\n" + "+ result = self._parser.parse(raw_input)\n" + " return {\n" + "diff --git a/examples/anti_entropy/case_study/legacy_parser.py b/examples/anti_entropy/case_study/legacy_parser.py\n" + "--- a/examples/anti_entropy/case_study/legacy_parser.py\n" + "+++ b/examples/anti_entropy/case_study/legacy_parser.py\n" + "@@ -4,15 +4,5 @@\n" + "-class LegacyMessageParser:\n" + "- def __init__(self, delimiter: str = \"|\") -> None:\n" + "- self.delimiter = delimiter\n" + "- def decode(self, raw_data: str) -> dict[str, Any]:\n" + "- pass\n" + "-def parse_legacy_frame(raw_data: str) -> dict[str, Any]:\n" + "- pass\n" + "+# Deprecated: do not use\n" + "+# def parse_legacy_frame(raw_data: str) -> dict[str, Any]:\n" + "+# return LegacyMessageParser().decode(raw_data)\n" + "+class LegacyMessageParser:\n" + "+ pass\n" + ) + + +def generate_arm3_diff() -> str: + """Arm 3: Agent cleanly deletes legacy_parser.py (/dev/null) and updates client.py.""" + return ( + "diff --git a/examples/anti_entropy/case_study/client.py b/examples/anti_entropy/case_study/client.py\n" + "--- a/examples/anti_entropy/case_study/client.py\n" + "+++ b/examples/anti_entropy/case_study/client.py\n" + "@@ -2,7 +2,7 @@\n" + " from typing import Any\n" + "-from .legacy_parser import LegacyMessageParser\n" + "+from .stream_parser import StreamMessageParser\n" + " \n" + " \n" + " class DataConsumer:\n" + "@@ -10,6 +10,6 @@\n" + " def __init__(self) -> None:\n" + "- self._parser = LegacyMessageParser()\n" + "+ self._parser = StreamMessageParser()\n" + " \n" + " def process_message(self, raw_input: str) -> dict[str, Any]:\n" + "- result = self._parser.decode(raw_input)\n" + "+ result = self._parser.parse(raw_input)\n" + " return {\n" + "diff --git a/examples/anti_entropy/case_study/legacy_parser.py b/examples/anti_entropy/case_study/legacy_parser.py\n" + "deleted file mode 100644\n" + "--- a/examples/anti_entropy/case_study/legacy_parser.py\n" + "+++ /dev/null\n" + "@@ -1,30 +0,0 @@\n" + "-# Legacy message parser slated for retirement\n" + "-from typing import Any\n" + "-class LegacyMessageParser:\n" + "- pass\n" + "-def parse_legacy_frame(raw_data: str) -> dict[str, Any]:\n" + "- pass\n" + ) + + +def evaluate_arm(name: str, diff_text: str, basis_data: dict) -> dict: + result = run_anti_entropy_verify( + diff_text=diff_text, + basis_data=basis_data, + goal_id=f"eval-{name.lower()}", + ) + lines_added = sum(1 for line in diff_text.splitlines() if line.startswith("+") and not line.startswith("+++")) + lines_removed = sum(1 for line in diff_text.splitlines() if line.startswith("-") and not line.startswith("---")) + net_diff_lines = lines_added - lines_removed + + return { + "arm": name, + "functional_tests": "passed", + "anti_entropy_verdict": result["verdict"], + "retired_symbols": result["retired_symbols"], + "blocking_findings": result["blocking_finding_count"], + "advisory_findings": result["advisory_finding_count"], + "findings": [f["message"] for f in result["receipt"]["findings"]], + "net_diff_lines": net_diff_lines, + "overall_quality": "REJECTED (ENTROPIC)" if result["verdict"] != "passed" else "ACCEPTED (CLEAN)", + } + + +def main() -> int: + spec_path = Path("examples/anti_entropy/case_study/task_spec.json") + spec = json.loads(spec_path.read_text(encoding="utf-8")) + basis = spec["retirement_basis"] + + print("================================================================================") + print("Anti-Entropy Governance Arm Comparison (Three-Arm Factorial Contrast)") + print("================================================================================") + print(f"Task Objective: {spec["objective"]}") + print(f"Declared Retirement Targets: {basis["target_symbols"]}") + print(f"Declared Target Paths to Delete: {basis["target_paths"]}") + print("--------------------------------------------------------------------------------\n") + + arms = [ + ("Arm 1 (Baseline - No Governance)", generate_arm1_diff()), + ("Arm 2 (Control - Prompt Only)", generate_arm2_diff()), + ("Arm 3 (Treatment - Anti-Entropy Two-Tier)", generate_arm3_diff()), + ] + + for arm_name, diff in arms: + eval_res = evaluate_arm(arm_name, diff, basis) + print(f"[{eval_res["arm"]}]") + print(f" • Functional Tests: {eval_res["functional_tests"]}") + print(f" • Anti-Entropy Verdict: {eval_res["anti_entropy_verdict"]}") + print(f" • Overall Code Quality: {eval_res["overall_quality"]}") + print(f" • Net Diff Lines: {eval_res["net_diff_lines"]:+} lines") + print(f" • Cleanly Retired: {eval_res["retired_symbols"]}") + print(f" • Blocking Violations: {eval_res["blocking_findings"]}") + print(f" • Advisory Hygiene Flaws: {eval_res["advisory_findings"]}") + if eval_res["findings"]: + for f in eval_res["findings"]: + print(f" - Violation: {f}") + print() + + print("================================================================================") + print("Contrast Summary & Core Insights:") + print("1. Arm 1: Pass tests, BUT leaves legacy_parser.py behind as dead code. Net lines: 0.") + print("2. Arm 2: Pass tests, BUT leaves zombie classes and commented ghost code. Blocked by gate.") + print("3. Arm 3: Pass tests, clean /dev/null path deletion, 0 wrappers, net reduction: -27 lines.") + print("================================================================================") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/loopx/capabilities/anti_entropy/__init__.py b/loopx/capabilities/anti_entropy/__init__.py index 5b004cef88..8e1a2b3132 100644 --- a/loopx/capabilities/anti_entropy/__init__.py +++ b/loopx/capabilities/anti_entropy/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +from .agent_tool import anti_entropy_inspect from .catalog_entry import ANTI_ENTROPY_CATALOG_ENTRY from .cli import run_anti_entropy_verify from .contract import ( @@ -24,6 +25,7 @@ "AntiEntropyFinding", "AntiEntropyReceipt", "RetirementBasis", + "anti_entropy_inspect", "audit_ghost_code", "evaluate_diff_hygiene", "run_anti_entropy_verify", diff --git a/loopx/capabilities/anti_entropy/agent_tool.py b/loopx/capabilities/anti_entropy/agent_tool.py new file mode 100644 index 0000000000..cbd606ab7d --- /dev/null +++ b/loopx/capabilities/anti_entropy/agent_tool.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from typing import Any + +from .cli import run_anti_entropy_verify + + +def anti_entropy_inspect( + *, + diff_text: str, + retirement_basis: dict[str, Any] | None = None, + goal_id: str | None = None, +) -> dict[str, Any]: + """Inspect unified diff against a declared retirement basis and diff hygiene rules. + + This tool allows autonomous agents to verify that deprecated symbols and files + are genuinely decommissioned (delete-first) and that no ghost code or zombie + wrappers are reintroduced, returning an immutable structured receipt. + """ + return run_anti_entropy_verify( + diff_text=diff_text, + basis_data=retirement_basis, + goal_id=goal_id, + ) diff --git a/loopx/capabilities/anti_entropy/oracles.py b/loopx/capabilities/anti_entropy/oracles.py index f41b9b00dd..9949a38e9b 100644 --- a/loopx/capabilities/anti_entropy/oracles.py +++ b/loopx/capabilities/anti_entropy/oracles.py @@ -15,16 +15,55 @@ RetirementBasis, ) -_DEF_OR_CLASS_RE = re.compile(r"^\s*(?:async\s+def|def|class)\s+([A-Za-z_][A-Za-z0-9_]*)") +_PY_DEF_OR_CLASS_RE = re.compile(r"^\s*(?:async\s+def|def|class)\s+([A-Za-z_][A-Za-z0-9_]*)") + +_TS_DEF_PATTERNS = [ + # function / async function / export function + re.compile(r"^\s*(?:export\s+(?:default\s+)?)?(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)"), + # class / abstract class / export class + re.compile(r"^\s*(?:export\s+(?:default\s+)?)?(?:abstract\s+)?class\s+([A-Za-z_$][A-Za-z0-9_$]*)"), + # interface + re.compile(r"^\s*(?:export\s+)?interface\s+([A-Za-z_$][A-Za-z0-9_$]*)"), + # type alias: type Foo = ... or type Foo = ... + re.compile(r"^\s*(?:export\s+)?type\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:<[^>]*>)?\s*="), + # enum + re.compile(r"^\s*(?:export\s+)?(?:const\s+)?enum\s+([A-Za-z_$][A-Za-z0-9_$]*)"), + # const / let / var Foo = (...) => or = async (...) => (with optional generics) + re.compile( + r"^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?::[^=]+)?=\s*(?:async\s*)?(?:<[^>]*>)?\s*(?:\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>" + ), + # const / let / var Foo = function + re.compile( + r"^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?::[^=]+)?=\s*(?:async\s+)?function" + ), + # const / let / var Foo = (multiline signature start) + re.compile( + r"^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?::[^=]+)?=\s*(?:async\s*)?(?:<[^>]*>)?\s*\(" + ), +] + _COMMENT_LINE_RE = re.compile(r"^\s*(?:#|//)\s*(.+)$") +_DOC_EXTENSIONS = {".md", ".rst", ".txt", ".json", ".yaml", ".yml", ".toml"} +_TS_EXTENSIONS = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"} +_PY_EXTENSIONS = {".py", ".pyi"} + def _hash_diff(diff_text: str) -> str: return hashlib.sha256(diff_text.encode("utf-8")).hexdigest() -def unquote_git_path(path_str: str) -> str: - """Unquote and normalize Git C-style quoted paths with octal or hex escapes.""" +def find_repo_root(start_path: Path | None = None) -> Path: + """Resolve repository root by looking for .git folder up the tree.""" + cur = (start_path or Path.cwd()).resolve() + for parent in [cur, *cur.parents]: + if (parent / ".git").exists(): + return parent + return cur + + +def decode_git_diff_path(path_str: str, is_source: bool = True) -> str: + """Decode a Git diff path with optional C-style quotes, stripping Git a/ or b/ prefix.""" path_str = path_str.strip() if path_str.startswith('"') and path_str.endswith('"') and len(path_str) >= 2: inner = path_str[1:-1] @@ -34,9 +73,70 @@ def unquote_git_path(path_str: str) -> str: except Exception: path_str = inner path_str = path_str.replace("\\", "/").strip() - if path_str.startswith("a/") or path_str.startswith("b/"): + if path_str == "/dev/null": + return "/dev/null" + if is_source and path_str.startswith("a/"): + return path_str[2:] + elif not is_source and path_str.startswith("b/"): + return path_str[2:] + elif path_str.startswith("a/") or path_str.startswith("b/"): + return path_str[2:] + return path_str + + +def normalize_repo_path(path_str: str) -> str: + """Normalize a repository-relative target path from RetirementBasis. + + Does not strip real directory segments (e.g. "a/") or leading hidden dots (e.g. ".api.py"). + """ + path_str = path_str.strip() + if path_str.startswith('"') and path_str.endswith('"') and len(path_str) >= 2: + inner = path_str[1:-1] + try: + raw_bytes = codecs.escape_decode(inner.encode("latin1"))[0] + path_str = raw_bytes.decode("utf-8") + except Exception: + path_str = inner + path_str = path_str.replace("\\", "/").strip() + while path_str.startswith("./"): path_str = path_str[2:] - return path_str.lstrip("./").lstrip("/") + while path_str.startswith("/"): + path_str = path_str[1:] + return path_str + + +def unquote_git_path(path_str: str) -> str: + """Backward-compatible helper for unquoting git diff paths.""" + return decode_git_diff_path(path_str) + + +def parse_diff_git_header(line: str) -> tuple[str, str] | None: + """Parse diff --git line correctly, handling paths with spaces and quotes.""" + line = line.strip() + if not line.startswith("diff --git "): + return None + rest = line[len("diff --git "):].strip() + + # Case 1: Both quoted, e.g. "a/foo bar" "b/foo bar" + m_quoted = re.match(r'^("(?:[^"\\]|\\.)+")\s+("(?:[^"\\]|\\.)+")$', rest) + if m_quoted: + return m_quoted.group(1), m_quoted.group(2) + + # Case 2: Identical relative paths across a/ and b/ (deletion / modification) + m_same = re.match(r"^a/(.+)\s+b/\1$", rest) + if m_same: + return f"a/{m_same.group(1)}", f"b/{m_same.group(1)}" + + # Case 3: Search for separator " b/" from the right + if rest.startswith("a/"): + idx = rest.rfind(" b/") + if idx != -1: + return rest[:idx], rest[idx + 1:] + + parts = rest.split(None, 1) + if len(parts) == 2: + return parts[0], parts[1] + return None def _extract_deleted_paths(diff_text: str) -> set[str]: @@ -48,30 +148,38 @@ def _extract_deleted_paths(diff_text: str) -> set[str]: for sec in diff_sections: if not sec.startswith("diff --git "): continue - header_match = re.match(r"^diff --git\s+(.+?)\s+(.+?)(?:\n|$)", sec) - if not header_match: - continue - src_raw = header_match.group(1).strip() + first_line = sec.splitlines()[0] is_deleted = bool( re.search(r"^deleted file mode \d+", sec, re.MULTILINE) or re.search(r"^\+\+\+\s+/dev/null", sec, re.MULTILINE) ) if is_deleted: - clean_path = unquote_git_path(src_raw) - if clean_path: - deleted_paths.add(clean_path) + # Check for standard patch header: --- a/\n+++ /dev/null + m_patch = re.search(r"^---\s+(.+?)\n\+\+\+\s+/dev/null", sec, re.MULTILINE) + if m_patch: + clean_path = decode_git_diff_path(m_patch.group(1).strip(), is_source=True) + if clean_path: + deleted_paths.add(clean_path) + continue + + # Empty file deletion with no content hunks (deleted file mode 100644 only) + header_paths = parse_diff_git_header(first_line) + if header_paths: + clean_path = decode_git_diff_path(header_paths[0], is_source=True) + if clean_path: + deleted_paths.add(clean_path) # 2. Also match standard --- \n+++ /dev/null for diffs without diff --git for match in re.finditer(r"^---\s+(.+?)\n\+\+\+\s+/dev/null", diff_text, re.MULTILINE): raw_path = match.group(1).strip() - clean_path = unquote_git_path(raw_path) + clean_path = decode_git_diff_path(raw_path, is_source=True) if clean_path: deleted_paths.add(clean_path) return deleted_paths -def _extract_defs_from_code(code: str) -> set[str]: +def _extract_python_defs_from_code(code: str) -> set[str]: if not code.strip(): return set() dedented = textwrap.dedent(code) @@ -96,10 +204,10 @@ def _extract_defs_from_code(code: str) -> set[str]: return set() -def _extract_signature_defs(lines: list[str]) -> set[str]: +def _extract_python_signature_defs(lines: list[str]) -> set[str]: defs: set[str] = set() for line in lines: - m = _DEF_OR_CLASS_RE.match(line) + m = _PY_DEF_OR_CLASS_RE.match(line) if m: name = m.group(1) try: @@ -109,14 +217,50 @@ def _extract_signature_defs(lines: list[str]) -> set[str]: if node.name == name: defs.add(name) except SyntaxError: - pass + defs.add(name) + return defs + + +def _extract_ts_defs(lines: list[str]) -> set[str]: + defs: set[str] = set() + for line in lines: + line_clean = line.strip() + if not line_clean or line_clean.startswith("//") or line_clean.startswith("/*") or line_clean.startswith("*"): + continue + for pattern in _TS_DEF_PATTERNS: + m = pattern.match(line) + if m: + defs.add(m.group(1)) + break return defs -def _analyze_diff_symbols(diff_text: str) -> tuple[set[str], set[str], set[str]]: +def _extract_defs_for_file(lines: list[str], file_path: str = "") -> set[str]: + code_text = "\n".join(lines) + ext = Path(file_path).suffix.lower() if file_path else "" + + if ext in _TS_EXTENSIONS: + return _extract_ts_defs(lines) + elif ext in _PY_EXTENSIONS: + return _extract_python_defs_from_code(code_text) | _extract_python_signature_defs(lines) + else: + py_defs = _extract_python_defs_from_code(code_text) | _extract_python_signature_defs(lines) + ts_defs = _extract_ts_defs(lines) + return py_defs | ts_defs + + +def _analyze_diff_symbols(diff_text: str) -> tuple[set[str], set[str], set[str], set[str], list[str]]: + """Analyze diff to extract deleted, reintroduced, surviving context, and disk defined symbols, + + plus all added lines for reference auditing. + Returns: + (deleted_symbols, reintroduced_symbols, surviving_context_defs, still_defined_on_disk, all_added_lines) + """ deleted_symbols: set[str] = set() reintroduced_symbols: set[str] = set() + surviving_context_defs: set[str] = set() still_defined_on_disk: set[str] = set() + all_added_lines: list[str] = [] diff_text = textwrap.dedent(diff_text) file_diffs = re.split(r"(?=^diff --git |^--- )", diff_text, flags=re.MULTILINE) @@ -125,13 +269,14 @@ def _analyze_diff_symbols(diff_text: str) -> tuple[set[str], set[str], set[str]] continue file_path = "" - m_git = re.search(r"^diff --git\s+(.+?)\s+(.+?)(?:\n|$)", f_diff, re.MULTILINE) - if m_git: - file_path = unquote_git_path(m_git.group(2).strip()) + first_line = f_diff.splitlines()[0] if f_diff.startswith("diff --git ") else "" + header_paths = parse_diff_git_header(first_line) if first_line else None + if header_paths: + file_path = decode_git_diff_path(header_paths[1], is_source=False) else: m_patch = re.search(r"^\+\+\+\s+(.+?)(?:\n|$)", f_diff, re.MULTILINE) if m_patch: - file_path = unquote_git_path(m_patch.group(1).strip()) + file_path = decode_git_diff_path(m_patch.group(1).strip(), is_source=False) hunk_texts = re.split(r"(?=^@@\s+-\d+.*?\s+\+\d+.*?\s+@@)", f_diff, flags=re.MULTILINE) for h_text in hunk_texts: @@ -143,22 +288,32 @@ def _analyze_diff_symbols(diff_text: str) -> tuple[set[str], set[str], set[str]] post_lines: list[str] = [] deleted_lines: list[str] = [] added_lines: list[str] = [] + context_lines: list[str] = [] for line in lines: if line.startswith(" "): - pre_lines.append(line[1:]) - post_lines.append(line[1:]) + clean_line = line[1:] + pre_lines.append(clean_line) + post_lines.append(clean_line) + context_lines.append(clean_line) elif line.startswith("-") and not line.startswith("---"): - pre_lines.append(line[1:]) - deleted_lines.append(line[1:]) + clean_line = line[1:] + pre_lines.append(clean_line) + deleted_lines.append(clean_line) elif line.startswith("+") and not line.startswith("+++"): - post_lines.append(line[1:]) - added_lines.append(line[1:]) + clean_line = line[1:] + post_lines.append(clean_line) + added_lines.append(clean_line) + all_added_lines.append(clean_line) + + pre_defs = _extract_defs_for_file(pre_lines, file_path) + post_defs = _extract_defs_for_file(post_lines, file_path) + deleted_defs = _extract_defs_for_file(deleted_lines, file_path) + added_defs = _extract_defs_for_file(added_lines, file_path) + ctx_defs = _extract_defs_for_file(context_lines, file_path) - pre_defs = _extract_defs_from_code("\n".join(pre_lines)) - post_defs = _extract_defs_from_code("\n".join(post_lines)) - added_defs = _extract_defs_from_code("\n".join(added_lines)) | _extract_signature_defs(added_lines) - deleted_defs = _extract_defs_from_code("\n".join(deleted_lines)) | _extract_signature_defs(deleted_lines) + for s in ctx_defs: + surviving_context_defs.add(s) for s in pre_defs: if s not in post_defs: @@ -170,16 +325,20 @@ def _analyze_diff_symbols(diff_text: str) -> tuple[set[str], set[str], set[str]] for s in added_defs: reintroduced_symbols.add(s) - for s in post_defs: - if s in added_defs or s in deleted_defs: - reintroduced_symbols.add(s) - if file_path and file_path != "/dev/null": - disk_p = Path(file_path) + is_file_deleted = bool( + re.search(r"^deleted file mode \d+", f_diff, re.MULTILINE) + or re.search(r"^\+\+\+\s+/dev/null", f_diff, re.MULTILINE) + ) + if file_path and file_path != "/dev/null" and not is_file_deleted: + repo_root = find_repo_root() + disk_p = (repo_root / file_path).resolve() + if not disk_p.is_file(): + disk_p = Path(file_path).resolve() if disk_p.is_file(): try: content = disk_p.read_text(encoding="utf-8") - disk_defs = _extract_defs_from_code(content) + disk_defs = _extract_defs_for_file(content.splitlines(), file_path) for s in disk_defs: still_defined_on_disk.add(s) if s in deleted_symbols: @@ -187,7 +346,7 @@ def _analyze_diff_symbols(diff_text: str) -> tuple[set[str], set[str], set[str]] except Exception: pass - return deleted_symbols, reintroduced_symbols, still_defined_on_disk + return deleted_symbols, reintroduced_symbols, surviving_context_defs, still_defined_on_disk, all_added_lines def verify_delete_first( @@ -235,23 +394,35 @@ def verify_delete_first( ) return findings - deleted_symbols, reintroduced_symbols, still_defined_on_disk = _analyze_diff_symbols(diff_text) + deleted_symbols, reintroduced_symbols, surviving_context_defs, still_defined_on_disk, all_added_lines = _analyze_diff_symbols(diff_text) deleted_paths = _extract_deleted_paths(diff_text) for symbol in basis.target_symbols: - if symbol in reintroduced_symbols or symbol in still_defined_on_disk: + # Check if the symbol is reintroduced as definition OR actively referenced/called in added code + is_reintroduced = symbol in reintroduced_symbols + if not is_reintroduced: + sym_pattern = re.compile(r"\b" + re.escape(symbol) + r"\b") + for added_l in all_added_lines: + clean_added = added_l.strip() + if clean_added.startswith(("#", "//", "/*", "*")): + continue + if sym_pattern.search(clean_added): + is_reintroduced = True + break + + if is_reintroduced: findings.append( AntiEntropyFinding( rule_id="AE002-ZOMBIE_SYMBOL_REINTRODUCED", severity="blocking", message=( f"Target symbol '{symbol}' was slated for retirement but reintroduced " - "or retained as an active definition in the diff or repository." + "as an active definition or called in added lines in the diff or repository." ), location=symbol, ) ) - elif symbol not in deleted_symbols: + elif symbol not in deleted_symbols or symbol in surviving_context_defs or symbol in still_defined_on_disk: findings.append( AntiEntropyFinding( rule_id="AE001-DELETE_FIRST_VIOLATION", @@ -266,7 +437,7 @@ def verify_delete_first( ) for path in basis.target_paths: - norm_path = unquote_git_path(path) + norm_path = normalize_repo_path(path) if norm_path not in deleted_paths: findings.append( AntiEntropyFinding( @@ -289,47 +460,74 @@ def audit_ghost_code(diff_text: str) -> list[AntiEntropyFinding]: start_line = "" diff_text = textwrap.dedent(diff_text) - for line in diff_text.splitlines(): - if not line.startswith("+") or line.startswith("+++"): - consecutive_commented_code = 0 + file_diffs = re.split(r"(?=^diff --git |^--- )", diff_text, flags=re.MULTILINE) + + for f_diff in file_diffs: + if not f_diff.strip(): continue - raw = line[1:].strip() - comment_match = _COMMENT_LINE_RE.match(raw) - if comment_match: - comment_content = comment_match.group(1).strip() - is_code_stmt = False - if _DEF_OR_CLASS_RE.match(comment_content): - is_code_stmt = True - else: - try: - ast.parse(comment_content) - is_code_stmt = True - except SyntaxError: - is_code_stmt = False + file_path = "" + m_git = re.search(r"^diff --git\s+(.+?)\s+(.+?)(?:\n|$)", f_diff, re.MULTILINE) + if m_git: + file_path = unquote_git_path(m_git.group(2).strip()) + else: + m_patch = re.search(r"^\+\+\+\s+(.+?)(?:\n|$)", f_diff, re.MULTILINE) + if m_patch: + file_path = unquote_git_path(m_patch.group(1).strip()) - if is_code_stmt: - consecutive_commented_code += 1 - if consecutive_commented_code == 1: - start_line = raw + ext = Path(file_path).suffix.lower() if file_path else "" + if ext in _DOC_EXTENSIONS: + continue + + consecutive_commented_code = 0 + start_line = "" + + for line in f_diff.splitlines(): + if not line.startswith("+") or line.startswith("+++"): + consecutive_commented_code = 0 + continue + + raw = line[1:].strip() + comment_match = _COMMENT_LINE_RE.match(raw) + if comment_match: + comment_content = comment_match.group(1).strip() + is_code_stmt = False + + if _PY_DEF_OR_CLASS_RE.match(comment_content): + is_code_stmt = True + elif any(p.match(comment_content) for p in _TS_DEF_PATTERNS): + is_code_stmt = True + elif comment_content.endswith(";") or comment_content.startswith(("import ", "from ", "export ", "return ", "if ", "for ")): + is_code_stmt = True + else: + try: + ast.parse(comment_content) + is_code_stmt = True + except SyntaxError: + is_code_stmt = False + + if is_code_stmt: + consecutive_commented_code += 1 + if consecutive_commented_code == 1: + start_line = raw + else: + consecutive_commented_code = 0 else: consecutive_commented_code = 0 - else: - consecutive_commented_code = 0 - if consecutive_commented_code >= 3: - findings.append( - AntiEntropyFinding( - rule_id="AE003-COMMENTED_CODE_ADVISORY", - severity="advisory", - message=( - "Detected 3+ consecutive lines of commented-out code. " - "Consider complete removal under version control rather than ghost commenting." - ), - location=start_line[:80], + if consecutive_commented_code >= 3: + findings.append( + AntiEntropyFinding( + rule_id="AE003-COMMENTED_CODE_ADVISORY", + severity="advisory", + message=( + "Detected 3+ consecutive lines of commented-out code. " + "Consider complete removal under version control rather than ghost commenting." + ), + location=start_line[:80], + ) ) - ) - consecutive_commented_code = 0 + consecutive_commented_code = 0 return findings @@ -356,15 +554,22 @@ def evaluate_diff_hygiene( else RetirementBasis.from_dict(retirement_basis) ) if basis.target_symbols and diff_text.strip(): - deleted_symbols, reintroduced_symbols, still_defined_on_disk = _analyze_diff_symbols(diff_text) + deleted_symbols, reintroduced_symbols, surviving_context_defs, still_defined_on_disk, all_added_lines = _analyze_diff_symbols(diff_text) failing_symbols = { f.location for f in all_findings if f.location and f.rule_id in ("AE001-DELETE_FIRST_VIOLATION", "AE002-ZOMBIE_SYMBOL_REINTRODUCED") } for s in basis.target_symbols: + sym_pattern = re.compile(r"\b" + re.escape(s) + r"\b") + has_added_ref = any( + sym_pattern.search(l) for l in all_added_lines + if not l.strip().startswith(("#", "//", "/*", "*")) + ) if ( s in deleted_symbols and s not in reintroduced_symbols + and not has_added_ref + and s not in surviving_context_defs and s not in still_defined_on_disk and s not in failing_symbols ): diff --git a/tests/capabilities/test_anti_entropy.py b/tests/capabilities/test_anti_entropy.py index 62be6ecc96..615aa00520 100644 --- a/tests/capabilities/test_anti_entropy.py +++ b/tests/capabilities/test_anti_entropy.py @@ -401,3 +401,205 @@ def test_reintroduced_symbol_excluded_from_retired_symbols_with_partial_retireme assert any(f.rule_id == "AE002-ZOMBIE_SYMBOL_REINTRODUCED" and f.location == "old_api" for f in receipt.findings) # old_api is excluded from retired_symbols, but old_helper is accurately certified! assert receipt.retired_symbols == ("old_helper",) + + +def test_typescript_clean_delete_first() -> None: + """Verify TypeScript functions, classes, interfaces, and types are accurately recognized as retired.""" + diff = """ +--- a/control_plane.ts ++++ b/control_plane.ts +@@ -1,15 +1,2 @@ +-export function oldLeaseAcquire(taskId: string): boolean { +- return false; +-} +-export class OldScheduler { +- run(): void {} +-} +-export interface OldLeaseConfig { +- timeoutMs: number; +-} +-export type OldLeaseState = "active" | "expired"; +-export const oldArrowHelper = (x: number) => x * 2; ++export function newLeaseAcquire(): boolean { return true; } +""" + basis = RetirementBasis( + target_symbols=( + "oldLeaseAcquire", + "OldScheduler", + "OldLeaseConfig", + "OldLeaseState", + "oldArrowHelper", + ), + ) + receipt = evaluate_diff_hygiene(diff, basis) + assert receipt.verdict == "passed", f"findings: {receipt.findings}" + assert set(receipt.retired_symbols) == { + "oldLeaseAcquire", + "OldScheduler", + "OldLeaseConfig", + "OldLeaseState", + "oldArrowHelper", + } + + +def test_typescript_call_deletion_does_not_retire_definition() -> None: + """Verify deleting a TypeScript function call without deleting definition triggers AE001.""" + diff = """ +--- a/control_plane.ts ++++ b/control_plane.ts +@@ -1,7 +1,7 @@ + export function oldLeaseAcquire(): boolean { + return false; + } + export function run() { +- return oldLeaseAcquire(); ++ return newLeaseAcquire(); + } +""" + basis = RetirementBasis(target_symbols=("oldLeaseAcquire",)) + receipt = evaluate_diff_hygiene(diff, basis) + assert receipt.verdict == "rejected" + assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt.findings) + assert "oldLeaseAcquire" not in receipt.retired_symbols + + +def test_typescript_zombie_symbol_reintroduced() -> None: + """Verify reintroducing an old TypeScript function as a wrapper triggers AE002.""" + diff = """ +--- a/control_plane.ts ++++ b/control_plane.ts +@@ -1,4 +1,4 @@ +-export function oldTaskLease() { return 1; } ++export function oldTaskLease() { return newTaskLease(); } +""" + basis = RetirementBasis(target_symbols=("oldTaskLease",)) + receipt = evaluate_diff_hygiene(diff, basis) + assert receipt.verdict == "rejected" + assert any(f.rule_id == "AE002-ZOMBIE_SYMBOL_REINTRODUCED" for f in receipt.findings) + + +def test_offline_self_contained_diff_without_disk_access() -> None: + """Verify diff verification is self-contained and works for non-existent disk paths.""" + diff = """ +--- a/non_existent_dir/virtual_module.py ++++ b/non_existent_dir/virtual_module.py +@@ -1,5 +1,2 @@ +-def obsolete_worker(): +- return None ++def active_worker(): return 1 +""" + basis = RetirementBasis(target_symbols=("obsolete_worker",)) + receipt = evaluate_diff_hygiene(diff, basis) + assert receipt.verdict == "passed" + assert receipt.retired_symbols == ("obsolete_worker",) + + +def test_agent_tool_inspect_interface() -> None: + """Verify the structured agent tool anti_entropy_inspect returns complete, valid payload.""" + from loopx.capabilities.anti_entropy.agent_tool import anti_entropy_inspect + + diff = """ +--- a/service.ts ++++ b/service.ts +@@ -1,4 +1,2 @@ +-export function legacyHandler() {} ++export function modernHandler() {} +""" + payload = anti_entropy_inspect( + diff_text=diff, + retirement_basis={"target_symbols": ["legacyHandler"]}, + goal_id="goal-test-tool", + ) + assert payload["ok"] is True + assert payload["command"] == "verify" + assert payload["goal_id"] == "goal-test-tool" + assert payload["retired_symbols"] == ["legacyHandler"] + assert payload["verdict"] == "passed" + + +def test_path_normalization_distinguishes_a_dir_and_dot_files() -> None: + """Verify a/api.py, api.py, and .api.py are never conflated.""" + diff_delete_root = """ +--- a/api.py ++++ /dev/null +@@ -1,2 +0,0 @@ +-def f(): pass +""" + basis_a = RetirementBasis(target_paths=("a/api.py",)) + receipt_a = evaluate_diff_hygiene(diff_delete_root, basis_a) + assert receipt_a.verdict == "rejected" + assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" and f.location == "a/api.py" for f in receipt_a.findings) + + basis_dot = RetirementBasis(target_paths=(".api.py",)) + receipt_dot = evaluate_diff_hygiene(diff_delete_root, basis_dot) + assert receipt_dot.verdict == "rejected" + assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" and f.location == ".api.py" for f in receipt_dot.findings) + + diff_delete_nested_a = """ +--- a/a/api.py ++++ /dev/null +@@ -1,2 +0,0 @@ +-def f(): pass +""" + receipt_nested = evaluate_diff_hygiene(diff_delete_nested_a, basis_a) + assert receipt_nested.verdict == "passed" + assert len(receipt_nested.findings) == 0 + + diff_delete_dot = """ +--- a/.api.py ++++ /dev/null +@@ -1,2 +0,0 @@ +-def f(): pass +""" + receipt_dot_pass = evaluate_diff_hygiene(diff_delete_dot, basis_dot) + assert receipt_dot_pass.verdict == "passed" + assert len(receipt_dot_pass.findings) == 0 + + +def test_empty_file_deletion_with_spaces_in_filename() -> None: + """Verify unquoted git diff headers for empty files with spaces in names are correctly recognized.""" + diff_spaces = """ +diff --git a/legacy/old api.py b/legacy/old api.py +deleted file mode 100644 +index e69de29..0000000 +""" + basis = RetirementBasis(target_paths=("legacy/old api.py",)) + receipt = evaluate_diff_hygiene(diff_spaces, basis) + assert receipt.verdict == "passed", f"findings: {receipt.findings}" + assert len(receipt.findings) == 0 + + +def test_docstring_sample_not_certified_when_function_retained_on_disk(tmp_path: Path, monkeypatch) -> None: + """Verify deleting docstring example lines does not falsely certify a function retained on disk, even from subdirs.""" + repo_root = tmp_path / "mock_repo" + repo_root.mkdir() + (repo_root / ".git").mkdir() + subdir = repo_root / "nested" / "sub" + subdir.mkdir(parents=True) + + module_file = repo_root / "service.py" + module_file.write_text( + """\"\"\"\nExample:\n def old_api():\n pass\n\"\"\"\n\ndef old_api():\n return 42\n""", + encoding="utf-8", + ) + + diff_docstring = """ +--- a/service.py ++++ b/service.py +@@ -3,2 +3,0 @@ +- def old_api(): +- pass +""" + basis = RetirementBasis(target_symbols=("old_api",)) + + monkeypatch.chdir(repo_root) + receipt_root = evaluate_diff_hygiene(diff_docstring, basis) + assert receipt_root.verdict == "rejected" + assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt_root.findings) + assert "old_api" not in receipt_root.retired_symbols + + monkeypatch.chdir(subdir) + receipt_sub = evaluate_diff_hygiene(diff_docstring, basis) + assert receipt_sub.verdict == "rejected" + assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt_sub.findings) + assert "old_api" not in receipt_sub.retired_symbols From 86044052afc279ea2fe0aa91be979d1bc476fe7a Mon Sep 17 00:00:00 2001 From: LoopDesk Date: Thu, 29 Oct 2026 18:14:04 +0800 Subject: [PATCH 03/21] fix(anti-entropy): bind source evidence and wire task-scoped feedback loop Address PR #7545 review: require explicit workspace binding so symbol retirement is certified only against complete post-change sources (AE004 otherwise), parse bound sources with the AST instead of merging line signatures and free-text matches, exclude documentation files from symbol evidence, and record receipt provenance (basis_hash, source_root, source_evidence). Add loopx anti-entropy inspect as the in-flight progress-fact entry with the anti_entropy_inspect tool descriptor and turn-context projection; delivery acceptance stays owned by change-quality. Rewrite compare_arms.py as an honest isolated sandbox demonstration and fix Python 3.11 f-string syntax and ruff findings. Signed-off-by: LoopDesk --- .../anti-entropy-benchmark-decisions.md | 22 +- examples/anti-entropy-governance-smoke.py | 48 +- examples/anti_entropy/compare_arms.py | 363 +++++----- loopx/capabilities/anti_entropy/README.md | 75 ++- loopx/capabilities/anti_entropy/__init__.py | 8 + loopx/capabilities/anti_entropy/agent_tool.py | 7 +- .../anti_entropy/catalog_entry.py | 24 +- loopx/capabilities/anti_entropy/cli.py | 11 +- loopx/capabilities/anti_entropy/contract.py | 14 +- loopx/capabilities/anti_entropy/feedback.py | 211 ++++++ loopx/capabilities/anti_entropy/oracles.py | 619 ++++++++++++++---- loopx/cli_commands/anti_entropy.py | 96 +++ tests/capabilities/test_anti_entropy.py | 355 ++++++++-- .../test_anti_entropy_feedback.py | 177 +++++ 14 files changed, 1667 insertions(+), 363 deletions(-) create mode 100644 loopx/capabilities/anti_entropy/feedback.py create mode 100644 tests/capabilities/test_anti_entropy_feedback.py diff --git a/docs/decisions/anti-entropy-benchmark-decisions.md b/docs/decisions/anti-entropy-benchmark-decisions.md index fb1a3d9245..6ce25664ed 100644 --- a/docs/decisions/anti-entropy-benchmark-decisions.md +++ b/docs/decisions/anti-entropy-benchmark-decisions.md @@ -27,9 +27,9 @@ LoopX possesses an existing deterministic AST-based anti-entropy engine (`loopx/ - **Considered Alternatives**: - *Option 1*: Pure prompt guidance (soft constraint). Zero tooling overhead, but models consistently regress under multi-file pressure. - *Option 2*: Post-hoc terminal gate only. Verifies diff at submission; fails late when the agent has already exhausted budget, leading to blind rollback loops. - - *Option 3 (Selected)*: **Two-Tier Closed Loop (Execution Tool + Submission Gate)**. - - Exposes `anti_entropy_inspect` as an advisory tool during execution so the agent can self-audit diff cleanliness. - - Chains `loopx anti-entropy verify` into the delivery evaluation command, failing the run if `AntiEntropyReceipt.verdict != "passed"`. + - *Option 3 (Selected, partially delivered)*: **Two-Tier Closed Loop (Execution Tool + Submission Gate)**. + - Delivered now: the execution tier as an explicitly callable entry (`anti_entropy_inspect` tool descriptor plus `loopx anti-entropy inspect`) returning actionable progress-fact feedback, and the gate tier as a named delivery route — final blocking stays owned by the existing change-quality `prepare/record/verify` receipt, not a parallel authority. + - Not yet proven: that models actually adopt the tool and prefer deletion in long-horizon runs. That claim requires actual recorded runs and is intentionally not asserted by this ADR or the comparator. - **Trade-off**: Increases tool-call rounds and slight token overhead per run, in exchange for deterministic first-pass cleanliness and eliminating expensive blind rollbacks. --- @@ -40,7 +40,8 @@ LoopX possesses an existing deterministic AST-based anti-entropy engine (`loopx/ - *Option 2*: Dynamic runtime AST inference without declaration. Prone to false positives when agents refactor internal helper functions. - *Option 3 (Selected)*: **Declarative Sidecar / Task-Envelope Hook Pattern**. - The task specification explicitly provides an `anti_entropy_basis` (target symbols and target paths to retire). - - The benchmark/test runner wraps the task externally: conditionally injects the inspect tool into the agent envelope and appends the verify CLI command to the test command. + - Delivered now: the typed turn-context projection (`build_anti_entropy_turn_context`) a host can inject when the capability is enabled for the task, and the explicit CLI/tool entries agents call. + - Named follow-up (not delivered here): a runner that conditionally injects the inspect tool into a host agent envelope and appends the verify command to a test command. Owner: the host integration that owns agent envelopes; post-writeback typed intent stays with the provider-neutral post-writeback capability-hooks RFC, including failure isolation and idempotence. - `loopx/control_plane/` remains completely unmodified (0-line diff). - **Trade-off**: Requires test cases to declare their target retirement list in advance, rather than inferring intent magically. @@ -53,7 +54,7 @@ LoopX possesses an existing deterministic AST-based anti-entropy engine (`loopx/ - *Option 3 (Selected)*: **Self-Contained Realistic Engineering Playground in `examples/anti_entropy/`**. - A multi-module Python service (protocol parser + streaming receiver + client caller + unit test suite). - Real task: Migrate all callers from `LegacyProtocolParser` to `StreamMessageParser` and physically delete `legacy_parser.py`. - - Automated comparator evaluates Arm 1 (Baseline), Arm 2 (Prompt Only), and Arm 3 (Two-Tier Anti-Entropy). + - The comparator is a deterministic, fixed-input sandbox demonstration: per arm it seeds a disposable git sandbox, applies the fixed post-state, captures the real diff with git, executes the case-study functional tests with pytest, and evaluates the oracle against the bound sandbox workspace. It measures oracle semantics only — it launches no model runs and measures no agent-runtime behavior, so its output must not be read as an agent-effectiveness result. - **Trade-off**: Tests on a standalone engineering playground rather than mutating production LoopX code directly. --- @@ -67,7 +68,16 @@ LoopX possesses an existing deterministic AST-based anti-entropy engine (`loopx/ --- -## 4. Impacted System Components & Retired Paths +## 4. Delivered Boundary (This PR) + +- **Delivered**: default-off capability with explicit typed entries — `loopx anti-entropy verify` (delivery-gate semantics, exit 1 on rejection), `loopx anti-entropy inspect` (in-flight progress-fact feedback, exit 0), the `anti_entropy_inspect` tool descriptor registered in the capability catalog, the `build_anti_entropy_turn_context` projection, and the single-task feedback cycle classifying findings as actionable progress facts. Symbol retirement certification requires an explicitly bound workspace (`--workspace`); diff-only receipts may reject but never certify, and evidence provenance (`basis_hash`, `source_root`, `source_evidence`) is recorded on every receipt. +- **Delivery authority**: unchanged and owned by change-quality (`prepare/record/verify`, exact diff scope, receipt invalidation). This capability supplies evidence and names the delivery route; it writes no delivery receipt. +- **Not claimed**: model adoption of the loop in real agent runs, measured entropy reduction on live workloads, or any comparator-derived effectiveness number. Those require the recorded-run evaluation program (Decision 3) executed for real, and the envelope-injection runner from Decision 2. +- **Source binding invariants**: complete post-change sources are AST-parsed; documentation files never contribute symbol evidence; string literals, comments, and docstring examples are never counted as references or definitions; a missing bound source is evidence-insufficient (AE004), never silently skipped. + +--- + +## 5. Impacted System Components & Retired Paths - **Impacted Components**: - `docs/decisions/anti-entropy-benchmark-decisions.md`: Canonical ADR record. diff --git a/examples/anti-entropy-governance-smoke.py b/examples/anti-entropy-governance-smoke.py index 0f8ab206b4..edfc4e149a 100755 --- a/examples/anti-entropy-governance-smoke.py +++ b/examples/anti-entropy-governance-smoke.py @@ -1,20 +1,32 @@ #!/usr/bin/env python3 -"""Smoke test for anti-entropy governance: AST-based delete-first verification and diff hygiene.""" +"""Smoke test for anti-entropy governance: AST-based delete-first verification and diff hygiene. + +Exercises the source-binding contract: symbol retirement certification requires +an explicitly bound workspace with the complete post-change sources; diff-only +evidence can reject but never certifies. +""" from __future__ import annotations import sys +import tempfile from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(REPO_ROOT)) -from loopx.capabilities.anti_entropy.cli import run_anti_entropy_verify -from loopx.capabilities.anti_entropy.contract import ( +from loopx.capabilities.anti_entropy.cli import run_anti_entropy_verify # noqa: E402 +from loopx.capabilities.anti_entropy.contract import ( # noqa: E402 ANTI_ENTROPY_CAPABILITY_ID, RetirementBasis, ) -from loopx.capabilities.anti_entropy.oracles import evaluate_diff_hygiene +from loopx.capabilities.anti_entropy.oracles import evaluate_diff_hygiene # noqa: E402 + + +def _bound_workspace(content: str) -> Path: + workspace = Path(tempfile.mkdtemp(prefix="ae_smoke_ws_")) + (workspace / "calculator.py").write_text(content, encoding="utf-8") + return workspace def test_clean_delete_first() -> None: @@ -27,13 +39,32 @@ def test_clean_delete_first() -> None: +def new_add(a: int, b: int) -> int: + return a + b """ + workspace = _bound_workspace("def new_add(a: int, b: int) -> int:\n return a + b\n") basis = RetirementBasis( target_symbols=("old_add",), rationale="Replaced by new_add", ) - receipt = evaluate_diff_hygiene(diff, basis) + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) assert receipt.verdict == "passed", f"expected passed, got {receipt.verdict}" assert "old_add" in receipt.retired_symbols + assert receipt.source_evidence == "workspace_sources" + + +def test_diff_only_evidence_cannot_certify() -> None: + diff = """ +--- a/calculator.py ++++ b/calculator.py +@@ -1,5 +1,5 @@ +-def old_add(a: int, b: int) -> int: +- return a + b ++def new_add(a: int, b: int) -> int: ++ return a + b +""" + basis = RetirementBasis(target_symbols=("old_add",)) + receipt = evaluate_diff_hygiene(diff, basis) + assert receipt.verdict == "rejected", f"expected rejected, got {receipt.verdict}" + assert any(f.rule_id == "AE004-INSUFFICIENT_SOURCE_EVIDENCE" for f in receipt.findings) + assert receipt.retired_symbols == () def test_delete_first_violation() -> None: @@ -46,11 +77,12 @@ def test_delete_first_violation() -> None: def old_add(a: int, b: int) -> int: return a + b """ + workspace = _bound_workspace("def new_add(a: int, b: int) -> int:\n return a + b\ndef old_add(a: int, b: int) -> int:\n return a + b\n") basis = RetirementBasis( target_symbols=("old_add",), allow_compat_seam=False, ) - receipt = evaluate_diff_hygiene(diff, basis) + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) assert receipt.verdict == "rejected", f"expected rejected, got {receipt.verdict}" assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt.findings) @@ -79,10 +111,13 @@ def test_cli_runner() -> None: +class NewService: pass """ + workspace = Path(tempfile.mkdtemp(prefix="ae_smoke_ws_")) + (workspace / "module.py").write_text("class NewService:\n pass\n", encoding="utf-8") payload = run_anti_entropy_verify( diff_text=diff, basis_data={"target_symbols": ["OldService"]}, goal_id="test-goal-1", + workspace=workspace, ) assert payload["ok"] is True assert payload["capability_id"] == ANTI_ENTROPY_CAPABILITY_ID @@ -91,6 +126,7 @@ def test_cli_runner() -> None: def main() -> int: test_clean_delete_first() + test_diff_only_evidence_cannot_certify() test_delete_first_violation() test_zombie_reintroduced() test_cli_runner() diff --git a/examples/anti_entropy/compare_arms.py b/examples/anti_entropy/compare_arms.py index b0ac36ac51..d8c20fa620 100644 --- a/examples/anti_entropy/compare_arms.py +++ b/examples/anti_entropy/compare_arms.py @@ -1,187 +1,234 @@ -"""Comparator script evaluating Arms on the realistic refactoring case study. +"""Isolated end-to-end demonstration of anti-entropy governance on a fixed refactor task. -Measures the concrete difference between: -- Arm 1 (Baseline): No governance (leaves legacy files/wrappers; passes functional tests, fails cleanliness) -- Arm 2 (Control): Prompt-only soft constraint (leaves ghost commented code / shims) -- Arm 3 (Treatment): Anti-entropy two-tier closed loop (AST delete-first + verification gate) +For each arm the script executes a real isolated change inside a disposable git +sandbox: it seeds the case-study pre-state, writes the arm's fixed post-state, +captures the real unified diff with git, runs the case-study functional test +suite with pytest, and evaluates the anti-entropy oracle against the sandbox +workspace as the explicitly bound post-change source. + +This is a deterministic demonstration on hand-authored post-state fixtures. It +launches no model runs and measures no agent-runtime behavior; functional-test +results below are real executions of the seeded case-study tests. """ from __future__ import annotations import json +import os +import shutil +import subprocess import sys +import tempfile from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(REPO_ROOT)) - -from loopx.capabilities.anti_entropy.cli import run_anti_entropy_verify - - -def generate_arm1_diff() -> str: - """Arm 1: Agent modifies client.py but keeps legacy_parser.py as unused dead code.""" - return ( - "diff --git a/examples/anti_entropy/case_study/client.py b/examples/anti_entropy/case_study/client.py\n" - "--- a/examples/anti_entropy/case_study/client.py\n" - "+++ b/examples/anti_entropy/case_study/client.py\n" - "@@ -2,7 +2,7 @@\n" - " from typing import Any\n" - "-from .legacy_parser import LegacyMessageParser\n" - "+from .stream_parser import StreamMessageParser\n" - " \n" - " \n" - " class DataConsumer:\n" - "@@ -10,6 +10,6 @@\n" - " def __init__(self) -> None:\n" - "- self._parser = LegacyMessageParser()\n" - "+ self._parser = StreamMessageParser()\n" - " \n" - " def process_message(self, raw_input: str) -> dict[str, Any]:\n" - "- result = self._parser.decode(raw_input)\n" - "+ result = self._parser.parse(raw_input)\n" - " return {\n" - ) - - -def generate_arm2_diff() -> str: - """Arm 2: Agent edits legacy_parser.py but leaves ghost commented-out code and a shim.""" - return ( - "diff --git a/examples/anti_entropy/case_study/client.py b/examples/anti_entropy/case_study/client.py\n" - "--- a/examples/anti_entropy/case_study/client.py\n" - "+++ b/examples/anti_entropy/case_study/client.py\n" - "@@ -2,7 +2,7 @@\n" - " from typing import Any\n" - "-from .legacy_parser import LegacyMessageParser\n" - "+from .stream_parser import StreamMessageParser\n" - " \n" - " \n" - " class DataConsumer:\n" - "@@ -10,6 +10,6 @@\n" - " def __init__(self) -> None:\n" - "- self._parser = LegacyMessageParser()\n" - "+ self._parser = StreamMessageParser()\n" - " \n" - " def process_message(self, raw_input: str) -> dict[str, Any]:\n" - "- result = self._parser.decode(raw_input)\n" - "+ result = self._parser.parse(raw_input)\n" - " return {\n" - "diff --git a/examples/anti_entropy/case_study/legacy_parser.py b/examples/anti_entropy/case_study/legacy_parser.py\n" - "--- a/examples/anti_entropy/case_study/legacy_parser.py\n" - "+++ b/examples/anti_entropy/case_study/legacy_parser.py\n" - "@@ -4,15 +4,5 @@\n" - "-class LegacyMessageParser:\n" - "- def __init__(self, delimiter: str = \"|\") -> None:\n" - "- self.delimiter = delimiter\n" - "- def decode(self, raw_data: str) -> dict[str, Any]:\n" - "- pass\n" - "-def parse_legacy_frame(raw_data: str) -> dict[str, Any]:\n" - "- pass\n" - "+# Deprecated: do not use\n" - "+# def parse_legacy_frame(raw_data: str) -> dict[str, Any]:\n" - "+# return LegacyMessageParser().decode(raw_data)\n" - "+class LegacyMessageParser:\n" - "+ pass\n" - ) +CASE_STUDY_REL = "examples/anti_entropy/case_study" +sys.path.insert(0, str(REPO_ROOT)) -def generate_arm3_diff() -> str: - """Arm 3: Agent cleanly deletes legacy_parser.py (/dev/null) and updates client.py.""" - return ( - "diff --git a/examples/anti_entropy/case_study/client.py b/examples/anti_entropy/case_study/client.py\n" - "--- a/examples/anti_entropy/case_study/client.py\n" - "+++ b/examples/anti_entropy/case_study/client.py\n" - "@@ -2,7 +2,7 @@\n" - " from typing import Any\n" - "-from .legacy_parser import LegacyMessageParser\n" - "+from .stream_parser import StreamMessageParser\n" - " \n" - " \n" - " class DataConsumer:\n" - "@@ -10,6 +10,6 @@\n" - " def __init__(self) -> None:\n" - "- self._parser = LegacyMessageParser()\n" - "+ self._parser = StreamMessageParser()\n" - " \n" - " def process_message(self, raw_input: str) -> dict[str, Any]:\n" - "- result = self._parser.decode(raw_input)\n" - "+ result = self._parser.parse(raw_input)\n" - " return {\n" - "diff --git a/examples/anti_entropy/case_study/legacy_parser.py b/examples/anti_entropy/case_study/legacy_parser.py\n" - "deleted file mode 100644\n" - "--- a/examples/anti_entropy/case_study/legacy_parser.py\n" - "+++ /dev/null\n" - "@@ -1,30 +0,0 @@\n" - "-# Legacy message parser slated for retirement\n" - "-from typing import Any\n" - "-class LegacyMessageParser:\n" - "- pass\n" - "-def parse_legacy_frame(raw_data: str) -> dict[str, Any]:\n" - "- pass\n" +from loopx.capabilities.anti_entropy.cli import run_anti_entropy_verify # noqa: E402 + +_CLIENT_POST = '''"""Consumer client migrated to the modern stream parser.""" + +from typing import Any +from .stream_parser import StreamMessageParser + + +class DataConsumer: + """High-level data consumer.""" + + def __init__(self) -> None: + self._parser = StreamMessageParser() + + def process_message(self, raw_input: str) -> dict[str, Any]: + result = self._parser.parse(raw_input) + return { + "status": "ok", + "content": result["payload"], + "length": result["size"], + } +''' + +_LEGACY_ZOMBIE_POST = '''# Deprecated: do not use +# def parse_legacy_frame(raw_data: str) -> dict[str, Any]: +# return LegacyMessageParser().decode(raw_data) +class LegacyMessageParser: + pass +''' + +_CASE_STUDY_FILES = ( + "client.py", + "legacy_parser.py", + "stream_parser.py", + "tests/test_client.py", +) + +# Each arm is a fixed hand-authored post-state applied to the seeded pre-state. +# None means the file is deleted by this arm. +_ARM_POST_STATES: dict[str, dict[str, str | None]] = { + "Arm 1 (Baseline Pattern: legacy file kept)": { + f"{CASE_STUDY_REL}/client.py": _CLIENT_POST, + }, + "Arm 2 (Prompt-Only Pattern: zombie + ghost comments)": { + f"{CASE_STUDY_REL}/client.py": _CLIENT_POST, + f"{CASE_STUDY_REL}/legacy_parser.py": _LEGACY_ZOMBIE_POST, + }, + "Arm 3 (Anti-Entropy Pattern: clean /dev/null deletion)": { + f"{CASE_STUDY_REL}/client.py": _CLIENT_POST, + f"{CASE_STUDY_REL}/legacy_parser.py": None, + }, +} + + +def _run(cmd: list[str], cwd: Path, env: dict[str, str] | None = None) -> tuple[int, str]: + proc = subprocess.run( + cmd, + cwd=cwd, + capture_output=True, + env=env, + text=False, ) - - -def evaluate_arm(name: str, diff_text: str, basis_data: dict) -> dict: - result = run_anti_entropy_verify( - diff_text=diff_text, - basis_data=basis_data, - goal_id=f"eval-{name.lower()}", + output = (proc.stdout + proc.stderr).decode("utf-8", errors="replace") + return proc.returncode, output + + +def _seed_sandbox(sandbox: Path) -> None: + """Seed the case-study pre-state and commit it as the sandbox baseline.""" + case_study_src = REPO_ROOT / CASE_STUDY_REL + for rel in _CASE_STUDY_FILES: + target = sandbox / CASE_STUDY_REL / rel + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(case_study_src / rel, target) + # The task spec is the authoritative input declaring the retirement basis. + shutil.copy2(case_study_src / "task_spec.json", sandbox / CASE_STUDY_REL / "task_spec.json") + _run(["git", "init", "-q"], sandbox) + _run(["git", "add", "-A"], sandbox) + rc, out = _run( + [ + "git", + "-c", + "user.name=anti-entropy-demo", + "-c", + "user.email=demo@localhost", + "commit", + "-qm", + "seed case-study pre-state", + ], + sandbox, ) - lines_added = sum(1 for line in diff_text.splitlines() if line.startswith("+") and not line.startswith("+++")) - lines_removed = sum(1 for line in diff_text.splitlines() if line.startswith("-") and not line.startswith("---")) - net_diff_lines = lines_added - lines_removed - - return { - "arm": name, - "functional_tests": "passed", - "anti_entropy_verdict": result["verdict"], - "retired_symbols": result["retired_symbols"], - "blocking_findings": result["blocking_finding_count"], - "advisory_findings": result["advisory_finding_count"], - "findings": [f["message"] for f in result["receipt"]["findings"]], - "net_diff_lines": net_diff_lines, - "overall_quality": "REJECTED (ENTROPIC)" if result["verdict"] != "passed" else "ACCEPTED (CLEAN)", - } + if rc != 0: + raise RuntimeError(f"sandbox seed commit failed: {out}") + + +def _capture_diff(sandbox: Path) -> str: + rc, diff_text = _run(["git", "diff"], sandbox) + if rc != 0: + raise RuntimeError(f"git diff failed in sandbox: {diff_text}") + return diff_text + + +def _run_functional_tests(sandbox: Path) -> tuple[str, str]: + test_rel = f"{CASE_STUDY_REL}/tests/test_client.py" + env = dict(os.environ) + env["PYTHONPATH"] = str(sandbox) + rc, output = _run([sys.executable, "-m", "pytest", test_rel, "-q"], sandbox, env=env) + if rc == 0: + return "passed (executed in sandbox)", output + return f"failed (exit {rc}, executed in sandbox)", output + + +def evaluate_arm(name: str, post_state: dict[str, str | None], basis_data: dict) -> dict: + sandbox = Path(tempfile.mkdtemp(prefix="ae_arm_")) + try: + _seed_sandbox(sandbox) + for rel, content in post_state.items(): + target = sandbox / rel + if content is None: + target.unlink() + else: + target.write_text(content, encoding="utf-8") + + diff_text = _capture_diff(sandbox) + tests_result, tests_output = _run_functional_tests(sandbox) + result = run_anti_entropy_verify( + diff_text=diff_text, + basis_data=basis_data, + goal_id=f"fixture-{abs(hash(name)) % 10000}", + workspace=sandbox, + ) + + lines_added = sum( + 1 for line in diff_text.splitlines() if line.startswith("+") and not line.startswith("+++") + ) + lines_removed = sum( + 1 for line in diff_text.splitlines() if line.startswith("-") and not line.startswith("---") + ) + + return { + "arm": name, + "functional_tests": tests_result, + "functional_tests_output_tail": tests_output.strip().splitlines()[-1:] or [""], + "anti_entropy_verdict": result["verdict"], + "retired_symbols": result["retired_symbols"], + "blocking_findings": result["blocking_finding_count"], + "advisory_findings": result["advisory_finding_count"], + "findings": [f["message"] for f in result["receipt"]["findings"]], + "net_diff_lines": lines_added - lines_removed, + "source_evidence": result["receipt"]["source_evidence"], + } + finally: + shutil.rmtree(sandbox, ignore_errors=True) def main() -> int: - spec_path = Path("examples/anti_entropy/case_study/task_spec.json") + spec_path = REPO_ROOT / CASE_STUDY_REL / "task_spec.json" + if not spec_path.is_file(): + print(f"Notice: task specification not found at {spec_path}, demonstration skipped.") + return 0 + spec = json.loads(spec_path.read_text(encoding="utf-8")) - basis = spec["retirement_basis"] + basis = spec.get("retirement_basis", {}) + + objective = spec.get("objective", "") + target_symbols = basis.get("target_symbols", []) + target_paths = basis.get("target_paths", []) print("================================================================================") - print("Anti-Entropy Governance Arm Comparison (Three-Arm Factorial Contrast)") + print("Anti-Entropy Governance Isolated Sandbox Demonstration") print("================================================================================") - print(f"Task Objective: {spec["objective"]}") - print(f"Declared Retirement Targets: {basis["target_symbols"]}") - print(f"Declared Target Paths to Delete: {basis["target_paths"]}") + print(f"Task Objective: {objective}") + print(f"Declared Retirement Targets: {target_symbols}") + print(f"Declared Target Paths to Delete: {target_paths}") + print("Each arm: seeded git sandbox -> fixed post-state -> real git diff ->") + print("real pytest run -> oracle verify bound to the sandbox workspace.") print("--------------------------------------------------------------------------------\n") - arms = [ - ("Arm 1 (Baseline - No Governance)", generate_arm1_diff()), - ("Arm 2 (Control - Prompt Only)", generate_arm2_diff()), - ("Arm 3 (Treatment - Anti-Entropy Two-Tier)", generate_arm3_diff()), - ] - - for arm_name, diff in arms: - eval_res = evaluate_arm(arm_name, diff, basis) - print(f"[{eval_res["arm"]}]") - print(f" • Functional Tests: {eval_res["functional_tests"]}") - print(f" • Anti-Entropy Verdict: {eval_res["anti_entropy_verdict"]}") - print(f" • Overall Code Quality: {eval_res["overall_quality"]}") - print(f" • Net Diff Lines: {eval_res["net_diff_lines"]:+} lines") - print(f" • Cleanly Retired: {eval_res["retired_symbols"]}") - print(f" • Blocking Violations: {eval_res["blocking_findings"]}") - print(f" • Advisory Hygiene Flaws: {eval_res["advisory_findings"]}") - if eval_res["findings"]: - for f in eval_res["findings"]: - print(f" - Violation: {f}") + for arm_name, post_state in _ARM_POST_STATES.items(): + res = evaluate_arm(arm_name, post_state, basis) + print(f"[{res['arm']}]") + print(f" • Functional Tests: {res['functional_tests']}") + if res["functional_tests_output_tail"]: + print(f" - pytest tail: {res['functional_tests_output_tail'][0]}") + print(f" • Anti-Entropy Verdict: {res['anti_entropy_verdict']}") + print(f" • Evidence Binding: {res['source_evidence']}") + print(f" • Net Diff Lines: {res['net_diff_lines']:+} lines") + print(f" • Cleanly Retired: {res['retired_symbols']}") + print(f" • Blocking Violations: {res['blocking_findings']}") + print(f" • Advisory Hygiene Flaws: {res['advisory_findings']}") + if res["findings"]: + for f in res["findings"]: + print(f" - Finding: {f}") print() print("================================================================================") - print("Contrast Summary & Core Insights:") - print("1. Arm 1: Pass tests, BUT leaves legacy_parser.py behind as dead code. Net lines: 0.") - print("2. Arm 2: Pass tests, BUT leaves zombie classes and commented ghost code. Blocked by gate.") - print("3. Arm 3: Pass tests, clean /dev/null path deletion, 0 wrappers, net reduction: -27 lines.") + print("Demonstration Reading (oracle semantics only, no agent-runtime measurement):") + print("1. Functional tests alone pass whenever the caller migrates, in every arm.") + print("2. Only the oracle binds the declared retirement basis to complete post-change") + print(" sources: kept legacy files and zombie definitions stay blocking (AE001),") + print(" active references stay blocking (AE002), and 3+ line commented-out code") + print(" blocks stay advisory (AE003).") + print("3. Arm 3 achieves verified retirement: clean /dev/null deletion, zero blocking") + print(" findings, retired symbols certified against the bound workspace.") print("================================================================================") return 0 diff --git a/loopx/capabilities/anti_entropy/README.md b/loopx/capabilities/anti_entropy/README.md index 5abac687b4..98b7feead5 100644 --- a/loopx/capabilities/anti_entropy/README.md +++ b/loopx/capabilities/anti_entropy/README.md @@ -17,10 +17,13 @@ The Anti-Entropy capability addresses this through: 1. **AST-Based Delete-First Enforcement (`verify_delete_first`)**: Uses Python's syntax tree parser to verify that declared retirement targets (functions and classes) are genuinely removed from the codebase rather than - merely bypassed or wrapped. + merely bypassed or wrapped. Symbol retirement is a derived fact about + **complete post-change sources**, not about diff text alone. 2. **Zombie Symbol Prevention**: Detects if a symbol marked for retirement is reintroduced as a new definition - in newly added code lines. + in the diff, or is still referenced by active identifiers in the bound + post-change sources (string literals, comments, and docstring examples are + never counted as references). 3. **Target Paths Verification**: Ensures that declared legacy file paths slated for deletion are completely removed in the unified diff (`/dev/null`). @@ -29,7 +32,54 @@ The Anti-Entropy capability addresses this through: quality findings without blocking legitimate documentation or explanatory comments. 5. **Durable Receipt Generation (`AntiEntropyReceipt`)**: Runs deterministic hygiene oracles against the exact git diff and produces - an immutable receipt recording verified retired symbols and findings. + an immutable receipt recording verified retired symbols, findings, and the + evidence provenance (`basis_hash`, `source_root`, `source_evidence`). + +## Source Binding And Evidence Rules + +Verification distinguishes diff-visible facts from workspace-derived facts: + +- `--workspace ` explicitly binds the directory holding the complete + post-change sources. The bound workspace — never the caller's CWD — is the + only disk source read, and its complete files are parsed with the AST + (line-signature matching is limited to unparseable in-diff code fragments; + documentation files never contribute symbol evidence). +- **AE004 (insufficient source evidence, blocking)**: without a bound + workspace, or when a diff-touched source file is missing from the bound + workspace, symbol retirement cannot be certified. A diff-only receipt may + reject, but never certifies retirement, and `retired_symbols` stays empty. + A path deleted in the diff but still present in the bound workspace is + reported as a patch/workspace mismatch. +- **AE001/AE002 remain hard gates** in this command; **AE003 stays advisory**. + `allow_compat_seam=true` turns a retained-but-authorized definition into an + advisory finding, and it is never listed as retired. +- The receipt records which sources backed the verdict; a `diff_hash` alone + does not prove anything about the task, the sources, or the policy. + +## Single-Task Feedback Loop (Default-Off) + +The capability ships disabled (`default_enabled: false`); enabling it for a +task is an explicit choice. When enabled, one closed loop per task is: + +1. **Task context** — `build_anti_entropy_turn_context(basis, goal_id, + workspace)` projects the declared `RetirementBasis` into a typed + turn-context payload a host can inject into agent input. Building the + payload executes nothing. +2. **In-flight check** — agents call the `anti_entropy_inspect` tool (or + `loopx anti-entropy inspect`). Blocking findings come back as + **actionable feedback with per-rule next actions**, classified as progress + facts, not final delivery failures; the CLI exits 0 whenever the check ran. +3. **Delivery verification** — final acceptance stays owned by the existing + change-quality owner (`loopx change-quality prepare/record/verify`, exact + diff scope and receipt invalidation). This capability supplies evidence and + names the delivery route; it writes no receipt store and creates no + parallel delivery authority. +4. **Post-writeback (named follow-up)** — typed post-writeback intent bound to + the source receipt remains owned by the provider-neutral post-writeback + capability-hooks RFC, including failure isolation and idempotence. + +Model adoption of the loop is proven by actual runs, not by this protocol; +tests here cover the typed protocol and the real callable entries. ## Attribution & Acknowledgments @@ -45,10 +95,16 @@ Inspect the capability and its registered protocols: loopx capability show anti-entropy-governance --format json ``` -Verify diff hygiene against a declared retirement basis: +In-flight inspection with actionable feedback (progress-fact semantics): ```bash -loopx anti-entropy verify --goal-id --diff-file --basis-json --format json +loopx anti-entropy inspect --goal-id --diff-file --basis-json --workspace --format json +``` + +Delivery-gate verification (non-zero exit on rejection): + +```bash +loopx anti-entropy verify --goal-id --diff-file --basis-json --workspace --format json ``` Example `basis.json`: @@ -62,10 +118,19 @@ Example `basis.json`: } ``` +A deterministic end-to-end demonstration on fixed fixtures lives in +`examples/anti_entropy/compare_arms.py`: it seeds a disposable git sandbox per +arm, applies the arm's fixed post-state, captures the real diff with git, runs +the case-study functional tests with pytest, and evaluates the oracle against +the sandbox workspace. It measures the oracle's semantics only — it launches +no model runs and measures no agent-runtime behavior. + ## Boundaries - The capability is default-off and operates as an opt-in code hygiene verifier via CLI and typed receipts. - Delete-first requires target old logic to be cleanly decommissioned in the diff without re-introducing wrappers or zombie symbols. +- Symbol retirement certification requires an explicitly bound workspace; diff-only evidence can reject but never certifies. - AST-based diff hygiene verifies exact code diffs without mutating repository code or performing speculative git writes. - Target paths declared for decommissioning must be cleanly removed in the unified diff. - Advisory findings highlight non-blocking hygiene opportunities (such as dead commented code) without causing false positive rejections of documentation or explanations. +- Delivery acceptance and receipts of record remain owned by the change-quality capability; this capability never writes delivery receipts. diff --git a/loopx/capabilities/anti_entropy/__init__.py b/loopx/capabilities/anti_entropy/__init__.py index 8e1a2b3132..e5c81f561c 100644 --- a/loopx/capabilities/anti_entropy/__init__.py +++ b/loopx/capabilities/anti_entropy/__init__.py @@ -11,6 +11,11 @@ AntiEntropyReceipt, RetirementBasis, ) +from .feedback import ( + ANTI_ENTROPY_INSPECT_TOOL_DESCRIPTOR, + build_anti_entropy_turn_context, + run_anti_entropy_feedback_cycle, +) from .oracles import ( audit_ghost_code, evaluate_diff_hygiene, @@ -20,6 +25,7 @@ __all__ = [ "ANTI_ENTROPY_CAPABILITY_ID", "ANTI_ENTROPY_CATALOG_ENTRY", + "ANTI_ENTROPY_INSPECT_TOOL_DESCRIPTOR", "ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION", "ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION", "AntiEntropyFinding", @@ -27,7 +33,9 @@ "RetirementBasis", "anti_entropy_inspect", "audit_ghost_code", + "build_anti_entropy_turn_context", "evaluate_diff_hygiene", + "run_anti_entropy_feedback_cycle", "run_anti_entropy_verify", "verify_delete_first", ] diff --git a/loopx/capabilities/anti_entropy/agent_tool.py b/loopx/capabilities/anti_entropy/agent_tool.py index cbd606ab7d..d9dc13ffc9 100644 --- a/loopx/capabilities/anti_entropy/agent_tool.py +++ b/loopx/capabilities/anti_entropy/agent_tool.py @@ -1,5 +1,6 @@ from __future__ import annotations +from pathlib import Path from typing import Any from .cli import run_anti_entropy_verify @@ -10,15 +11,19 @@ def anti_entropy_inspect( diff_text: str, retirement_basis: dict[str, Any] | None = None, goal_id: str | None = None, + workspace: str | Path | None = None, ) -> dict[str, Any]: """Inspect unified diff against a declared retirement basis and diff hygiene rules. This tool allows autonomous agents to verify that deprecated symbols and files are genuinely decommissioned (delete-first) and that no ghost code or zombie - wrappers are reintroduced, returning an immutable structured receipt. + wrappers are reintroduced, returning an immutable structured receipt. Symbol + retirement certification requires an explicitly bound ``workspace`` holding + the complete post-change sources. """ return run_anti_entropy_verify( diff_text=diff_text, basis_data=retirement_basis, goal_id=goal_id, + workspace=workspace, ) diff --git a/loopx/capabilities/anti_entropy/catalog_entry.py b/loopx/capabilities/anti_entropy/catalog_entry.py index b3b1858a3a..062e8e53b7 100644 --- a/loopx/capabilities/anti_entropy/catalog_entry.py +++ b/loopx/capabilities/anti_entropy/catalog_entry.py @@ -31,7 +31,7 @@ "re-introducing wrappers, zombie symbols, or ghost code." ), "entry_command": ( - "loopx anti-entropy verify --goal-id --diff-file --format json" + "loopx anti-entropy inspect --goal-id --diff-file --workspace --format json" ), "workflow_skill": { "name": "loopx-anti-entropy", @@ -41,16 +41,36 @@ }, "commands": [ { - "command": "loopx anti-entropy verify --goal-id --diff-file --format json", + "command": "loopx anti-entropy verify --goal-id --diff-file --workspace --format json", "purpose": "Verify exact diff hygiene and delete-first compliance against a declared retirement basis.", "write_boundary": "read-only diff verification and receipt generation; no repository mutation", }, + { + "command": "loopx anti-entropy inspect --goal-id --diff-file --workspace --format json", + "purpose": ( + "In-flight agent check with actionable feedback; rejections are " + "progress facts, not final delivery failures." + ), + "write_boundary": "read-only diff verification; no receipt store writes", + }, { "command": "loopx capability show anti-entropy-governance --format json", "purpose": "Inspect anti-entropy capability metadata, commands, and protocols.", "write_boundary": "read-only metadata projection", }, ], + "agent_tools": [ + { + "name": "anti_entropy_inspect", + "descriptor": "loopx.capabilities.anti_entropy.feedback.ANTI_ENTROPY_INSPECT_TOOL_DESCRIPTOR", + "entrypoint": "loopx.capabilities.anti_entropy.agent_tool.anti_entropy_inspect", + "purpose": ( + "Agent-callable inspection returning actionable findings as " + "progress facts plus an immutable receipt." + ), + "write_boundary": "read-only verification; no host state mutation", + }, + ], "implemented_protocols": [ { "schema_version": ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION, diff --git a/loopx/capabilities/anti_entropy/cli.py b/loopx/capabilities/anti_entropy/cli.py index 883ec0eb9a..3dff6d684c 100644 --- a/loopx/capabilities/anti_entropy/cli.py +++ b/loopx/capabilities/anti_entropy/cli.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json from pathlib import Path from typing import Any @@ -16,10 +15,16 @@ def run_anti_entropy_verify( diff_text: str, basis_data: Any = None, goal_id: str | None = None, + workspace: str | Path | None = None, ) -> dict[str, Any]: - """Verify exact diff hygiene and delete-first compliance, returning structured payload.""" + """Verify exact diff hygiene and delete-first compliance, returning structured payload. + + ``workspace`` explicitly binds the post-change sources used to verify symbol + retirement. Without it, verdicts can reject but never certify retirement + (AE004 insufficient source evidence). + """ basis = RetirementBasis.from_dict(basis_data) if basis_data is not None else None - receipt = evaluate_diff_hygiene(diff_text, basis) + receipt = evaluate_diff_hygiene(diff_text, basis, workspace=workspace) blocking_count = sum(1 for f in receipt.findings if f.severity == "blocking") advisory_count = sum(1 for f in receipt.findings if f.severity == "advisory") diff --git a/loopx/capabilities/anti_entropy/contract.py b/loopx/capabilities/anti_entropy/contract.py index 0c94c60bb8..20aa6b9aea 100644 --- a/loopx/capabilities/anti_entropy/contract.py +++ b/loopx/capabilities/anti_entropy/contract.py @@ -118,7 +118,13 @@ def to_dict(self) -> dict[str, Any]: @dataclass(frozen=True, slots=True) class AntiEntropyReceipt: - """Durable receipt verifying diff hygiene and delete-first enforcement.""" + """Durable receipt verifying diff hygiene and delete-first enforcement. + + Evidence provenance: ``basis_hash`` binds the receipt to the exact declared + retirement intent; ``source_root`` and ``source_evidence`` record which + post-change sources (if any) backed the verdict. A ``diff_only`` receipt can + reject but never certifies symbol retirement. + """ schema_version: str = ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION receipt_id: str = field(default="") @@ -127,6 +133,9 @@ class AntiEntropyReceipt: findings: tuple[AntiEntropyFinding, ...] = () retired_symbols: tuple[str, ...] = () timestamp_utc: float = field(default_factory=time.time) + basis_hash: str = field(default="") + source_root: str | None = field(default=None) + source_evidence: str = "diff_only" # "workspace_sources" | "diff_only" def to_dict(self) -> dict[str, Any]: receipt_id = self.receipt_id @@ -141,4 +150,7 @@ def to_dict(self) -> dict[str, Any]: "findings": [f.to_dict() for f in self.findings], "retired_symbols": list(self.retired_symbols), "timestamp_utc": self.timestamp_utc, + "basis_hash": self.basis_hash, + "source_root": self.source_root, + "source_evidence": self.source_evidence, } diff --git a/loopx/capabilities/anti_entropy/feedback.py b/loopx/capabilities/anti_entropy/feedback.py new file mode 100644 index 0000000000..4ccc71e073 --- /dev/null +++ b/loopx/capabilities/anti_entropy/feedback.py @@ -0,0 +1,211 @@ +"""Default-off, task-scoped feedback loop for anti-entropy retirement work. + +Single-task closed loop: + +1. Task context: ``build_anti_entropy_turn_context`` projects a declared + ``RetirementBasis`` into a typed turn-context payload a host can inject into + agent input when the capability is enabled for that task. +2. In-flight check: agents call the ``anti_entropy_inspect`` tool (or the CLI + mirror) and receive actionable feedback. Blocking findings are progress + facts guiding the next correction, not final delivery failures. +3. Delivery verification: stays owned by the existing change-quality owner + (exact-diff prepare/record/verify and receipt invalidation). This module + only returns the delivery route; it never writes receipts or creates a + parallel delivery authority. + +Model adoption is proven by actual runs elsewhere; the tests for this module +cover the protocol and the real callable entry points. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Mapping + +from .agent_tool import anti_entropy_inspect +from .contract import AntiEntropyFinding, RetirementBasis + +_INSPECT_CLI_ROUTE = ( + "loopx anti-entropy inspect --diff-file " + "--basis-json --workspace --format json" +) + +_DELIVERY_ROUTE = ( + "loopx change-quality record --goal-id --result-path " + "then loopx change-quality verify --goal-id " +) + +_TOOL_NAME = "anti_entropy_inspect" + +_TOOL_DESCRIPTION = ( + "Verify a unified diff against a declared retirement basis and diff hygiene " + "rules. Returns an immutable receipt with actionable findings: blocking " + "findings are progress facts to fix before delivery, not final delivery " + "failures. Symbol retirement certification requires an explicitly bound " + "workspace holding the complete post-change sources." +) + +ANTI_ENTROPY_INSPECT_TOOL_DESCRIPTOR: dict[str, Any] = { + "name": _TOOL_NAME, + "description": _TOOL_DESCRIPTION, + "input_schema": { + "type": "object", + "required": ["diff_text"], + "properties": { + "diff_text": { + "type": "string", + "description": "Unified diff text of the change under review.", + }, + "retirement_basis": { + "type": "object", + "description": ( + "Declared RetirementBasis (target_symbols, target_paths, " + "allow_compat_seam, rationale)." + ), + }, + "workspace": { + "type": "string", + "description": ( + "Directory with the complete post-change sources; required " + "to certify symbol retirement." + ), + }, + "goal_id": {"type": "string", "description": "Associated LoopX goal identifier."}, + }, + }, + "cli_route": _INSPECT_CLI_ROUTE, +} + + +def _rule_next_action(finding: AntiEntropyFinding) -> str: + if finding.rule_id.startswith("AE001"): + return "Remove the superseded definition or target path in this change, or declare an authorized compatibility seam." + if finding.rule_id.startswith("AE002"): + return "Remove the reintroduced wrapper or active reference so the retired name has no live definition or caller." + if finding.rule_id.startswith("AE003"): + return "Delete the commented-out block under version control instead of keeping ghost code." + if finding.rule_id.startswith("AE004"): + return "Re-run with --workspace bound to the complete post-change sources; diff-only evidence cannot certify retirement." + return "Address the reported diff hygiene finding and re-run the inspection." + + +def build_anti_entropy_turn_context( + *, + basis: RetirementBasis | Mapping[str, Any], + goal_id: str | None = None, + workspace: str | Path | None = None, +) -> dict[str, Any]: + """Project a declared retirement basis into a typed turn-context payload. + + The payload is data for the host to inject when the capability is enabled + for the task; calling this function does not execute anything. + """ + basis_obj = ( + basis if isinstance(basis, RetirementBasis) else RetirementBasis.from_dict(basis) + ) + context: dict[str, Any] = { + "capability_id": "anti-entropy-governance", + "task": { + "objective": basis_obj.rationale, + "target_symbols": list(basis_obj.target_symbols), + "target_paths": list(basis_obj.target_paths), + "allow_compat_seam": basis_obj.allow_compat_seam, + }, + "inspect_tool": ANTI_ENTROPY_INSPECT_TOOL_DESCRIPTOR, + "feedback_semantics": { + "blocking_findings": ( + "Progress facts for the next correction round; they are not " + "final delivery failures." + ), + "retirement_certification": ( + "Requires an explicitly bound workspace with complete " + "post-change sources (AE004 otherwise)." + ), + "delivery_gate": ( + "Final acceptance is owned by change-quality prepare/record/" + "verify; this capability supplies evidence only." + ), + }, + "delivery_route": _DELIVERY_ROUTE, + } + if goal_id is not None: + context["goal_id"] = goal_id + if workspace is not None: + context["workspace"] = str(workspace) + return context + + +def run_anti_entropy_feedback_cycle( + *, + diff_text: str, + basis: RetirementBasis | Mapping[str, Any] | None = None, + goal_id: str | None = None, + workspace: str | Path | None = None, +) -> dict[str, Any]: + """Run one inspect round and classify its outcome as actionable feedback. + + This is the real in-flight check entry: it never raises on verification + findings. A rejected verdict yields ``is_progress_fact=True`` with + per-finding next actions; only a passed, source-bound verification with all + targets covered marks the round delivery-ready (final acceptance still + belongs to the change-quality owner). + """ + result = anti_entropy_inspect( + diff_text=diff_text, + retirement_basis=( + dict(basis) if isinstance(basis, Mapping) else basis + ), + goal_id=goal_id, + workspace=workspace, + ) + receipt = result.get("receipt", {}) + findings = receipt.get("findings", []) + actionable: list[dict[str, Any]] = [ + { + "rule_id": f.get("rule_id"), + "severity": f.get("severity"), + "message": f.get("message"), + "location": f.get("location"), + "next_action": _rule_next_action( + AntiEntropyFinding( + rule_id=f.get("rule_id", ""), + severity=f.get("severity", "advisory"), + message=f.get("message", ""), + location=f.get("location"), + ) + ), + } + for f in findings + ] + + verdict = result.get("verdict") + retired = result.get("retired_symbols", []) + source_evidence = receipt.get("source_evidence", "diff_only") + + basis_obj = ( + basis if isinstance(basis, RetirementBasis) else ( + RetirementBasis.from_dict(basis) if basis is not None else None + ) + ) + declared_symbols = list(basis_obj.target_symbols) if basis_obj is not None else [] + all_targets_retired = bool(declared_symbols) and all(s in retired for s in declared_symbols) + + delivery_ready = ( + verdict == "passed" + and source_evidence == "workspace_sources" + and all_targets_retired + ) + + return { + "ok": True, + "goal_id": goal_id, + "verdict": verdict, + "source_evidence": source_evidence, + "retired_symbols": retired, + "actionable_feedback": actionable, + "is_progress_fact": verdict == "rejected", + "delivery_ready": delivery_ready, + "delivery_route": _DELIVERY_ROUTE, + "inspect_cli_route": _INSPECT_CLI_ROUTE, + "receipt": receipt, + } diff --git a/loopx/capabilities/anti_entropy/oracles.py b/loopx/capabilities/anti_entropy/oracles.py index 9949a38e9b..81a06791fa 100644 --- a/loopx/capabilities/anti_entropy/oracles.py +++ b/loopx/capabilities/anti_entropy/oracles.py @@ -3,9 +3,12 @@ import ast import codecs import hashlib +import json +import os import re import textwrap from collections.abc import Mapping +from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -48,13 +51,51 @@ _TS_EXTENSIONS = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"} _PY_EXTENSIONS = {".py", ".pyi"} +_RULE_AE001 = "AE001-DELETE_FIRST_VIOLATION" +_RULE_AE002 = "AE002-ZOMBIE_SYMBOL_REINTRODUCED" +_RULE_AE003 = "AE003-COMMENTED_CODE_ADVISORY" +_RULE_AE004 = "AE004-INSUFFICIENT_SOURCE_EVIDENCE" + +_WORKSPACE_SKIP_DIRS = { + ".git", + ".hg", + ".svn", + "node_modules", + ".venv", + "venv", + "__pycache__", + ".tox", + ".nox", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + "dist", + "build", + ".idea", + ".vscode", +} + +_MAX_SOURCE_FILE_BYTES = 2_000_000 + +_MAX_WORKSPACE_FILES = 20000 + def _hash_diff(diff_text: str) -> str: return hashlib.sha256(diff_text.encode("utf-8")).hexdigest() +def hash_retirement_basis(basis: RetirementBasis) -> str: + """Stable hash binding the receipt to the exact declared retirement intent.""" + payload = json.dumps(basis.to_dict(), sort_keys=True, ensure_ascii=False) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + def find_repo_root(start_path: Path | None = None) -> Path: - """Resolve repository root by looking for .git folder up the tree.""" + """Resolve repository root by looking for .git folder up the tree. + + Kept for callers that locate a repository; verification itself requires an + explicitly bound workspace and no longer infers one from the CWD. + """ cur = (start_path or Path.cwd()).resolve() for parent in [cur, *cur.parents]: if (parent / ".git").exists(): @@ -139,6 +180,11 @@ def parse_diff_git_header(line: str) -> tuple[str, str] | None: return None +def _is_doc_file(file_path: str) -> bool: + ext = Path(file_path).suffix.lower() if file_path else "" + return ext in _DOC_EXTENSIONS + + def _extract_deleted_paths(diff_text: str) -> set[str]: deleted_paths: set[str] = set() diff_text = textwrap.dedent(diff_text) @@ -179,32 +225,45 @@ def _extract_deleted_paths(diff_text: str) -> set[str]: return deleted_paths -def _extract_python_defs_from_code(code: str) -> set[str]: +def _extract_python_defs_from_code(code: str) -> set[str] | None: + """Extract Python definitions from a complete-parseable code block. + + Returns None when the text cannot be parsed as Python at all (so callers can + treat it as unparseable rather than as "defines nothing"). + """ if not code.strip(): return set() dedented = textwrap.dedent(code) try: tree = ast.parse(dedented) - return { - node.name - for node in ast.walk(tree) - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) - } + return _python_def_names(tree) except SyntaxError: try: wrapped = "def _wrapper_():\n" + textwrap.indent(dedented, " ") tree = ast.parse(wrapped) return { - node.name - for node in ast.walk(tree) - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) - and node.name != "_wrapper_" + name + for name in _python_def_names(tree) + if name != "_wrapper_" } except SyntaxError: - return set() + return None + + +def _python_def_names(tree: ast.AST) -> set[str]: + return { + node.name + for node in ast.walk(tree) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) + } def _extract_python_signature_defs(lines: list[str]) -> set[str]: + """Line-level fallback for code fragments that cannot be parsed as Python. + + Only meaningful for in-diff code fragments; complete files must use the AST + result instead so that prose/docstring examples are never counted as defs. + """ defs: set[str] = set() for line in lines: m = _PY_DEF_OR_CLASS_RE.match(line) @@ -235,32 +294,177 @@ def _extract_ts_defs(lines: list[str]) -> set[str]: return defs -def _extract_defs_for_file(lines: list[str], file_path: str = "") -> set[str]: +def _strip_ts_line(line: str) -> str: + """Strip // comments and string-literal spans from a TS/JS-like line.""" + line = re.sub(r"//.*$", "", line) + line = re.sub(r"/\*.*?(?:\*/|$)", "", line) + line = re.sub(r'"(?:\\.|[^"\\])*"', '""', line) + line = re.sub(r"'(?:\\.|[^'\\])*'", "''", line) + line = re.sub(r"`(?:\\.|[^`\\])*`", "``", line) + return line + + +def _extract_ts_refs(lines: list[str], defs: set[str]) -> set[str]: + """Word-level references for TS/JS-like lines, excluding definition lines themselves.""" + refs: set[str] = set() + for line in lines: + line_clean = line.strip() + is_def_line = any( + (m := pattern.match(line_clean)) and m.group(1) in defs + for pattern in _TS_DEF_PATTERNS + ) + if is_def_line: + continue + for word in re.findall(r"[A-Za-z_$][A-Za-z0-9_$]*", _strip_ts_line(line)): + if word in defs: + refs.add(word) + return refs + + +def _extract_defs_for_file(lines: list[str], file_path: str = "", is_full_file: bool = False) -> set[str]: + """Extract definitions for in-diff fragments of a file. + + Documentation files never contribute symbol definitions: fenced examples in + markdown are prose, not code. For Python fragments, the AST result wins when + the fragment parses; the line-signature fallback only covers unparseable + fragments of code files. + """ + if _is_doc_file(file_path): + return set() + code_text = "\n".join(lines) ext = Path(file_path).suffix.lower() if file_path else "" if ext in _TS_EXTENSIONS: return _extract_ts_defs(lines) elif ext in _PY_EXTENSIONS: - return _extract_python_defs_from_code(code_text) | _extract_python_signature_defs(lines) + py_ast_defs = _extract_python_defs_from_code(code_text) + if py_ast_defs is not None: + return py_ast_defs + if is_full_file: + return set() + return _extract_python_signature_defs(lines) else: - py_defs = _extract_python_defs_from_code(code_text) | _extract_python_signature_defs(lines) - ts_defs = _extract_ts_defs(lines) - return py_defs | ts_defs - - -def _analyze_diff_symbols(diff_text: str) -> tuple[set[str], set[str], set[str], set[str], list[str]]: - """Analyze diff to extract deleted, reintroduced, surviving context, and disk defined symbols, - - plus all added lines for reference auditing. - Returns: - (deleted_symbols, reintroduced_symbols, surviving_context_defs, still_defined_on_disk, all_added_lines) + py_ast_defs = _extract_python_defs_from_code(code_text) + if py_ast_defs: + return py_ast_defs + if is_full_file: + return _extract_ts_defs(lines) + return _extract_python_signature_defs(lines) | _extract_ts_defs(lines) + + +@dataclass(frozen=True, slots=True) +class _DiffSymbolAnalysis: + deleted_symbols: set[str] = field(default_factory=set) + reintroduced_symbols: set[str] = field(default_factory=set) + surviving_context_defs: set[str] = field(default_factory=set) + still_defined_on_disk: set[str] = field(default_factory=set) + all_added_lines: list[str] = field(default_factory=list) + unverified_source_files: tuple[str, ...] = () + file_to_deleted_symbols: dict[str, set[str]] = field(default_factory=dict) + deleted_paths: set[str] = field(default_factory=set) + + +@dataclass(frozen=True, slots=True) +class _WorkspaceEvidence: + """Complete post-change source evidence bound to an explicitly given workspace.""" + + root: Path + defined_symbols: dict[str, set[str]] = field(default_factory=dict) + referenced_symbols: dict[str, set[str]] = field(default_factory=dict) + existing_paths: set[str] = field(default_factory=set) + + +def _extract_python_refs_from_code(code: str) -> set[str] | None: + """Extract real Python identifier references via AST (strings/docstrings excluded).""" + try: + tree = ast.parse(textwrap.dedent(code)) + except SyntaxError: + return None + refs: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Name): + refs.add(node.id) + elif isinstance(node, ast.Attribute): + refs.add(node.attr) + elif isinstance(node, ast.ImportFrom): + if node.module: + refs.add(node.module.split(".")[0]) + for alias in node.names: + refs.add(alias.name.split(".")[0]) + if alias.asname: + refs.add(alias.asname) + return refs + + +def _scan_workspace(workspace: Path, symbols: tuple[str, ...]) -> _WorkspaceEvidence: + """Scan an explicitly bound workspace for definitions and real references of target symbols. + + Prefilters by substring so most files never reach the parser. Documentation + files are skipped: they are not executable evidence. + """ + evidence = _WorkspaceEvidence(root=workspace) + if not symbols: + return evidence + + symbol_set = set(symbols) + source_exts = _PY_EXTENSIONS | _TS_EXTENSIONS + visited = 0 + for dirpath, dirnames, filenames in os.walk(workspace): + dirnames[:] = [d for d in dirnames if d not in _WORKSPACE_SKIP_DIRS] + for filename in filenames: + if visited >= _MAX_WORKSPACE_FILES: + return evidence + file_path = Path(dirpath) / filename + if file_path.suffix.lower() not in source_exts: + continue + try: + if file_path.stat().st_size > _MAX_SOURCE_FILE_BYTES: + continue + content = file_path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + visited += 1 + if not any(s in content for s in symbol_set): + continue + rel = file_path.relative_to(workspace).as_posix() + ext = file_path.suffix.lower() + if ext in _PY_EXTENSIONS: + defs = _extract_python_defs_from_code(content) + refs = _extract_python_refs_from_code(content) + if defs is None or refs is None: + # Unparseable source cannot prove absence; treat the symbol + # as still defined to avoid false certification. + for s in symbol_set: + if s in content: + evidence.defined_symbols.setdefault(s, set()).add(rel) + continue + else: + content_lines = content.splitlines() + defs = _extract_ts_defs(content_lines) + refs = _extract_ts_refs(content_lines, defs) + for s in symbol_set: + if s in defs: + evidence.defined_symbols.setdefault(s, set()).add(rel) + if s in refs: + evidence.referenced_symbols.setdefault(s, set()).add(rel) + return evidence + + +def _analyze_diff_symbols(diff_text: str, workspace: Path | None = None) -> _DiffSymbolAnalysis: + """Analyze diff to extract deleted, reintroduced, surviving context, and disk-defined symbols. + + Source binding: when ``workspace`` is provided, complete post-change sources + are read from that explicit root only — never from the caller's CWD. Files + that cannot be read from the bound workspace are reported as unverified. """ deleted_symbols: set[str] = set() reintroduced_symbols: set[str] = set() surviving_context_defs: set[str] = set() still_defined_on_disk: set[str] = set() all_added_lines: list[str] = [] + unverified_source_files: list[str] = [] + file_to_deleted_symbols: dict[str, set[str]] = {} diff_text = textwrap.dedent(diff_text) file_diffs = re.split(r"(?=^diff --git |^--- )", diff_text, flags=re.MULTILINE) @@ -279,6 +483,8 @@ def _analyze_diff_symbols(diff_text: str) -> tuple[set[str], set[str], set[str], file_path = decode_git_diff_path(m_patch.group(1).strip(), is_source=False) hunk_texts = re.split(r"(?=^@@\s+-\d+.*?\s+\+\d+.*?\s+@@)", f_diff, flags=re.MULTILINE) + file_d_syms: set[str] = set() + for h_text in hunk_texts: if not h_text.startswith("@@"): continue @@ -318,130 +524,215 @@ def _analyze_diff_symbols(diff_text: str) -> tuple[set[str], set[str], set[str], for s in pre_defs: if s not in post_defs: deleted_symbols.add(s) + file_d_syms.add(s) for s in deleted_defs: if s not in post_defs and s not in added_defs: deleted_symbols.add(s) + file_d_syms.add(s) for s in added_defs: reintroduced_symbols.add(s) + if file_path: + file_to_deleted_symbols[file_path] = file_d_syms + is_file_deleted = bool( re.search(r"^deleted file mode \d+", f_diff, re.MULTILINE) or re.search(r"^\+\+\+\s+/dev/null", f_diff, re.MULTILINE) ) - if file_path and file_path != "/dev/null" and not is_file_deleted: - repo_root = find_repo_root() - disk_p = (repo_root / file_path).resolve() - if not disk_p.is_file(): - disk_p = Path(file_path).resolve() - if disk_p.is_file(): - try: - content = disk_p.read_text(encoding="utf-8") - disk_defs = _extract_defs_for_file(content.splitlines(), file_path) - for s in disk_defs: - still_defined_on_disk.add(s) - if s in deleted_symbols: - deleted_symbols.remove(s) - except Exception: - pass - - return deleted_symbols, reintroduced_symbols, surviving_context_defs, still_defined_on_disk, all_added_lines - + if file_path and file_path != "/dev/null" and not is_file_deleted and not _is_doc_file(file_path): + if workspace is None: + unverified_source_files.append(file_path) + else: + source_file = (workspace / file_path).resolve() + if not source_file.is_file(): + unverified_source_files.append(file_path) + else: + try: + content = source_file.read_text(encoding="utf-8") + disk_defs = _extract_defs_for_file( + content.splitlines(), file_path, is_full_file=True + ) + if ( + Path(file_path).suffix.lower() in _PY_EXTENSIONS + and _extract_python_defs_from_code(content) is None + ): + unverified_source_files.append(file_path) + continue + for s in disk_defs: + still_defined_on_disk.add(s) + if s in deleted_symbols: + deleted_symbols.remove(s) + except (OSError, UnicodeDecodeError): + unverified_source_files.append(file_path) + + return _DiffSymbolAnalysis( + deleted_symbols=deleted_symbols, + reintroduced_symbols=reintroduced_symbols, + surviving_context_defs=surviving_context_defs, + still_defined_on_disk=still_defined_on_disk, + all_added_lines=all_added_lines, + unverified_source_files=tuple(unverified_source_files), + file_to_deleted_symbols=file_to_deleted_symbols, + deleted_paths=_extract_deleted_paths(diff_text), + ) -def verify_delete_first( - diff_text: str, - retirement_basis: RetirementBasis | Mapping[str, Any] | None = None, -) -> list[AntiEntropyFinding]: - findings: list[AntiEntropyFinding] = [] - if retirement_basis is None: - return findings +def _symbol_hunk_reintroduced(symbol: str, analysis: _DiffSymbolAnalysis) -> bool: + """True when the diff itself visibly re-adds a definition of the symbol.""" + return symbol in analysis.reintroduced_symbols - basis = ( - retirement_basis - if isinstance(retirement_basis, RetirementBasis) - else RetirementBasis.from_dict(retirement_basis) - ) - if not basis.target_symbols and not basis.target_paths: - return findings +def _verify_symbol_targets( + basis: RetirementBasis, + analysis: _DiffSymbolAnalysis, + workspace_evidence: _WorkspaceEvidence | None, +) -> tuple[list[AntiEntropyFinding], set[str]]: + """Evaluate each target symbol; returns (findings, certified_retired_symbols).""" + findings: list[AntiEntropyFinding] = [] + certified: set[str] = set() - diff_text = textwrap.dedent(diff_text) - if not diff_text.strip(): - for symbol in basis.target_symbols: + for symbol in basis.target_symbols: + # 1. Diff-visible zombie: definition re-added inside the diff itself. + if _symbol_hunk_reintroduced(symbol, analysis): findings.append( AntiEntropyFinding( - rule_id="AE001-DELETE_FIRST_VIOLATION", - severity="blocking" if not basis.allow_compat_seam else "advisory", + rule_id=_RULE_AE002, + severity="blocking", message=( - f"Target symbol '{symbol}' was declared for retirement but diff is empty. " - "Delete-first requires explicit removal of superseded logic." + f"Target symbol '{symbol}' was slated for retirement but reintroduced " + "as an active definition in the diff." ), location=symbol, ) ) - for path in basis.target_paths: + continue + + # 2. Symbol retirement is a derived fact about complete post-change + # sources. Without an explicitly bound workspace it cannot be certified. + if workspace_evidence is None: findings.append( AntiEntropyFinding( - rule_id="AE001-DELETE_FIRST_VIOLATION", - severity="blocking" if not basis.allow_compat_seam else "advisory", + rule_id=_RULE_AE004, + severity="blocking", message=( - f"Target path '{path}' was declared for retirement but diff is empty." + f"Target symbol '{symbol}' cannot be certified as retired: no source " + "workspace was bound. Pass an explicit workspace containing the complete " + "post-change sources; a diff alone does not prove definition removal." ), - location=path, + location=symbol, ) ) - return findings - - deleted_symbols, reintroduced_symbols, surviving_context_defs, still_defined_on_disk, all_added_lines = _analyze_diff_symbols(diff_text) - deleted_paths = _extract_deleted_paths(diff_text) + continue - for symbol in basis.target_symbols: - # Check if the symbol is reintroduced as definition OR actively referenced/called in added code - is_reintroduced = symbol in reintroduced_symbols - if not is_reintroduced: - sym_pattern = re.compile(r"\b" + re.escape(symbol) + r"\b") - for added_l in all_added_lines: - clean_added = added_l.strip() - if clean_added.startswith(("#", "//", "/*", "*")): - continue - if sym_pattern.search(clean_added): - is_reintroduced = True - break + unverified_files = [ + f + for f in analysis.unverified_source_files + if symbol in analysis.file_to_deleted_symbols.get(f, set()) + ] + if unverified_files: + findings.append( + AntiEntropyFinding( + rule_id=_RULE_AE004, + severity="blocking", + message=( + f"Target symbol '{symbol}' cannot be certified as retired: complete " + f"post-change source is unavailable in the bound workspace for {', '.join(unverified_files)}." + ), + location=symbol, + ) + ) + continue - if is_reintroduced: + # 3. Real references in post-change sources (AST identifiers exclude + # string literals, comments and docstring examples). + ref_files = sorted(workspace_evidence.referenced_symbols.get(symbol, set())) + if ref_files: findings.append( AntiEntropyFinding( - rule_id="AE002-ZOMBIE_SYMBOL_REINTRODUCED", + rule_id=_RULE_AE002, severity="blocking", message=( - f"Target symbol '{symbol}' was slated for retirement but reintroduced " - "as an active definition or called in added lines in the diff or repository." + f"Target symbol '{symbol}' was slated for retirement but is still " + f"referenced by post-change sources: {', '.join(ref_files)}." ), location=symbol, ) ) - elif symbol not in deleted_symbols or symbol in surviving_context_defs or symbol in still_defined_on_disk: + continue + + # 4. Definition removal: complete workspace scan is authoritative, so a + # retained definition is caught even when the diff only edited prose. + def_files = sorted(workspace_evidence.defined_symbols.get(symbol, set())) + if def_files: + severity = "advisory" if basis.allow_compat_seam else "blocking" findings.append( AntiEntropyFinding( - rule_id="AE001-DELETE_FIRST_VIOLATION", - severity="blocking" if not basis.allow_compat_seam else "advisory", + rule_id=_RULE_AE001, + severity=severity, message=( - f"Target symbol '{symbol}' was declared for retirement in RetirementBasis " - "but its definition was not removed from the diff. Delete-first requires deleting " - "superseded definitions rather than merely removing calls, altering docs, or adding wrappers." + f"Target symbol '{symbol}' was declared for retirement but its definition " + f"is still present in post-change sources: {', '.join(def_files)}. " + "Delete-first requires removing superseded definitions rather than " + "merely removing calls, altering docs, or adding wrappers." ), location=symbol, ) ) + continue + + # 5. Unchanged context lines are diff-visible facts: a definition kept as + # hunk context was never removed by this diff. + if symbol in analysis.surviving_context_defs: + if workspace_evidence is None: + findings.append( + AntiEntropyFinding( + rule_id=_RULE_AE001, + severity="blocking" if not basis.allow_compat_seam else "advisory", + message=( + f"Target symbol '{symbol}' was declared for retirement but its " + "definition is still present in unchanged diff context lines. " + "Delete-first requires removing superseded definitions." + ), + location=symbol, + ) + ) + else: + findings.append( + AntiEntropyFinding( + rule_id=_RULE_AE004, + severity="blocking", + message=( + f"Target symbol '{symbol}' appears as a retained definition in the diff " + "context but was not found in the bound workspace sources; the patch " + "does not match the bound post-change sources." + ), + location=symbol, + ) + ) + continue + + # 6. No definition remains anywhere in the bound workspace. Positive + # deletion evidence in the diff attributes the retirement to this change. + if symbol in analysis.deleted_symbols or symbol in analysis.deleted_paths: + certified.add(symbol) + + return findings, certified + +def _verify_path_targets( + basis: RetirementBasis, + analysis: _DiffSymbolAnalysis, + workspace_evidence: _WorkspaceEvidence | None, +) -> list[AntiEntropyFinding]: + findings: list[AntiEntropyFinding] = [] for path in basis.target_paths: norm_path = normalize_repo_path(path) - if norm_path not in deleted_paths: + if norm_path not in analysis.deleted_paths: findings.append( AntiEntropyFinding( - rule_id="AE001-DELETE_FIRST_VIOLATION", + rule_id=_RULE_AE001, severity="blocking" if not basis.allow_compat_seam else "advisory", message=( f"Target path '{path}' was declared for retirement in RetirementBasis " @@ -450,7 +741,80 @@ def verify_delete_first( location=path, ) ) + continue + if workspace_evidence is not None and norm_path != "/dev/null": + disk_path = (workspace_evidence.root / norm_path).resolve() + if disk_path.exists(): + findings.append( + AntiEntropyFinding( + rule_id=_RULE_AE004, + severity="blocking", + message=( + f"Target path '{path}' is deleted in the diff but still exists in the " + "bound workspace; the patch does not match the bound post-change sources." + ), + location=path, + ) + ) + return findings + + +def verify_delete_first( + diff_text: str, + retirement_basis: RetirementBasis | Mapping[str, Any] | None = None, + workspace: str | Path | None = None, + _analysis: _DiffSymbolAnalysis | None = None, +) -> list[AntiEntropyFinding]: + findings: list[AntiEntropyFinding] = [] + + if retirement_basis is None: + return findings + + basis = ( + retirement_basis + if isinstance(retirement_basis, RetirementBasis) + else RetirementBasis.from_dict(retirement_basis) + ) + if not basis.target_symbols and not basis.target_paths: + return findings + + diff_text = textwrap.dedent(diff_text) + analysis = _analysis or _analyze_diff_symbols(diff_text, Path(workspace) if workspace else None) + + if not diff_text.strip(): + for symbol in basis.target_symbols: + findings.append( + AntiEntropyFinding( + rule_id=_RULE_AE001, + severity="blocking" if not basis.allow_compat_seam else "advisory", + message=( + f"Target symbol '{symbol}' was declared for retirement but diff is empty. " + "Delete-first requires explicit removal of superseded logic." + ), + location=symbol, + ) + ) + for path in basis.target_paths: + findings.append( + AntiEntropyFinding( + rule_id=_RULE_AE001, + severity="blocking" if not basis.allow_compat_seam else "advisory", + message=( + f"Target path '{path}' was declared for retirement but diff is empty." + ), + location=path, + ) + ) + return findings + + workspace_evidence: _WorkspaceEvidence | None = None + if workspace is not None and (basis.target_symbols or basis.target_paths): + workspace_evidence = _scan_workspace(Path(workspace), basis.target_symbols) + + symbol_findings, _certified = _verify_symbol_targets(basis, analysis, workspace_evidence) + findings.extend(symbol_findings) + findings.extend(_verify_path_targets(basis, analysis, workspace_evidence)) return findings @@ -518,7 +882,7 @@ def audit_ghost_code(diff_text: str) -> list[AntiEntropyFinding]: if consecutive_commented_code >= 3: findings.append( AntiEntropyFinding( - rule_id="AE003-COMMENTED_CODE_ADVISORY", + rule_id=_RULE_AE003, severity="advisory", message=( "Detected 3+ consecutive lines of commented-out code. " @@ -535,49 +899,48 @@ def audit_ghost_code(diff_text: str) -> list[AntiEntropyFinding]: def evaluate_diff_hygiene( diff_text: str, retirement_basis: RetirementBasis | Mapping[str, Any] | None = None, + workspace: str | Path | None = None, ) -> AntiEntropyReceipt: diff_text = textwrap.dedent(diff_text) diff_hash = _hash_diff(diff_text) all_findings: list[AntiEntropyFinding] = [] - all_findings.extend(verify_delete_first(diff_text, retirement_basis)) - all_findings.extend(audit_ghost_code(diff_text)) - - has_blocking = any(f.severity == "blocking" for f in all_findings) - verdict = "rejected" if has_blocking else "passed" - - retired: list[str] = [] - if retirement_basis: + basis: RetirementBasis | None = None + if retirement_basis is not None: basis = ( retirement_basis if isinstance(retirement_basis, RetirementBasis) else RetirementBasis.from_dict(retirement_basis) ) - if basis.target_symbols and diff_text.strip(): - deleted_symbols, reintroduced_symbols, surviving_context_defs, still_defined_on_disk, all_added_lines = _analyze_diff_symbols(diff_text) - failing_symbols = { - f.location for f in all_findings - if f.location and f.rule_id in ("AE001-DELETE_FIRST_VIOLATION", "AE002-ZOMBIE_SYMBOL_REINTRODUCED") - } - for s in basis.target_symbols: - sym_pattern = re.compile(r"\b" + re.escape(s) + r"\b") - has_added_ref = any( - sym_pattern.search(l) for l in all_added_lines - if not l.strip().startswith(("#", "//", "/*", "*")) - ) - if ( - s in deleted_symbols - and s not in reintroduced_symbols - and not has_added_ref - and s not in surviving_context_defs - and s not in still_defined_on_disk - and s not in failing_symbols - ): - retired.append(s) + + workspace_path = Path(workspace) if workspace is not None else None + source_evidence = "workspace_sources" if workspace_path is not None else "diff_only" + + analysis = _analyze_diff_symbols(diff_text, workspace_path) + + if basis is not None and (basis.target_symbols or basis.target_paths): + workspace_evidence: _WorkspaceEvidence | None = None + if workspace_path is not None and (basis.target_symbols or basis.target_paths): + workspace_evidence = _scan_workspace(workspace_path, basis.target_symbols) + symbol_findings, certified = _verify_symbol_targets(basis, analysis, workspace_evidence) + all_findings.extend(symbol_findings) + all_findings.extend(_verify_path_targets(basis, analysis, workspace_evidence)) + else: + certified = set() + + all_findings.extend(audit_ghost_code(diff_text)) + + has_blocking = any(f.severity == "blocking" for f in all_findings) + verdict = "rejected" if has_blocking else "passed" + + retired: list[str] = sorted(certified) return AntiEntropyReceipt( diff_hash=diff_hash, verdict=verdict, findings=tuple(all_findings), - retired_symbols=tuple(sorted(retired)), + retired_symbols=tuple(retired), + basis_hash=hash_retirement_basis(basis) if basis is not None else "", + source_root=str(workspace_path) if workspace_path is not None else None, + source_evidence=source_evidence, ) diff --git a/loopx/cli_commands/anti_entropy.py b/loopx/cli_commands/anti_entropy.py index ae80430952..ece7b7be90 100644 --- a/loopx/cli_commands/anti_entropy.py +++ b/loopx/cli_commands/anti_entropy.py @@ -42,6 +42,39 @@ def register_anti_entropy_commands( "--basis-json", help="JSON string or file path containing RetirementBasis specification.", ) + verify.add_argument( + "--workspace", + help=( + "Directory containing the complete post-change sources; required to " + "certify symbol retirement. Omitting it limits the verdict to " + "diff-only evidence." + ), + ) + inspect = commands.add_parser( + "inspect", + help=( + "In-flight agent check: verify the diff and return actionable " + "feedback. Rejections are progress facts, not final delivery " + "failures (exit 0 whenever the check runs)." + ), + ) + add_subcommand_format(inspect) + inspect.add_argument("--goal-id", help="Associated LoopX goal identifier.") + inspect.add_argument( + "--diff-file", + help="Path to unified diff patch file, or - for stdin.", + ) + inspect.add_argument( + "--basis-json", + help="JSON string or file path containing RetirementBasis specification.", + ) + inspect.add_argument( + "--workspace", + help=( + "Directory containing the complete post-change sources; required to " + "certify symbol retirement." + ), + ) def _render(payload: dict[str, Any]) -> str: @@ -59,6 +92,9 @@ def _render(payload: dict[str, Any]) -> str: lines.append("") lines.append("## Receipt") lines.append(f"- diff_hash: `{receipt.get('diff_hash')}`") + lines.append(f"- source_evidence: `{receipt.get('source_evidence', 'diff_only')}`") + if receipt.get("source_root"): + lines.append(f"- source_root: `{receipt.get('source_root')}`") retired = receipt.get("retired_symbols", []) if retired: lines.append(f"- retired_symbols: `{', '.join(retired)}`") @@ -71,6 +107,18 @@ def _render(payload: dict[str, Any]) -> str: rule = item.get("rule_id") msg = item.get("message") lines.append(f"- [{sev}] {rule}: {msg}") + if payload.get("command") == "inspect": + lines.append("") + lines.append("## Feedback") + lines.append(f"- is_progress_fact: `{payload.get('is_progress_fact')}`") + lines.append(f"- delivery_ready: `{payload.get('delivery_ready')}`") + lines.append(f"- delivery_route: `{payload.get('delivery_route')}`") + for item in payload.get("actionable_feedback", []): + lines.append("") + lines.append(f"### [{item.get('severity', '').upper()}] {item.get('rule_id')}") + if item.get("location"): + lines.append(f"- location: `{item.get('location')}`") + lines.append(f"- next_action: {item.get('next_action')}") return "\n".join(lines) + "\n" @@ -112,11 +160,59 @@ def handle_anti_entropy_command( except Exception: raise ValueError(f"basis file not found: {raw_basis}") + workspace: Path | None = None + if getattr(args, "workspace", None): + workspace = Path(args.workspace).expanduser().resolve() + if not workspace.is_dir(): + raise ValueError(f"workspace directory not found: {args.workspace}") + payload = run_anti_entropy_verify( diff_text=diff_text, basis_data=basis_data, goal_id=args.goal_id, + workspace=workspace, + ) + elif command == "inspect": + diff_text = "" + if args.diff_file == "-": + diff_text = sys.stdin.read() + elif args.diff_file: + diff_path = Path(args.diff_file).expanduser() + if diff_path.is_file(): + diff_text = diff_path.read_text(encoding="utf-8") + else: + raise ValueError(f"diff file not found: {args.diff_file}") + + basis_data = None + if args.basis_json is not None: + raw_basis = args.basis_json.strip() + if raw_basis.startswith("{") or raw_basis.startswith("["): + basis_data = json.loads(raw_basis) + else: + path = Path(raw_basis).expanduser() + if path.is_file(): + basis_data = json.loads(path.read_text(encoding="utf-8")) + else: + try: + basis_data = json.loads(raw_basis) + except Exception: + raise ValueError(f"basis file not found: {raw_basis}") + + workspace = None + if getattr(args, "workspace", None): + workspace = Path(args.workspace).expanduser().resolve() + if not workspace.is_dir(): + raise ValueError(f"workspace directory not found: {args.workspace}") + + from ..capabilities.anti_entropy.feedback import run_anti_entropy_feedback_cycle + + payload = run_anti_entropy_feedback_cycle( + diff_text=diff_text, + basis=basis_data, + goal_id=args.goal_id, + workspace=workspace, ) + payload = {"command": "inspect", **payload} else: raise ValueError(f"unknown anti-entropy command `{command}`") except (OSError, ValueError, TypeError) as exc: diff --git a/tests/capabilities/test_anti_entropy.py b/tests/capabilities/test_anti_entropy.py index 615aa00520..ae05de3822 100644 --- a/tests/capabilities/test_anti_entropy.py +++ b/tests/capabilities/test_anti_entropy.py @@ -1,14 +1,12 @@ from __future__ import annotations -import json -from typing import Any +from pathlib import Path from loopx.capabilities.anti_entropy import ( ANTI_ENTROPY_CAPABILITY_ID, ANTI_ENTROPY_CATALOG_ENTRY, ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION, ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION, - AntiEntropyReceipt, RetirementBasis, audit_ghost_code, evaluate_diff_hygiene, @@ -31,8 +29,18 @@ def test_catalog_registration() -> None: assert "delete-first" in record["real_world_anchor"] or "refactoring" in record["real_world_anchor"] -def test_verify_delete_first_passed() -> None: - """Verify clean retirement where old symbol is cleanly deleted.""" +def _make_workspace(tmp_path: Path, files: dict[str, str]) -> Path: + """Create an explicitly bound workspace containing post-change sources.""" + workspace = tmp_path / "workspace" + for rel_path, content in files.items(): + target = workspace / rel_path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + return workspace + + +def test_verify_delete_first_passed(tmp_path: Path) -> None: + """Verify clean retirement where old symbol is cleanly deleted from bound sources.""" diff = """ --- a/legacy_module.py +++ b/legacy_module.py @@ -42,19 +50,23 @@ def test_verify_delete_first_passed() -> None: +def modern_calculator(x: int, y: int) -> int: + return x + y """ + workspace = _make_workspace( + tmp_path, + {"legacy_module.py": "def modern_calculator(x: int, y: int) -> int:\n return x + y\n"}, + ) basis = RetirementBasis( target_symbols=("old_deprecated_calculator",), rationale="Replaced by modern_calculator", ) - findings = verify_delete_first(diff, basis) + findings = verify_delete_first(diff, basis, workspace=workspace) assert len(findings) == 0 - receipt = evaluate_diff_hygiene(diff, basis) + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) assert receipt.verdict == "passed" assert "old_deprecated_calculator" in receipt.retired_symbols -def test_verify_delete_first_violation() -> None: +def test_verify_delete_first_violation(tmp_path: Path) -> None: """Verify rejection when old symbol is kept instead of delete-first.""" diff = """ --- a/legacy_module.py @@ -65,21 +77,32 @@ def test_verify_delete_first_violation() -> None: def old_deprecated_calculator(x, y): return x + y """ + workspace = _make_workspace( + tmp_path, + { + "legacy_module.py": ( + "def modern_calculator(x: int, y: int) -> int:\n" + " return x + y\n" + "def old_deprecated_calculator(x, y):\n" + " return x + y\n" + ) + }, + ) basis = RetirementBasis( target_symbols=("old_deprecated_calculator",), allow_compat_seam=False, ) - findings = verify_delete_first(diff, basis) + findings = verify_delete_first(diff, basis, workspace=workspace) assert len(findings) == 1 assert findings[0].rule_id == "AE001-DELETE_FIRST_VIOLATION" assert findings[0].severity == "blocking" - receipt = evaluate_diff_hygiene(diff, basis) + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) assert receipt.verdict == "rejected" def test_verify_delete_first_zombie_reintroduced() -> None: - """Verify rejection when symbol is deleted and reintroduced simultaneously.""" + """Verify rejection when symbol is deleted and reintroduced simultaneously in the diff.""" diff = """ --- a/legacy_module.py +++ b/legacy_module.py @@ -95,7 +118,7 @@ def test_verify_delete_first_zombie_reintroduced() -> None: def test_target_paths_deletion() -> None: """Verify target_paths require complete file deletion headers in the diff.""" - # File cleanly deleted -> pass + # File cleanly deleted -> pass (diff-only evidence is sufficient for paths) deleted_diff = """ --- a/legacy/old_file.py +++ /dev/null @@ -122,7 +145,7 @@ def test_target_paths_deletion() -> None: assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt_partial.findings) -def test_compat_seam_does_not_falsely_retire() -> None: +def test_compat_seam_does_not_falsely_retire(tmp_path: Path) -> None: """Verify allow_compat_seam allows passing but does NOT claim un-deleted symbols as retired.""" diff = """ --- a/legacy_module.py @@ -132,16 +155,39 @@ def test_compat_seam_does_not_falsely_retire() -> None: def old_api(): return 1 """ + workspace = _make_workspace(tmp_path, {"legacy_module.py": "def old_api():\n return 1\n"}) basis = RetirementBasis( target_symbols=("old_api",), allow_compat_seam=True, ) - receipt = evaluate_diff_hygiene(diff, basis) + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) assert receipt.verdict == "passed" + seam_findings = [f for f in receipt.findings if f.location == "old_api"] + assert seam_findings and all(f.severity == "advisory" for f in seam_findings) # old_api was NOT deleted, so it must NOT be in retired_symbols! assert "old_api" not in receipt.retired_symbols +def test_retained_definition_is_blocking_without_compat_seam(tmp_path: Path) -> None: + """A retained definition without an authorized compat seam stays a hard AE001 block.""" + diff = """ +--- a/legacy_module.py ++++ b/legacy_module.py +@@ -1,3 +1,4 @@ ++# some edit + def old_api(): + return 1 +""" + workspace = _make_workspace(tmp_path, {"legacy_module.py": "def old_api():\n return 1\n"}) + basis = RetirementBasis(target_symbols=("old_api",), allow_compat_seam=False) + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) + assert receipt.verdict == "rejected" + assert any( + f.rule_id == "AE001-DELETE_FIRST_VIOLATION" and f.severity == "blocking" + for f in receipt.findings + ) + + def test_false_positive_prevention() -> None: """Ensure normal English comments or documentation mentioning words like fallback are not blocked.""" diff = """ @@ -177,7 +223,7 @@ def test_audit_ghost_code_commented_block() -> None: assert findings[0].severity == "advisory" -def test_cli_runner() -> None: +def test_cli_runner(tmp_path: Path) -> None: """Verify CLI verify function generates a complete, valid payload.""" clean_diff = """ --- a/service.py @@ -187,16 +233,19 @@ def test_cli_runner() -> None: +def modern_call(): return 42 """ + workspace = _make_workspace(tmp_path, {"service.py": "def modern_call():\n return 42\n"}) payload = run_anti_entropy_verify( diff_text=clean_diff, basis_data={"target_symbols": ["legacy_call"]}, goal_id="test-goal-id", + workspace=workspace, ) assert payload["ok"] is True assert payload["verdict"] == "passed" assert payload["blocking_finding_count"] == 0 assert "legacy_call" in payload["retired_symbols"] + def test_retirement_basis_type_validation() -> None: """Verify strict type validation on RetirementBasis.from_dict to prevent truthiness loopholes.""" import pytest @@ -240,12 +289,17 @@ def test_retirement_basis_type_validation() -> None: RetirementBasis.from_dict({"rationale": 123}) -def test_cli_exit_code_and_subprocess() -> None: +def test_cli_exit_code_and_subprocess(tmp_path: Path) -> None: """Verify CLI returns exit code 1 on rejection, 0 on pass, and 2 on invalid basis JSON.""" import subprocess import sys - # 1. Rejected verification -> exit code 1 + workspace = _make_workspace( + tmp_path, + {"service.py": "def old_api():\n return 1\n"}, + ) + + # 1. Rejected verification (definition retained in bound workspace) -> exit code 1 diff_fail = """ --- a/service.py +++ b/service.py @@ -265,6 +319,8 @@ def old_api(): "-", "--basis-json", "{\"target_symbols\": [\"old_api\"]}", + "--workspace", + str(workspace), "--format", "json", ], @@ -274,6 +330,7 @@ def old_api(): assert proc_fail.returncode == 1 # 2. Passed verification -> exit code 0 + clean_workspace = _make_workspace(tmp_path / "clean", {"service.py": "def new_api():\n return 2\n"}) diff_pass = """ --- a/service.py +++ b/service.py @@ -294,6 +351,8 @@ def old_api(): "-", "--basis-json", "{\"target_symbols\": [\"old_api\"]}", + "--workspace", + str(clean_workspace), "--format", "json", ], @@ -322,8 +381,30 @@ def old_api(): ) assert proc_err.returncode == 2 + # 4. Nonexistent workspace directory -> exit code 2 + proc_ws = subprocess.run( + [ + sys.executable, + "-m", + "loopx.cli", + "anti-entropy", + "verify", + "--diff-file", + "-", + "--basis-json", + "{\"target_symbols\": [\"old_api\"]}", + "--workspace", + str(tmp_path / "does_not_exist"), + "--format", + "json", + ], + input=diff_pass.encode("utf-8"), + capture_output=True, + ) + assert proc_ws.returncode == 2 + -def test_docstring_example_deletion_does_not_retire_real_definition() -> None: +def test_docstring_example_deletion_does_not_retire_real_definition(tmp_path: Path) -> None: """Verify deleting a code example in a docstring does not falsely pass a retained real definition.""" diff = ( "--- a/api.py\n" @@ -337,8 +418,12 @@ def test_docstring_example_deletion_does_not_retire_real_definition() -> None: " def old_api():\n" " return 2\n" ) + workspace = _make_workspace( + tmp_path, + {"api.py": '"""\nExample:\n def old_api():\n return 1\n"""\n\ndef old_api():\n return 2\n'}, + ) basis = RetirementBasis(target_symbols=("old_api",)) - receipt = evaluate_diff_hygiene(diff, basis) + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) assert receipt.verdict == "rejected" assert any(f.rule_id in ("AE001-DELETE_FIRST_VIOLATION", "AE002-ZOMBIE_SYMBOL_REINTRODUCED") for f in receipt.findings) assert "old_api" not in receipt.retired_symbols @@ -382,7 +467,7 @@ def test_exact_git_path_confirmation_and_encoding() -> None: assert len(receipt_quoted.findings) == 0 -def test_reintroduced_symbol_excluded_from_retired_symbols_with_partial_retirement() -> None: +def test_reintroduced_symbol_excluded_from_retired_symbols_with_partial_retirement(tmp_path: Path) -> None: """Verify modified/reintroduced symbol is excluded from retired_symbols while cleanly retired symbol is kept.""" diff = """ --- a/service.py @@ -395,15 +480,16 @@ def test_reintroduced_symbol_excluded_from_retired_symbols_with_partial_retireme +def old_api(x=1): + return x """ + workspace = _make_workspace(tmp_path, {"service.py": "def old_api(x=1):\n return x\n"}) basis = RetirementBasis(target_symbols=("old_helper", "old_api")) - receipt = evaluate_diff_hygiene(diff, basis) + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) assert receipt.verdict == "rejected" assert any(f.rule_id == "AE002-ZOMBIE_SYMBOL_REINTRODUCED" and f.location == "old_api" for f in receipt.findings) # old_api is excluded from retired_symbols, but old_helper is accurately certified! assert receipt.retired_symbols == ("old_helper",) -def test_typescript_clean_delete_first() -> None: +def test_typescript_clean_delete_first(tmp_path: Path) -> None: """Verify TypeScript functions, classes, interfaces, and types are accurately recognized as retired.""" diff = """ --- a/control_plane.ts @@ -422,6 +508,10 @@ def test_typescript_clean_delete_first() -> None: -export const oldArrowHelper = (x: number) => x * 2; +export function newLeaseAcquire(): boolean { return true; } """ + workspace = _make_workspace( + tmp_path, + {"control_plane.ts": "export function newLeaseAcquire(): boolean { return true; }\n"}, + ) basis = RetirementBasis( target_symbols=( "oldLeaseAcquire", @@ -431,7 +521,7 @@ def test_typescript_clean_delete_first() -> None: "oldArrowHelper", ), ) - receipt = evaluate_diff_hygiene(diff, basis) + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) assert receipt.verdict == "passed", f"findings: {receipt.findings}" assert set(receipt.retired_symbols) == { "oldLeaseAcquire", @@ -442,7 +532,7 @@ def test_typescript_clean_delete_first() -> None: } -def test_typescript_call_deletion_does_not_retire_definition() -> None: +def test_typescript_call_deletion_does_not_retire_definition(tmp_path: Path) -> None: """Verify deleting a TypeScript function call without deleting definition triggers AE001.""" diff = """ --- a/control_plane.ts @@ -456,8 +546,21 @@ def test_typescript_call_deletion_does_not_retire_definition() -> None: + return newLeaseAcquire(); } """ + workspace = _make_workspace( + tmp_path, + { + "control_plane.ts": ( + "export function oldLeaseAcquire(): boolean {\n" + " return false;\n" + "}\n" + "export function run() {\n" + " return newLeaseAcquire();\n" + "}\n" + ) + }, + ) basis = RetirementBasis(target_symbols=("oldLeaseAcquire",)) - receipt = evaluate_diff_hygiene(diff, basis) + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) assert receipt.verdict == "rejected" assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt.findings) assert "oldLeaseAcquire" not in receipt.retired_symbols @@ -478,8 +581,8 @@ def test_typescript_zombie_symbol_reintroduced() -> None: assert any(f.rule_id == "AE002-ZOMBIE_SYMBOL_REINTRODUCED" for f in receipt.findings) -def test_offline_self_contained_diff_without_disk_access() -> None: - """Verify diff verification is self-contained and works for non-existent disk paths.""" +def test_diff_only_verification_cannot_certify_retirement() -> None: + """Without a bound workspace the verdict may reject but never certifies retirement (AE004).""" diff = """ --- a/non_existent_dir/virtual_module.py +++ b/non_existent_dir/virtual_module.py @@ -490,11 +593,13 @@ def test_offline_self_contained_diff_without_disk_access() -> None: """ basis = RetirementBasis(target_symbols=("obsolete_worker",)) receipt = evaluate_diff_hygiene(diff, basis) - assert receipt.verdict == "passed" - assert receipt.retired_symbols == ("obsolete_worker",) + assert receipt.verdict == "rejected" + assert any(f.rule_id == "AE004-INSUFFICIENT_SOURCE_EVIDENCE" for f in receipt.findings) + assert receipt.retired_symbols == () + assert receipt.source_evidence == "diff_only" -def test_agent_tool_inspect_interface() -> None: +def test_agent_tool_inspect_interface(tmp_path: Path) -> None: """Verify the structured agent tool anti_entropy_inspect returns complete, valid payload.""" from loopx.capabilities.anti_entropy.agent_tool import anti_entropy_inspect @@ -505,10 +610,12 @@ def test_agent_tool_inspect_interface() -> None: -export function legacyHandler() {} +export function modernHandler() {} """ + workspace = _make_workspace(tmp_path, {"service.ts": "export function modernHandler() {}\n"}) payload = anti_entropy_inspect( diff_text=diff, retirement_basis={"target_symbols": ["legacyHandler"]}, goal_id="goal-test-tool", + workspace=str(workspace), ) assert payload["ok"] is True assert payload["command"] == "verify" @@ -569,18 +676,11 @@ def test_empty_file_deletion_with_spaces_in_filename() -> None: assert len(receipt.findings) == 0 -def test_docstring_sample_not_certified_when_function_retained_on_disk(tmp_path: Path, monkeypatch) -> None: - """Verify deleting docstring example lines does not falsely certify a function retained on disk, even from subdirs.""" - repo_root = tmp_path / "mock_repo" - repo_root.mkdir() - (repo_root / ".git").mkdir() - subdir = repo_root / "nested" / "sub" - subdir.mkdir(parents=True) - - module_file = repo_root / "service.py" - module_file.write_text( - """\"\"\"\nExample:\n def old_api():\n pass\n\"\"\"\n\ndef old_api():\n return 42\n""", - encoding="utf-8", +def test_docstring_sample_not_certified_when_function_retained_on_disk(tmp_path: Path) -> None: + """Deleting docstring example lines must not certify a function retained in the bound workspace.""" + workspace = _make_workspace( + tmp_path, + {"service.py": '"""\nExample:\n def old_api():\n pass\n"""\n\ndef old_api():\n return 42\n'}, ) diff_docstring = """ @@ -592,14 +692,163 @@ def test_docstring_sample_not_certified_when_function_retained_on_disk(tmp_path: """ basis = RetirementBasis(target_symbols=("old_api",)) - monkeypatch.chdir(repo_root) - receipt_root = evaluate_diff_hygiene(diff_docstring, basis) - assert receipt_root.verdict == "rejected" - assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt_root.findings) - assert "old_api" not in receipt_root.retired_symbols - - monkeypatch.chdir(subdir) - receipt_sub = evaluate_diff_hygiene(diff_docstring, basis) - assert receipt_sub.verdict == "rejected" - assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt_sub.findings) - assert "old_api" not in receipt_sub.retired_symbols + receipt = evaluate_diff_hygiene(diff_docstring, basis, workspace=workspace) + assert receipt.verdict == "rejected" + assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt.findings) + assert "old_api" not in receipt.retired_symbols + + # The same diff and workspace bound from a different CWD yields the same result: + # conclusions depend on the explicit binding, not the caller's working directory. + receipt_again = evaluate_diff_hygiene(diff_docstring, basis, workspace=workspace) + assert receipt_again.verdict == receipt.verdict + assert receipt_again.diff_hash == receipt.diff_hash + + +def test_string_literal_mention_does_not_block_retirement(tmp_path: Path) -> None: + """A string mentioning the retired name is not an active reference (AE002 must not fire).""" + diff = """ +--- a/service.py ++++ b/service.py +@@ -1,4 +1,4 @@ +-def old_api(): +- return 1 ++def new_api(): ++ return "old_api has been removed" +""" + workspace = _make_workspace( + tmp_path, + {"service.py": 'def new_api():\n return "old_api has been removed"\n'}, + ) + basis = RetirementBasis(target_symbols=("old_api",)) + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) + assert receipt.verdict == "passed", f"findings: {receipt.findings}" + assert not any(f.rule_id == "AE002-ZOMBIE_SYMBOL_REINTRODUCED" for f in receipt.findings) + assert receipt.retired_symbols == ("old_api",) + + +def test_readme_fenced_example_is_not_symbol_evidence(tmp_path: Path) -> None: + """Deleting a fenced def example from docs must not certify a definition still in real sources.""" + diff = """ +--- a/README.md ++++ b/README.md +@@ -5,4 +5,0 @@ +-```python +-def old_api(): +- pass +-``` +""" + workspace = _make_workspace( + tmp_path, + { + "README.md": "# Docs\n\nUsage examples live elsewhere.\n", + "service.py": "def old_api():\n return 42\n", + }, + ) + basis = RetirementBasis(target_symbols=("old_api",)) + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) + assert receipt.verdict == "rejected" + assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt.findings) + assert "old_api" not in receipt.retired_symbols + + +def test_definition_in_untouched_file_is_caught_by_workspace_scan(tmp_path: Path) -> None: + """A definition living outside the diff still blocks certification via the bound workspace.""" + diff = """ +--- a/client.py ++++ b/client.py +@@ -1,3 +1,3 @@ +-import old_api + pass +""" + workspace = _make_workspace( + tmp_path, + { + "client.py": "pass\n", + "service.py": "def old_api():\n return 42\n", + }, + ) + basis = RetirementBasis(target_symbols=("old_api",)) + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) + assert receipt.verdict == "rejected" + assert any(f.rule_id == "AE001-DELETE_FIRST_VIOLATION" for f in receipt.findings) + assert "old_api" not in receipt.retired_symbols + + +def test_missing_source_file_in_bound_workspace_cannot_certify(tmp_path: Path) -> None: + """A diff-touched source file missing from the bound workspace means evidence is insufficient.""" + diff = """ +--- a/service.py ++++ b/service.py +@@ -1,3 +1,2 @@ +-def old_api(): +- return 1 ++pass +""" + # Bound workspace does NOT contain service.py at all. + workspace = _make_workspace(tmp_path, {"other.py": "x = 1\n"}) + basis = RetirementBasis(target_symbols=("old_api",)) + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) + assert receipt.verdict == "rejected" + assert any(f.rule_id == "AE004-INSUFFICIENT_SOURCE_EVIDENCE" for f in receipt.findings) + assert "old_api" not in receipt.retired_symbols + + +def test_same_diff_bound_to_different_workspaces_diverge(tmp_path: Path) -> None: + """Identical diff plus different bound workspaces yields correct, receipt-recorded verdicts.""" + diff = """ +--- a/service.py ++++ b/service.py +@@ -1,3 +1,2 @@ +-def old_api(): +- return 1 ++pass +""" + basis = RetirementBasis(target_symbols=("old_api",)) + + ws_clean = _make_workspace(tmp_path / "clean", {"service.py": "pass\n"}) + receipt_clean = evaluate_diff_hygiene(diff, basis, workspace=ws_clean) + assert receipt_clean.verdict == "passed" + assert receipt_clean.retired_symbols == ("old_api",) + assert receipt_clean.source_evidence == "workspace_sources" + assert receipt_clean.source_root == str(ws_clean) + assert receipt_clean.basis_hash + + ws_dirty = _make_workspace(tmp_path / "dirty", {"service.py": "def old_api():\n return 1\n"}) + receipt_dirty = evaluate_diff_hygiene(diff, basis, workspace=ws_dirty) + assert receipt_dirty.verdict == "rejected" + assert "old_api" not in receipt_dirty.retired_symbols + assert receipt_dirty.source_root == str(ws_dirty) + assert receipt_dirty.basis_hash == receipt_clean.basis_hash + + +def test_path_deleted_but_still_on_disk_is_contradiction(tmp_path: Path) -> None: + """A deleted-in-diff path that still exists in the bound workspace is an evidence mismatch.""" + diff = """ +--- a/api.py ++++ /dev/null +@@ -1,2 +0,0 @@ +-def f(): pass +""" + basis = RetirementBasis(target_paths=("api.py",)) + + ws_stale = _make_workspace(tmp_path / "stale", {"api.py": "def f(): pass\n"}) + receipt_stale = evaluate_diff_hygiene(diff, basis, workspace=ws_stale) + assert receipt_stale.verdict == "rejected" + assert any(f.rule_id == "AE004-INSUFFICIENT_SOURCE_EVIDENCE" for f in receipt_stale.findings) + + ws_clean = _make_workspace(tmp_path / "clean", {"keep.py": "x = 1\n"}) + receipt_clean = evaluate_diff_hygiene(diff, basis, workspace=ws_clean) + assert receipt_clean.verdict == "passed" + assert len(receipt_clean.findings) == 0 + + +def test_receipt_without_basis_has_empty_provenance(tmp_path: Path) -> None: + """Receipts without a declared basis carry empty basis_hash and diff_only evidence.""" + receipt = evaluate_diff_hygiene("--- a/x.py\n+++ b/x.py\n@@ -1 +1 @@\n-a\n+b\n") + assert receipt.basis_hash == "" + assert receipt.source_root is None + assert receipt.source_evidence == "diff_only" + assert ANTI_ENTROPY_RECEIPT_SCHEMA_VERSION == receipt.schema_version + assert ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION == RetirementBasis().schema_version + assert ANTI_ENTROPY_CAPABILITY_ID == "anti-entropy-governance" + assert ANTI_ENTROPY_CATALOG_ENTRY["id"] == ANTI_ENTROPY_CAPABILITY_ID diff --git a/tests/capabilities/test_anti_entropy_feedback.py b/tests/capabilities/test_anti_entropy_feedback.py new file mode 100644 index 0000000000..da3d5e7371 --- /dev/null +++ b/tests/capabilities/test_anti_entropy_feedback.py @@ -0,0 +1,177 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +from loopx.capabilities.anti_entropy import ( + ANTI_ENTROPY_CAPABILITY_ID, + ANTI_ENTROPY_CATALOG_ENTRY, + ANTI_ENTROPY_INSPECT_TOOL_DESCRIPTOR, + build_anti_entropy_turn_context, + run_anti_entropy_feedback_cycle, +) + +CLEAN_DIFF = """ +--- a/service.py ++++ b/service.py +@@ -1,3 +1,2 @@ +-def old_api(): +- return 1 ++pass +""" + +VIOLATION_DIFF = """ +--- a/service.py ++++ b/service.py +@@ -1,4 +1,5 @@ ++def new_api(): ++ return 2 + def old_api(): + return 1 +""" + +BASIS = {"target_symbols": ["old_api"], "rationale": "replaced by new_api"} + + +def _make_workspace(tmp_path: Path, content: str) -> Path: + workspace = tmp_path / "workspace" + workspace.mkdir(parents=True, exist_ok=True) + (workspace / "service.py").write_text(content, encoding="utf-8") + return workspace + + +def test_capability_is_default_off() -> None: + """The feedback loop ships disabled: enabling is an explicit task-scoped choice.""" + assert ANTI_ENTROPY_CATALOG_ENTRY["default_enabled"] is False + + +def test_inspect_tool_descriptor_is_well_formed() -> None: + """The agent tool descriptor names required inputs and routes.""" + assert ANTI_ENTROPY_INSPECT_TOOL_DESCRIPTOR["name"] == "anti_entropy_inspect" + schema = ANTI_ENTROPY_INSPECT_TOOL_DESCRIPTOR["input_schema"] + assert schema["required"] == ["diff_text"] + assert "workspace" in schema["properties"] + assert "--workspace" in ANTI_ENTROPY_INSPECT_TOOL_DESCRIPTOR["cli_route"] + # The catalog registers the tool surface so hosts can discover it. + agent_tools = ANTI_ENTROPY_CATALOG_ENTRY["agent_tools"] + assert any(t["name"] == "anti_entropy_inspect" for t in agent_tools) + + +def test_turn_context_projects_declared_task(tmp_path: Path) -> None: + """The turn context carries the declared retirement goal and inspect route.""" + context = build_anti_entropy_turn_context( + basis=BASIS, goal_id="goal-123", workspace=tmp_path + ) + assert context["capability_id"] == ANTI_ENTROPY_CAPABILITY_ID + assert context["goal_id"] == "goal-123" + assert context["task"]["target_symbols"] == ["old_api"] + assert context["task"]["objective"] == "replaced by new_api" + assert context["inspect_tool"]["name"] == "anti_entropy_inspect" + assert "change-quality" in context["feedback_semantics"]["delivery_gate"] + # Building the context executes nothing. + assert not list(tmp_path.iterdir()) + + +def test_feedback_cycle_rejection_is_progress_fact(tmp_path: Path) -> None: + """A retained definition yields actionable feedback, not a terminal failure.""" + workspace = _make_workspace( + tmp_path, "def new_api():\n return 2\n\ndef old_api():\n return 1\n" + ) + result = run_anti_entropy_feedback_cycle( + diff_text=VIOLATION_DIFF, basis=BASIS, goal_id="goal-123", workspace=workspace + ) + assert result["ok"] is True + assert result["verdict"] == "rejected" + assert result["is_progress_fact"] is True + assert result["delivery_ready"] is False + feedback = result["actionable_feedback"] + assert feedback, "expected actionable feedback entries" + ae001 = [f for f in feedback if f["rule_id"].startswith("AE001")] + assert ae001 and ae001[0]["next_action"] + assert "Remove" in ae001[0]["next_action"] + # The route for final acceptance is named but not executed here. + assert "change-quality" in result["delivery_route"] + # Read-only boundary: no receipt store or files written by the cycle. + assert sorted(p.name for p in workspace.iterdir()) == ["service.py"] + + +def test_feedback_cycle_reports_missing_workspace(tmp_path: Path) -> None: + """AE004 feedback tells the agent to bind the workspace and re-run.""" + result = run_anti_entropy_feedback_cycle(diff_text=CLEAN_DIFF, basis=BASIS) + assert result["verdict"] == "rejected" + assert result["is_progress_fact"] is True + ae004 = [f for f in result["actionable_feedback"] if f["rule_id"].startswith("AE004")] + assert ae004 + assert "workspace" in ae004[0]["next_action"] + + +def test_feedback_cycle_delivery_ready(tmp_path: Path) -> None: + """A passed, source-bound round covering all targets marks delivery-ready.""" + workspace = _make_workspace(tmp_path, "pass\n") + result = run_anti_entropy_feedback_cycle( + diff_text=CLEAN_DIFF, basis=BASIS, goal_id="goal-123", workspace=workspace + ) + assert result["verdict"] == "passed" + assert result["delivery_ready"] is True + assert result["is_progress_fact"] is False + assert result["retired_symbols"] == ["old_api"] + assert result["receipt"]["source_evidence"] == "workspace_sources" + + +def test_inspect_cli_roundtrip(tmp_path: Path) -> None: + """The CLI inspect subcommand is a real callable entry for agents.""" + workspace = _make_workspace( + tmp_path, "def new_api():\n return 2\n\ndef old_api():\n return 1\n" + ) + proc = subprocess.run( + [ + sys.executable, + "-m", + "loopx.cli", + "anti-entropy", + "inspect", + "--diff-file", + "-", + "--basis-json", + json.dumps(BASIS), + "--workspace", + str(workspace), + "--goal-id", + "goal-123", + "--format", + "json", + ], + input=VIOLATION_DIFF.encode("utf-8"), + capture_output=True, + ) + # Rejection is a progress fact: the check itself succeeds (exit 0). + assert proc.returncode == 0 + payload = json.loads(proc.stdout.decode("utf-8")) + assert payload["command"] == "inspect" + assert payload["verdict"] == "rejected" + assert payload["is_progress_fact"] is True + assert payload["actionable_feedback"] + + # Verify remains the delivery-gate entry with non-zero rejection exit. + proc_verify = subprocess.run( + [ + sys.executable, + "-m", + "loopx.cli", + "anti-entropy", + "verify", + "--diff-file", + "-", + "--basis-json", + json.dumps(BASIS), + "--workspace", + str(workspace), + "--format", + "json", + ], + input=VIOLATION_DIFF.encode("utf-8"), + capture_output=True, + ) + assert proc_verify.returncode == 1 From 95ba4506d93e667b2c181f44bb5f1d76a2d68fda Mon Sep 17 00:00:00 2001 From: LoopDesk Date: Thu, 29 Oct 2026 21:24:33 +0800 Subject: [PATCH 04/21] fix(anti-entropy): compose task-scoped feedback loop and harden source oracles Address the PR #7545 review round: compose anti_entropy_context_registration into the shared attach_turn_context owner with four lifecycle states (disabled zero-injection, enabled-not-applicable readback, active guidance, recovery remediation) so the capability gains a real production consumer while staying default-off and task-scoped. Rewrite TS/JS reference extraction to catch cross-file references after stripping comments and strings, and make workspace scan truncation (oversized files, file-count limits, read failures) a blocking AE004 that prevents retirement certification. Align the advertised delivery route with the real change-quality flow (prepare -> record --result-json --execute -> verify) and prove it end-to-end with a real CLI run plus a receipt-invalidation counter-example. Consolidate verify/inspect input reading into shared helpers. Signed-off-by: LoopDesk Signed-off-by: trae-solo-agent --- loopx/capabilities/anti_entropy/README.md | 14 +- loopx/capabilities/anti_entropy/__init__.py | 6 + .../anti_entropy/catalog_entry.py | 9 +- loopx/capabilities/anti_entropy/feedback.py | 10 +- loopx/capabilities/anti_entropy/oracles.py | 129 ++++++-- .../capabilities/anti_entropy/turn_context.py | 236 ++++++++++++++ loopx/capabilities/turn_context.py | 9 + loopx/cli_commands/anti_entropy.py | 103 +++--- tests/capabilities/test_anti_entropy.py | 183 +++++++++++ .../test_anti_entropy_feedback.py | 298 ++++++++++++++++++ 10 files changed, 901 insertions(+), 96 deletions(-) create mode 100644 loopx/capabilities/anti_entropy/turn_context.py diff --git a/loopx/capabilities/anti_entropy/README.md b/loopx/capabilities/anti_entropy/README.md index 98b7feead5..4ea1e6e71b 100644 --- a/loopx/capabilities/anti_entropy/README.md +++ b/loopx/capabilities/anti_entropy/README.md @@ -61,10 +61,14 @@ Verification distinguishes diff-visible facts from workspace-derived facts: The capability ships disabled (`default_enabled: false`); enabling it for a task is an explicit choice. When enabled, one closed loop per task is: -1. **Task context** — `build_anti_entropy_turn_context(basis, goal_id, - workspace)` projects the declared `RetirementBasis` into a typed - turn-context payload a host can inject into agent input. Building the - payload executes nothing. +1. **Task context** — `anti_entropy_context_registration` is composed into + the shared `loopx.capabilities.turn_context.attach_turn_context` owner: + a turn whose task declares a `RetirementBasis` (or explicitly enables the + capability) receives the targets, the inspect tool, and delete-first + guidance in its host input; disabled turns get zero injection, and + enabled-but-inapplicable turns only read back `enabled/applicable=false`. + Recovery turns (the payload carries the previous inspect outcome) get + remediation guidance instead. 2. **In-flight check** — agents call the `anti_entropy_inspect` tool (or `loopx anti-entropy inspect`). Blocking findings come back as **actionable feedback with per-rule next actions**, classified as progress @@ -127,7 +131,7 @@ no model runs and measures no agent-runtime behavior. ## Boundaries -- The capability is default-off and operates as an opt-in code hygiene verifier via CLI and typed receipts. +- The capability is default-off and task-scoped: turns receive the retirement context only when the task explicitly declares a retirement basis or enables the capability; the CLI (verify/inspect) remains the shared verification entry. - Delete-first requires target old logic to be cleanly decommissioned in the diff without re-introducing wrappers or zombie symbols. - Symbol retirement certification requires an explicitly bound workspace; diff-only evidence can reject but never certifies. - AST-based diff hygiene verifies exact code diffs without mutating repository code or performing speculative git writes. diff --git a/loopx/capabilities/anti_entropy/__init__.py b/loopx/capabilities/anti_entropy/__init__.py index e5c81f561c..65eca84019 100644 --- a/loopx/capabilities/anti_entropy/__init__.py +++ b/loopx/capabilities/anti_entropy/__init__.py @@ -21,6 +21,10 @@ evaluate_diff_hygiene, verify_delete_first, ) +from .turn_context import ( + anti_entropy_context_registration, + anti_entropy_context_result, +) __all__ = [ "ANTI_ENTROPY_CAPABILITY_ID", @@ -38,4 +42,6 @@ "run_anti_entropy_feedback_cycle", "run_anti_entropy_verify", "verify_delete_first", + "anti_entropy_context_registration", + "anti_entropy_context_result", ] diff --git a/loopx/capabilities/anti_entropy/catalog_entry.py b/loopx/capabilities/anti_entropy/catalog_entry.py index 062e8e53b7..17c006ca30 100644 --- a/loopx/capabilities/anti_entropy/catalog_entry.py +++ b/loopx/capabilities/anti_entropy/catalog_entry.py @@ -35,8 +35,8 @@ ), "workflow_skill": { "name": "loopx-anti-entropy", - "delivery": "standalone_cli", - "activation": "explicit_command_execution", + "delivery": "turn_context_hook_and_shared_cli", + "activation": "explicit_task_scoped_enablement", "project_copy_required": False, }, "commands": [ @@ -89,10 +89,13 @@ ], "docs": [_README], "boundaries": [ - "The capability is default-off and operates as an opt-in code hygiene verifier via CLI and typed receipts.", + "The capability is default-off and task-scoped: a turn receives the retirement context only when the task explicitly declares a retirement basis or enables the capability; disabled turns get zero injection.", + "The turn-context hook projects declared targets, the inspect tool, and delete-first guidance into host input via the shared attach_turn_context composition; it grants no execution authority.", + "The CLI (verify/inspect) remains the shared verification entry for humans and agents; final delivery acceptance is owned by the existing change-quality prepare/record/verify owner.", "Delete-first requires target old logic to be cleanly decommissioned in the diff without re-introducing wrappers or zombie symbols.", "AST-based diff hygiene verifies exact code diffs without mutating repository code or performing speculative git writes.", "Target paths declared for decommissioning must be cleanly removed in the unified diff.", + "Symbol retirement certification requires a completely scanned, explicitly bound post-change workspace; truncated scans (oversized files, unreadable files, file-count limits) block certification with AE004.", "Advisory findings highlight non-blocking hygiene opportunities (such as dead commented code) without causing false positive rejections of documentation or explanations.", "Inspired by and adopting core engineering principles of delete-first code retirement from Aegis.", ], diff --git a/loopx/capabilities/anti_entropy/feedback.py b/loopx/capabilities/anti_entropy/feedback.py index 4ccc71e073..3603f8bf25 100644 --- a/loopx/capabilities/anti_entropy/feedback.py +++ b/loopx/capabilities/anti_entropy/feedback.py @@ -31,8 +31,14 @@ ) _DELIVERY_ROUTE = ( - "loopx change-quality record --goal-id --result-path " - "then loopx change-quality verify --goal-id " + "loopx change-quality prepare --goal-id " + "(bind --repo-path/--base-ref to the reviewed scope), " + "then loopx change-quality record --goal-id " + "--result-json --execute " + "(result conforms to change_quality_agent_result_v2 with the prepared " + "scope_fingerprint), " + "then loopx change-quality verify --goal-id " + "(same --repo-path/--base-ref)" ) _TOOL_NAME = "anti_entropy_inspect" diff --git a/loopx/capabilities/anti_entropy/oracles.py b/loopx/capabilities/anti_entropy/oracles.py index 81a06791fa..7129e02861 100644 --- a/loopx/capabilities/anti_entropy/oracles.py +++ b/loopx/capabilities/anti_entropy/oracles.py @@ -39,12 +39,22 @@ re.compile( r"^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?::[^=]+)?=\s*(?:async\s+)?function" ), - # const / let / var Foo = (multiline signature start) + # const / let / var Foo = async ( re.compile( - r"^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?::[^=]+)?=\s*(?:async\s*)?(?:<[^>]*>)?\s*\(" + r"^\s*(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*(?::[^=]+)?=\s*async\s*(?:<[^>]*>)?\s*\(" + ), + # class methods / interface methods: e.g. public async oldMethod(param: string): void { + re.compile( + r"^\s*(?:(?:public|private|protected|readonly|override)\s+)*(?:static\s+)?(?:async\s+)?\*?\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:<[^>]*>)?\s*\([^)]*\)\s*(?::\s*[^{;]+)?\s*\{?" ), ] +_TS_RESERVED_NAMES = { + "if", "for", "switch", "while", "catch", "constructor", "get", "set", + "function", "class", "export", "import", "return", "throw", "default", + "new", "typeof", "instanceof", "void", "delete", "yield", "await", +} + _COMMENT_LINE_RE = re.compile(r"^\s*(?:#|//)\s*(.+)$") _DOC_EXTENSIONS = {".md", ".rst", ".txt", ".json", ".yaml", ".yml", ".toml"} @@ -289,7 +299,9 @@ def _extract_ts_defs(lines: list[str]) -> set[str]: for pattern in _TS_DEF_PATTERNS: m = pattern.match(line) if m: - defs.add(m.group(1)) + candidate = m.group(1) + if candidate not in _TS_RESERVED_NAMES: + defs.add(candidate) break return defs @@ -304,20 +316,44 @@ def _strip_ts_line(line: str) -> str: return line -def _extract_ts_refs(lines: list[str], defs: set[str]) -> set[str]: - """Word-level references for TS/JS-like lines, excluding definition lines themselves.""" +def _extract_ts_refs( + lines: list[str], + target_symbols: set[str], +) -> set[str]: + """Extract real references to target symbols in TS/JS-like lines. + + Strips comments and string-literal spans, then searches for whole-word identifier + matches. Definition lines only exclude the declared symbol identifier at its definition + site; cross-file imports, member calls (e.g. api.old_api()), and arrow expressions + referencing target_symbols are recorded as active references. + """ refs: set[str] = set() - for line in lines: - line_clean = line.strip() - is_def_line = any( - (m := pattern.match(line_clean)) and m.group(1) in defs - for pattern in _TS_DEF_PATTERNS - ) - if is_def_line: + if not target_symbols: + return refs + + for raw_line in lines: + stripped = _strip_ts_line(raw_line) + if not stripped.strip(): continue - for word in re.findall(r"[A-Za-z_$][A-Za-z0-9_$]*", _strip_ts_line(line)): - if word in defs: - refs.add(word) + + defined_on_line: str | None = None + for pattern in _TS_DEF_PATTERNS: + m = pattern.match(raw_line.strip()) + if m: + candidate = m.group(1) + if candidate in target_symbols and candidate not in _TS_RESERVED_NAMES: + defined_on_line = candidate + break + + line_for_refs = stripped + if defined_on_line: + line_for_refs = re.sub(r"\b" + re.escape(defined_on_line) + r"\b", "", line_for_refs, count=1) + + words = set(re.findall(r"[A-Za-z_$][A-Za-z0-9_$]*", line_for_refs)) + for s in target_symbols: + if s in words: + refs.add(s) + return refs @@ -365,7 +401,7 @@ class _DiffSymbolAnalysis: deleted_paths: set[str] = field(default_factory=set) -@dataclass(frozen=True, slots=True) +@dataclass(slots=True) class _WorkspaceEvidence: """Complete post-change source evidence bound to an explicitly given workspace.""" @@ -373,6 +409,9 @@ class _WorkspaceEvidence: defined_symbols: dict[str, set[str]] = field(default_factory=dict) referenced_symbols: dict[str, set[str]] = field(default_factory=dict) existing_paths: set[str] = field(default_factory=set) + scan_incomplete: bool = False + incomplete_reason: str = "" + skipped_files: tuple[str, ...] = () def _extract_python_refs_from_code(code: str) -> set[str] | None: @@ -410,24 +449,39 @@ def _scan_workspace(workspace: Path, symbols: tuple[str, ...]) -> _WorkspaceEvid symbol_set = set(symbols) source_exts = _PY_EXTENSIONS | _TS_EXTENSIONS visited = 0 + skipped: list[str] = [] + for dirpath, dirnames, filenames in os.walk(workspace): dirnames[:] = [d for d in dirnames if d not in _WORKSPACE_SKIP_DIRS] for filename in filenames: - if visited >= _MAX_WORKSPACE_FILES: - return evidence file_path = Path(dirpath) / filename if file_path.suffix.lower() not in source_exts: continue + rel = file_path.relative_to(workspace).as_posix() + if visited >= _MAX_WORKSPACE_FILES: + evidence.scan_incomplete = True + evidence.incomplete_reason = f"Workspace source file count exceeded limit ({_MAX_WORKSPACE_FILES} files)" + skipped.append(rel) + evidence.skipped_files = tuple(skipped) + return evidence try: - if file_path.stat().st_size > _MAX_SOURCE_FILE_BYTES: + size = file_path.stat().st_size + if size > _MAX_SOURCE_FILE_BYTES: + evidence.scan_incomplete = True + evidence.incomplete_reason = f"Source file '{rel}' exceeds size limit ({_MAX_SOURCE_FILE_BYTES} bytes)" + skipped.append(rel) continue content = file_path.read_text(encoding="utf-8") - except (OSError, UnicodeDecodeError): + except (OSError, UnicodeDecodeError) as exc: + evidence.scan_incomplete = True + evidence.incomplete_reason = f"Failed to read source file '{rel}': {exc}" + skipped.append(rel) continue + visited += 1 if not any(s in content for s in symbol_set): continue - rel = file_path.relative_to(workspace).as_posix() + ext = file_path.suffix.lower() if ext in _PY_EXTENSIONS: defs = _extract_python_defs_from_code(content) @@ -442,12 +496,15 @@ def _scan_workspace(workspace: Path, symbols: tuple[str, ...]) -> _WorkspaceEvid else: content_lines = content.splitlines() defs = _extract_ts_defs(content_lines) - refs = _extract_ts_refs(content_lines, defs) + refs = _extract_ts_refs(content_lines, symbol_set) + for s in symbol_set: if s in defs: evidence.defined_symbols.setdefault(s, set()).add(rel) if s in refs: evidence.referenced_symbols.setdefault(s, set()).add(rel) + + evidence.skipped_files = tuple(skipped) return evidence @@ -626,6 +683,21 @@ def _verify_symbol_targets( ) continue + if workspace_evidence.scan_incomplete: + findings.append( + AntiEntropyFinding( + rule_id=_RULE_AE004, + severity="blocking", + message=( + f"Target symbol '{symbol}' cannot be certified as retired: workspace scan is " + f"incomplete ({workspace_evidence.incomplete_reason}). Complete post-change " + "source evidence is required." + ), + location=symbol, + ) + ) + continue + unverified_files = [ f for f in analysis.unverified_source_files @@ -865,8 +937,17 @@ def audit_ghost_code(diff_text: str) -> list[AntiEntropyFinding]: is_code_stmt = True else: try: - ast.parse(comment_content) - is_code_stmt = True + parsed = ast.parse(comment_content) + if len(parsed.body) == 1 and isinstance(parsed.body[0], ast.Expr): + val = parsed.body[0].value + if isinstance(val, (ast.Name, ast.Constant)): + is_code_stmt = False + elif isinstance(val, ast.Tuple) and all(isinstance(elt, (ast.Name, ast.Constant)) for elt in val.elts): + is_code_stmt = False + else: + is_code_stmt = True + else: + is_code_stmt = True except SyntaxError: is_code_stmt = False diff --git a/loopx/capabilities/anti_entropy/turn_context.py b/loopx/capabilities/anti_entropy/turn_context.py new file mode 100644 index 0000000000..5655426d74 --- /dev/null +++ b/loopx/capabilities/anti_entropy/turn_context.py @@ -0,0 +1,236 @@ +"""Turn-context hook integration for anti-entropy code governance.""" + +from __future__ import annotations + +import json +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +from ...control_plane.capability_hooks import TurnContextHookRegistration +from .contract import RetirementBasis +from .feedback import build_anti_entropy_turn_context + +ANTI_ENTROPY_CONTEXT_MAX_RESULT_BYTES = 64 * 1024 + + +def _resolve_retirement_basis( + payload: Mapping[str, Any], + decision: Mapping[str, Any], +) -> RetirementBasis | None: + """Extract a declared retirement basis from turn payload, decision, or selected todo.""" + candidates = [ + payload.get("retirement_basis"), + payload.get("anti_entropy_basis"), + payload.get("anti_entropy", {}).get("retirement_basis") + if isinstance(payload.get("anti_entropy"), Mapping) + else None, + decision.get("retirement_basis"), + decision.get("anti_entropy_basis"), + decision.get("anti_entropy", {}).get("retirement_basis") + if isinstance(decision.get("anti_entropy"), Mapping) + else None, + ] + + selected_todo = payload.get("selected_todo") + if isinstance(selected_todo, Mapping): + candidates.extend( + [ + selected_todo.get("retirement_basis"), + selected_todo.get("anti_entropy_basis"), + ] + ) + + task = payload.get("task") + if isinstance(task, Mapping): + candidates.extend( + [ + task.get("retirement_basis"), + task.get("anti_entropy_basis"), + ] + ) + + for item in candidates: + if item is not None: + if isinstance(item, RetirementBasis): + return item + if isinstance(item, Mapping): + try: + basis = RetirementBasis.from_dict(item) + if basis.target_symbols or basis.target_paths: + return basis + except Exception: + pass + return None + + +def _is_anti_entropy_enabled( + payload: Mapping[str, Any], + decision: Mapping[str, Any], + basis: RetirementBasis | None, +) -> bool: + """Determine whether anti-entropy capability is enabled for this turn.""" + if payload.get("anti_entropy_enabled") is True: + return True + if decision.get("anti_entropy_enabled") is True: + return True + + ae_payload = payload.get("anti_entropy") + if isinstance(ae_payload, Mapping) and ae_payload.get("enabled") is True: + return True + + ae_decision = decision.get("anti_entropy") + if isinstance(ae_decision, Mapping) and ae_decision.get("enabled") is True: + return True + + envelope = payload.get("turn_envelope") + if isinstance(envelope, Mapping): + boundary = envelope.get("boundary") + if isinstance(boundary, Mapping): + caps = boundary.get("capabilities") + if isinstance(caps, Mapping): + ae_cap = caps.get("anti_entropy_governance") or caps.get("anti_entropy") + if isinstance(ae_cap, Mapping) and ae_cap.get("enabled") is True: + return True + if ae_cap is True: + return True + + # If an explicit retirement basis was declared, capability is implicitly enabled for that task + if basis is not None and (basis.target_symbols or basis.target_paths): + return True + + return False + + +def anti_entropy_context_result( + payload: Mapping[str, Any], + *, + goal_id: str, + decision: Mapping[str, Any], + workspace: Path | None = None, +) -> dict[str, Any]: + """Produce the bounded turn-context payload covering the four lifecycle states. + + 1. Disabled: capability is inactive. + 2. Enabled but not applicable: capability is active but task has no retirement targets. + 3. New task: declared targets projected into context + inspect tool + delete-first guidance. + 4. Failure recovery: previous diff had blocking findings; progress facts guide remediation. + """ + basis = _resolve_retirement_basis(payload, decision) + enabled = _is_anti_entropy_enabled(payload, decision, basis) + + # State 1: Disabled + if not enabled: + return { + "readback": {"anti_entropy_guidance": None}, + "context_fields": {}, + "instructions": [], + "result_fields": {}, + } + + # State 2: Enabled but not applicable + if basis is None or (not basis.target_symbols and not basis.target_paths): + return { + "readback": { + "anti_entropy_guidance": { + "enabled": True, + "applicable": False, + } + }, + "context_fields": {}, + "instructions": [], + "result_fields": {}, + } + + # Determine whether this is a failure recovery turn + recovery_evidence = ( + payload.get("anti_entropy_progress_fact") + or payload.get("anti_entropy_last_result") + or decision.get("anti_entropy_last_result") + ) + is_recovery = bool(recovery_evidence) + + # State 3 / 4: Active task or failure recovery + resolved_workspace = workspace or Path.cwd() + context = build_anti_entropy_turn_context( + basis=basis, + goal_id=goal_id, + workspace=resolved_workspace, + ) + + if is_recovery: + instructions = [ + "Anti-entropy recovery: previous diff check identified unretired symbols or diff hygiene issues. Address blocking findings before attempting delivery.", + "Run 'anti_entropy_inspect' or 'loopx anti-entropy inspect' to verify diff cleanliness; rejections are progress facts guiding the next correction.", + ] + status = "recovering" + else: + instructions = [ + "Anti-entropy code governance is active for this task: delete-first symbol and path retirement is required.", + "Call 'anti_entropy_inspect' or 'loopx anti-entropy inspect' to check diff cleanliness; blocking findings are progress facts to fix before delivery.", + ] + status = "active" + + readback = { + "anti_entropy_guidance": { + "enabled": True, + "applicable": True, + "target_symbols": list(basis.target_symbols), + "target_paths": list(basis.target_paths), + "status": status, + } + } + + full_result = { + "readback": readback, + "context_fields": {"anti_entropy_context": context}, + "instructions": instructions, + "result_fields": {}, + } + + encoded = json.dumps(full_result, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + if len(encoded) > ANTI_ENTROPY_CONTEXT_MAX_RESULT_BYTES: + # Bounded compaction: trim rationale or inspect tool descriptors if over budget + compact_context = dict(context) + compact_context.pop("inspect_tool", None) + return { + "readback": readback, + "context_fields": {"anti_entropy_context": compact_context}, + "instructions": instructions, + "result_fields": {}, + } + + return full_result + + +def anti_entropy_context_registration( + payload: Mapping[str, Any], + *, + registry_path: Path, + goal_id: str, + agent_id: str, + decision: Mapping[str, Any], + turn_instance_id: str, +) -> TurnContextHookRegistration: + """Create a TurnContextHookRegistration connecting anti-entropy into host input.""" + workspace = ( + Path(payload["workspace"]).resolve() + if "workspace" in payload + else (Path(payload["repo_path"]).resolve() if "repo_path" in payload else None) + ) + + def produce() -> Mapping[str, Any]: + return anti_entropy_context_result( + payload, + goal_id=goal_id, + decision=decision, + workspace=workspace, + ) + + return TurnContextHookRegistration( + hook_id="anti-entropy.turn-context", + capability_id="anti-entropy-governance", + readback_slots=("anti_entropy_guidance",), + producer=produce, + max_result_bytes=ANTI_ENTROPY_CONTEXT_MAX_RESULT_BYTES, + ) diff --git a/loopx/capabilities/turn_context.py b/loopx/capabilities/turn_context.py index 8655bdf0d4..2180c71b2d 100644 --- a/loopx/capabilities/turn_context.py +++ b/loopx/capabilities/turn_context.py @@ -11,6 +11,7 @@ PostSettlement, registered_post_settlement, ) +from .anti_entropy.turn_context import anti_entropy_context_registration from .reward_memory.turn_hooks import ( memory_context_registration, memory_settlement_registration, @@ -37,6 +38,14 @@ def attach_turn_context( decision=decision, turn_instance_id=turn_instance_id, ), + anti_entropy_context_registration( + payload, + registry_path=registry_path, + goal_id=goal_id, + agent_id=agent_id, + decision=decision, + turn_instance_id=turn_instance_id, + ), ), ) diff --git a/loopx/cli_commands/anti_entropy.py b/loopx/cli_commands/anti_entropy.py index ece7b7be90..508c785a2a 100644 --- a/loopx/cli_commands/anti_entropy.py +++ b/loopx/cli_commands/anti_entropy.py @@ -122,6 +122,41 @@ def _render(payload: dict[str, Any]) -> str: return "\n".join(lines) + "\n" +def _read_diff_text(args: argparse.Namespace) -> str: + if args.diff_file == "-": + return sys.stdin.read() + if args.diff_file: + diff_path = Path(args.diff_file).expanduser() + if diff_path.is_file(): + return diff_path.read_text(encoding="utf-8") + raise ValueError(f"diff file not found: {args.diff_file}") + return "" + + +def _read_basis_data(args: argparse.Namespace) -> Any: + if args.basis_json is None: + return None + raw_basis = args.basis_json.strip() + if raw_basis.startswith("{") or raw_basis.startswith("["): + return json.loads(raw_basis) + path = Path(raw_basis).expanduser() + if path.is_file(): + return json.loads(path.read_text(encoding="utf-8")) + try: + return json.loads(raw_basis) + except Exception: + raise ValueError(f"basis file not found: {raw_basis}") + + +def _resolve_workspace(args: argparse.Namespace) -> Path | None: + if not getattr(args, "workspace", None): + return None + workspace = Path(args.workspace).expanduser().resolve() + if not workspace.is_dir(): + raise ValueError(f"workspace directory not found: {args.workspace}") + return workspace + + def handle_anti_entropy_command( args: argparse.Namespace, *, @@ -134,38 +169,13 @@ def handle_anti_entropy_command( return None try: command = args.anti_entropy_command - if command == "verify": - diff_text = "" - if args.diff_file == "-": - diff_text = sys.stdin.read() - elif args.diff_file: - diff_path = Path(args.diff_file).expanduser() - if diff_path.is_file(): - diff_text = diff_path.read_text(encoding="utf-8") - else: - raise ValueError(f"diff file not found: {args.diff_file}") - - basis_data = None - if args.basis_json is not None: - raw_basis = args.basis_json.strip() - if raw_basis.startswith("{") or raw_basis.startswith("["): - basis_data = json.loads(raw_basis) - else: - path = Path(raw_basis).expanduser() - if path.is_file(): - basis_data = json.loads(path.read_text(encoding="utf-8")) - else: - try: - basis_data = json.loads(raw_basis) - except Exception: - raise ValueError(f"basis file not found: {raw_basis}") - - workspace: Path | None = None - if getattr(args, "workspace", None): - workspace = Path(args.workspace).expanduser().resolve() - if not workspace.is_dir(): - raise ValueError(f"workspace directory not found: {args.workspace}") + # Shared input reading: both entries bind the same diff, basis, and + # post-change workspace; only the outcome contract differs. + diff_text = _read_diff_text(args) + basis_data = _read_basis_data(args) + workspace = _resolve_workspace(args) + if command == "verify": payload = run_anti_entropy_verify( diff_text=diff_text, basis_data=basis_data, @@ -173,37 +183,6 @@ def handle_anti_entropy_command( workspace=workspace, ) elif command == "inspect": - diff_text = "" - if args.diff_file == "-": - diff_text = sys.stdin.read() - elif args.diff_file: - diff_path = Path(args.diff_file).expanduser() - if diff_path.is_file(): - diff_text = diff_path.read_text(encoding="utf-8") - else: - raise ValueError(f"diff file not found: {args.diff_file}") - - basis_data = None - if args.basis_json is not None: - raw_basis = args.basis_json.strip() - if raw_basis.startswith("{") or raw_basis.startswith("["): - basis_data = json.loads(raw_basis) - else: - path = Path(raw_basis).expanduser() - if path.is_file(): - basis_data = json.loads(path.read_text(encoding="utf-8")) - else: - try: - basis_data = json.loads(raw_basis) - except Exception: - raise ValueError(f"basis file not found: {raw_basis}") - - workspace = None - if getattr(args, "workspace", None): - workspace = Path(args.workspace).expanduser().resolve() - if not workspace.is_dir(): - raise ValueError(f"workspace directory not found: {args.workspace}") - from ..capabilities.anti_entropy.feedback import run_anti_entropy_feedback_cycle payload = run_anti_entropy_feedback_cycle( diff --git a/tests/capabilities/test_anti_entropy.py b/tests/capabilities/test_anti_entropy.py index ae05de3822..1ed03b5a11 100644 --- a/tests/capabilities/test_anti_entropy.py +++ b/tests/capabilities/test_anti_entropy.py @@ -2,6 +2,8 @@ from pathlib import Path +import pytest + from loopx.capabilities.anti_entropy import ( ANTI_ENTROPY_CAPABILITY_ID, ANTI_ENTROPY_CATALOG_ENTRY, @@ -852,3 +854,184 @@ def test_receipt_without_basis_has_empty_provenance(tmp_path: Path) -> None: assert ANTI_ENTROPY_RETIREMENT_BASIS_SCHEMA_VERSION == RetirementBasis().schema_version assert ANTI_ENTROPY_CAPABILITY_ID == "anti-entropy-governance" assert ANTI_ENTROPY_CATALOG_ENTRY["id"] == ANTI_ENTROPY_CAPABILITY_ID + + +def test_typescript_cross_file_reference_rejects_and_prevents_false_retirement(tmp_path: Path) -> None: + """Verify cross-file TS/JS member access (api.old_api()) blocks retirement certification.""" + diff = """ +--- a/service.mjs ++++ b/service.mjs +@@ -1,2 +1,2 @@ +-export function old_api() { return 1; } ++export function new_api() { return 2; } +""" + basis = RetirementBasis(target_symbols=("old_api",)) + ws = _make_workspace( + tmp_path / "ws_unmigrated", + { + "service.mjs": "export function new_api() { return 2; }\n", + "client.mjs": "import * as api from './service.mjs';\nexport function run() { return api.old_api(); }\n", + }, + ) + + receipt = evaluate_diff_hygiene(diff, basis, workspace=ws) + assert receipt.verdict == "rejected" + assert any( + f.rule_id == "AE002-ZOMBIE_SYMBOL_REINTRODUCED" and "client.mjs" in f.message + for f in receipt.findings + ) + assert receipt.retired_symbols == () + + +def test_typescript_cross_file_reference_passed_after_caller_migrated(tmp_path: Path) -> None: + """Verify paired positive case: retirement certifies after caller migrates away from old_api.""" + diff = """ +--- a/service.mjs ++++ b/service.mjs +@@ -1,2 +1,2 @@ +-export function old_api() { return 1; } ++export function new_api() { return 2; } +--- a/client.mjs ++++ b/client.mjs +@@ -2,1 +2,1 @@ +-export function run() { return api.old_api(); } ++export function run() { return api.new_api(); } +""" + basis = RetirementBasis(target_symbols=("old_api",)) + ws = _make_workspace( + tmp_path / "ws_migrated", + { + "service.mjs": "export function new_api() { return 2; }\n", + "client.mjs": "import * as api from './service.mjs';\nexport function run() { return api.new_api(); }\n", + }, + ) + + receipt = evaluate_diff_hygiene(diff, basis, workspace=ws) + assert receipt.verdict == "passed", f"findings: {receipt.findings}" + assert receipt.retired_symbols == ("old_api",) + + +def test_workspace_scan_truncation_on_large_file_fails_with_ae004(tmp_path: Path) -> None: + """Verify files exceeding scan size limits block certification with AE004 rather than false passing.""" + diff = """ +--- a/service.py ++++ b/service.py +@@ -1,2 +0,0 @@ +-def old_api(): +- return 1 +""" + basis = RetirementBasis(target_symbols=("old_api",)) + # Oversized file > 2_000_000 bytes + large_comment = "# " + ("A" * 100) + "\n" + oversized_content = "def old_api():\n return 42\n" + (large_comment * 20_500) + + ws = _make_workspace( + tmp_path / "ws_large", + { + "service.py": "# cleanly removed old_api\n", + "retained.py": oversized_content, + }, + ) + + receipt = evaluate_diff_hygiene(diff, basis, workspace=ws) + assert receipt.verdict == "rejected" + assert any( + f.rule_id == "AE004-INSUFFICIENT_SOURCE_EVIDENCE" and "exceeds size limit" in f.message + for f in receipt.findings + ) + assert receipt.retired_symbols == () + + +def test_workspace_scan_file_count_limit_fails_with_ae004( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Verify exceeding the workspace file-count limit blocks certification with AE004. + + A truncated scan cannot prove absence: even when the diff cleanly removes + the target, an unvisited source file might still define it. + """ + from loopx.capabilities.anti_entropy import oracles as ae_oracles + + monkeypatch.setattr(ae_oracles, "_MAX_WORKSPACE_FILES", 2) + + diff = """ +--- a/service.py ++++ b/service.py +@@ -1,2 +0,0 @@ +-def old_api(): +- return 1 +""" + basis = RetirementBasis(target_symbols=("old_api",)) + ws = _make_workspace( + tmp_path / "ws_many", + { + "service.py": "# cleanly removed old_api\n", + "a.py": "alpha = 1\n", + "b.py": "beta = 2\n", + "c.py": "gamma = 3\n", + }, + ) + + receipt = evaluate_diff_hygiene(diff, basis, workspace=ws) + assert receipt.verdict == "rejected" + assert any( + f.rule_id == "AE004-INSUFFICIENT_SOURCE_EVIDENCE" + and "file count exceeded limit" in f.message + for f in receipt.findings + ) + assert receipt.retired_symbols == () + + +def test_typescript_class_method_retirement() -> None: + """Verify TypeScript class methods (public, async, etc.) are recognized when cleanly deleted.""" + diff = """ +--- a/controller.ts ++++ b/controller.ts +@@ -1,8 +1,2 @@ + export class TaskController { +- public async oldTaskMethod(taskId: string): Promise { +- return; +- } +- private helperCalc(): number { return 1; } + newTaskMethod(): void {} + } +""" + basis = RetirementBasis(target_symbols=("oldTaskMethod", "helperCalc")) + receipt = evaluate_diff_hygiene(diff, basis) + assert any(f.rule_id == "AE004-INSUFFICIENT_SOURCE_EVIDENCE" for f in receipt.findings) + assert receipt.verdict == "rejected" + + +def test_ghost_code_ignores_natural_language_and_headings() -> None: + """Verify plain words / title headings in comments do not trigger ghost code false positives.""" + diff = """ +--- a/module.py ++++ b/module.py +@@ -1,2 +1,6 @@ + def compute(): ++ # Overview ++ # Architecture ++ # Design ++ # Summary + return 1 +""" + receipt = evaluate_diff_hygiene(diff) + assert receipt.verdict == "passed" + assert len(receipt.findings) == 0 + + +def test_typescript_parenthesized_expression_is_not_function_def() -> None: + """Verify const x = (a + b) * c is not misclassified as a function definition.""" + diff = """ +--- a/calc.ts ++++ b/calc.ts +@@ -1,2 +1,3 @@ + function run() { ++ const result = (a + b) * c; + } +""" + basis = RetirementBasis(target_symbols=("result",)) + receipt = evaluate_diff_hygiene(diff, basis) + assert receipt.verdict == "rejected" + assert any(f.rule_id == "AE004-INSUFFICIENT_SOURCE_EVIDENCE" for f in receipt.findings) + assert not any(f.rule_id == "AE002-ZOMBIE_SYMBOL_REINTRODUCED" for f in receipt.findings) diff --git a/tests/capabilities/test_anti_entropy_feedback.py b/tests/capabilities/test_anti_entropy_feedback.py index da3d5e7371..d7e642c1a2 100644 --- a/tests/capabilities/test_anti_entropy_feedback.py +++ b/tests/capabilities/test_anti_entropy_feedback.py @@ -5,6 +5,8 @@ import sys from pathlib import Path +import pytest + from loopx.capabilities.anti_entropy import ( ANTI_ENTROPY_CAPABILITY_ID, ANTI_ENTROPY_CATALOG_ENTRY, @@ -175,3 +177,299 @@ def test_inspect_cli_roundtrip(tmp_path: Path) -> None: capture_output=True, ) assert proc_verify.returncode == 1 + + +def test_delivery_route_has_valid_change_quality_parameters() -> None: + """Verify delivery route command matches loopx change-quality CLI syntax (--result-json, --execute).""" + from loopx.capabilities.anti_entropy.feedback import _DELIVERY_ROUTE + assert "--result-json" in _DELIVERY_ROUTE + assert "--execute" in _DELIVERY_ROUTE + assert "--result-path" not in _DELIVERY_ROUTE + assert "prepare" in _DELIVERY_ROUTE + assert "verify" in _DELIVERY_ROUTE + + +def test_delivery_route_executes_real_change_quality_flow( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """The emitted delivery route executes against the real change-quality owner. + + Full journey on a real Git retirement change: the anti-entropy inspect + round marks the change delivery-ready, then the route's prepare → + record (--result-json + --execute) → verify commands run through the real + CLI parser and handler, and any later diff change invalidates the stored + receipt (requalification_required). + """ + import subprocess as sp + + from loopx.capabilities.change_quality.receipt import ( + CHANGE_QUALITY_RESULT_SCHEMA_VERSION, + ) + from loopx.cli import main + from loopx.configure_goal import configure_goal + + goal_id = "anti-entropy-delivery-fixture" + + def git(repo: Path, *args: str) -> str: + return sp.run( + ["git", "-C", str(repo), *args], + check=True, text=True, + stdout=sp.PIPE, stderr=sp.PIPE, + ).stdout.strip() + + # Real Git fixture: committed old_api + caller, then a full retirement change. + repo = tmp_path / "repo" + repo.mkdir() + git(repo, "init") + git(repo, "config", "user.email", "anti-entropy@example.invalid") + git(repo, "config", "user.name", "Anti Entropy Fixture") + (repo / "service.py").write_text( + "def old_api():\n return 1\n", encoding="utf-8" + ) + (repo / "client.py").write_text( + "from service import old_api\n\ndef run():\n return old_api()\n", + encoding="utf-8", + ) + git(repo, "add", "service.py", "client.py") + git(repo, "commit", "-m", "fixture with legacy api") + # Post-change sources: migration completed, old_api fully removed. + (repo / "service.py").write_text( + "def new_api():\n return 2\n", encoding="utf-8" + ) + (repo / "client.py").write_text( + "from service import new_api\n\ndef run():\n return new_api()\n", + encoding="utf-8", + ) + real_diff = git(repo, "diff", "HEAD") + + registry = tmp_path / "registry.json" + runtime_root = tmp_path / "runtime" + registry.write_text( + json.dumps( + { + "common_runtime_root": str(runtime_root), + "goals": [ + {"id": goal_id, "repo": str(repo), "control_plane": {}} + ], + } + ), + encoding="utf-8", + ) + configure_goal( + registry_path=registry, + goal_id=goal_id, + change_quality_enabled=True, + change_quality_safe_fix=True, + change_quality_strict_receipt=True, + execute=True, + ) + + # 1. Anti-entropy inspect round: delivery-ready on the real post-change tree. + cycle = run_anti_entropy_feedback_cycle( + diff_text=real_diff, basis=BASIS, goal_id=goal_id, workspace=repo + ) + assert cycle["verdict"] == "passed", f"findings: {cycle['actionable_feedback']}" + assert cycle["delivery_ready"] is True + assert cycle["retired_symbols"] == ["old_api"] + + # The route template this round hands to the delivery owner. + from loopx.capabilities.anti_entropy.feedback import _DELIVERY_ROUTE + + scope_args = ["--repo-path", str(repo), "--base-ref", "HEAD"] + + # 2. Route step 1: prepare (real CLI parser + handler). + monkeypatch.chdir(tmp_path) + capsys.readouterr() + assert main( + ["--registry", str(registry), "--runtime-root", str(runtime_root), + "--format", "json", "change-quality", "prepare", + "--goal-id", goal_id, *scope_args] + ) == 0 + prepared = json.loads(capsys.readouterr().out) + fingerprint = prepared["scope"]["scope_fingerprint"] + assert fingerprint + + # 3. Route step 2: record with --result-json (change_quality_agent_result_v2). + result_path = tmp_path / "result.json" + result_path.write_text( + json.dumps( + { + "schema_version": CHANGE_QUALITY_RESULT_SCHEMA_VERSION, + "scope_fingerprint": fingerprint, + "reviewed_final_scope": True, + "reuse": { + "outcome": "retained", + "summary": "Fixture keeps the direct owner.", + "evidence_refs": ["path:service.py", "path:client.py"], + }, + "simplification": { + "outcome": "retained", + "summary": "Migration already minimal.", + "evidence_refs": ["path:service.py", "path:client.py"], + "safe_fix_applied": False, + }, + "risks": [], + "validation": [ + { + "validator": "anti-entropy-inspect", + "status": "passed", + "scope": "retirement targets certified on bound sources", + "required": True, + } + ], + } + ), + encoding="utf-8", + ) + capsys.readouterr() + assert main( + ["--registry", str(registry), "--runtime-root", str(runtime_root), + "--format", "json", "change-quality", "record", + "--goal-id", goal_id, *scope_args, + "--result-json", str(result_path), "--execute"] + ) == 0 + recorded = json.loads(capsys.readouterr().out) + assert recorded["decision"] == "pass" + assert recorded["receipt_path"] + + # 4. Route step 3: verify — valid for the unchanged exact scope. + capsys.readouterr() + assert main( + ["--registry", str(registry), "--runtime-root", str(runtime_root), + "--format", "json", "change-quality", "verify", + "--goal-id", goal_id, *scope_args] + ) == 0 + verified = json.loads(capsys.readouterr().out) + assert verified["status"] == "valid" + assert verified["pr_evidence"]["requalification_required"] is False + + # 5. Invalidation counterexample: any diff change after record requires + # requalification — the receipt is not a permanent pass. + (repo / "client.py").write_text( + "from service import new_api\n\ndef run():\n return new_api() + 1\n", + encoding="utf-8", + ) + capsys.readouterr() + assert main( + ["--registry", str(registry), "--runtime-root", str(runtime_root), + "--format", "json", "change-quality", "verify", + "--goal-id", goal_id, *scope_args] + ) == 1 + stale = json.loads(capsys.readouterr().out) + assert stale["ok"] is False + assert stale["pr_evidence"]["requalification_required"] is True + + # The executed tokens are exactly the route's contract: the string is a + # template of real, parser-accepted parameters. + for token in ("--result-json", "--execute", "prepare", "record", "verify"): + assert token in _DELIVERY_ROUTE + + +def test_turn_context_hook_lifecycle_all_states(tmp_path: Path) -> None: + """Verify turn-context hook across all 4 lifecycle states: disabled, non-applicable, new task, and recovery.""" + from loopx.capabilities.anti_entropy.turn_context import ( + anti_entropy_context_registration, + anti_entropy_context_result, + ) + from loopx.control_plane.capability_hooks import ( + dispatch_turn_context_hooks, + project_turn_context, + ) + + # State 1: Disabled + payload_disabled = {"goal_id": "g-1", "agent_id": "a-1"} + decision_disabled = {} + res_disabled = anti_entropy_context_result( + payload_disabled, goal_id="g-1", decision=decision_disabled + ) + assert res_disabled["readback"]["anti_entropy_guidance"] is None + assert res_disabled["context_fields"] == {} + assert res_disabled["instructions"] == [] + + # State 2: Enabled but not applicable (no retirement targets declared) + payload_na = {"goal_id": "g-1", "agent_id": "a-1", "anti_entropy_enabled": True} + res_na = anti_entropy_context_result( + payload_na, goal_id="g-1", decision={} + ) + assert res_na["readback"]["anti_entropy_guidance"]["enabled"] is True + assert res_na["readback"]["anti_entropy_guidance"]["applicable"] is False + assert res_na["context_fields"] == {} + + # State 3: Active new task (retirement basis declared) + basis_data = {"target_symbols": ["old_service"], "rationale": "migrate to v2"} + payload_active = { + "goal_id": "g-1", + "agent_id": "a-1", + "retirement_basis": basis_data, + "workspace": str(tmp_path), + } + res_active = anti_entropy_context_result( + payload_active, goal_id="g-1", decision={}, workspace=tmp_path + ) + guidance = res_active["readback"]["anti_entropy_guidance"] + assert guidance["enabled"] is True + assert guidance["applicable"] is True + assert guidance["status"] == "active" + assert "anti_entropy_context" in res_active["context_fields"] + assert any("delete-first" in inst.lower() for inst in res_active["instructions"]) + + # State 4: Failure recovery + payload_recovery = { + "goal_id": "g-1", + "agent_id": "a-1", + "retirement_basis": basis_data, + "anti_entropy_progress_fact": True, + "workspace": str(tmp_path), + } + res_recovery = anti_entropy_context_result( + payload_recovery, goal_id="g-1", decision={}, workspace=tmp_path + ) + guidance_rec = res_recovery["readback"]["anti_entropy_guidance"] + assert guidance_rec["status"] == "recovering" + assert any("recovery" in inst.lower() for inst in res_recovery["instructions"]) + + # End-to-end dispatch through capability hooks + reg = anti_entropy_context_registration( + payload_active, + registry_path=tmp_path, + goal_id="g-1", + agent_id="a-1", + decision={}, + turn_instance_id="t-1", + ) + payload_dispatched = dict(payload_active) + dispatch_turn_context_hooks(payload_dispatched, (reg,)) + assert payload_dispatched.get("anti_entropy_guidance") is not None + assert payload_dispatched["anti_entropy_guidance"]["status"] == "active" + + projected = project_turn_context(payload_dispatched) + assert "anti_entropy_context" in projected.get("capability_context_fields", []) + assert any("anti-entropy" in inst.lower() for inst in projected.get("capability_instructions", [])) + + +def test_attach_turn_context_integration(tmp_path: Path) -> None: + """Verify loopx.capabilities.turn_context.attach_turn_context wires anti-entropy with memory.""" + from loopx.capabilities.turn_context import attach_turn_context + + basis_data = {"target_symbols": ["old_api"], "rationale": "replaced"} + payload = { + "goal_id": "g-1", + "agent_id": "a-1", + "retirement_basis": basis_data, + "workspace": str(tmp_path), + } + + attach_turn_context( + payload, + registry_path=tmp_path, + goal_id="g-1", + agent_id="a-1", + decision={}, + turn_instance_id="turn-42", + ) + + assert "anti_entropy_guidance" in payload + assert payload["anti_entropy_guidance"]["status"] == "active" + assert payload["anti_entropy_guidance"]["target_symbols"] == ["old_api"] From 1e84464146acd16a9b4e37aa59c543ccf66dc742 Mon Sep 17 00:00:00 2001 From: LoopDesk Date: Fri, 30 Oct 2026 09:41:00 +0800 Subject: [PATCH 05/21] fix(anti-entropy): resolve review blockers on selected-todo intent, ts lexer, and path readiness - Connect native turn selected-todo intent (route, turn_envelope, decision, metadata, note) to turn context and enablement - Replace regex stripping with state-stack TS/JS lexer preserving template interpolations and inline URL code while catching unclosed syntax - Fix delivery_ready calculation for pure-path, empty basis, and mixed-target retirement tasks - Add comprehensive regression tests for interpolation, single-line URLs, unclosed syntax, and pure-path lifecycles Signed-off-by: LoopDesk --- loopx/capabilities/anti_entropy/cli.py | 6 +- loopx/capabilities/anti_entropy/contract.py | 2 + loopx/capabilities/anti_entropy/feedback.py | 29 ++- loopx/capabilities/anti_entropy/oracles.py | 245 ++++++++++++++++-- .../capabilities/anti_entropy/turn_context.py | 155 ++++++++--- tests/capabilities/test_anti_entropy.py | 98 +++++++ .../test_anti_entropy_feedback.py | 173 +++++++++++++ 7 files changed, 651 insertions(+), 57 deletions(-) diff --git a/loopx/capabilities/anti_entropy/cli.py b/loopx/capabilities/anti_entropy/cli.py index 3dff6d684c..346b404102 100644 --- a/loopx/capabilities/anti_entropy/cli.py +++ b/loopx/capabilities/anti_entropy/cli.py @@ -23,7 +23,11 @@ def run_anti_entropy_verify( retirement. Without it, verdicts can reject but never certify retirement (AE004 insufficient source evidence). """ - basis = RetirementBasis.from_dict(basis_data) if basis_data is not None else None + basis = ( + basis_data + if isinstance(basis_data, RetirementBasis) + else (RetirementBasis.from_dict(basis_data) if basis_data is not None else None) + ) receipt = evaluate_diff_hygiene(diff_text, basis, workspace=workspace) blocking_count = sum(1 for f in receipt.findings if f.severity == "blocking") diff --git a/loopx/capabilities/anti_entropy/contract.py b/loopx/capabilities/anti_entropy/contract.py index 20aa6b9aea..9da7539453 100644 --- a/loopx/capabilities/anti_entropy/contract.py +++ b/loopx/capabilities/anti_entropy/contract.py @@ -32,6 +32,8 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, data: Any) -> RetirementBasis: + if isinstance(data, cls): + return data if not isinstance(data, Mapping): raise TypeError( f"RetirementBasis data must be a JSON object (mapping), got {type(data).__name__}" diff --git a/loopx/capabilities/anti_entropy/feedback.py b/loopx/capabilities/anti_entropy/feedback.py index 3603f8bf25..0ccf8c6a88 100644 --- a/loopx/capabilities/anti_entropy/feedback.py +++ b/loopx/capabilities/anti_entropy/feedback.py @@ -24,6 +24,7 @@ from .agent_tool import anti_entropy_inspect from .contract import AntiEntropyFinding, RetirementBasis +from .oracles import normalize_repo_path _INSPECT_CLI_ROUTE = ( "loopx anti-entropy inspect --diff-file " @@ -194,7 +195,33 @@ def run_anti_entropy_feedback_cycle( ) ) declared_symbols = list(basis_obj.target_symbols) if basis_obj is not None else [] - all_targets_retired = bool(declared_symbols) and all(s in retired for s in declared_symbols) + declared_paths = list(basis_obj.target_paths) if basis_obj is not None else [] + has_declared_targets = bool(declared_symbols or declared_paths) + + symbols_retired = ( + not declared_symbols + or (bool(declared_symbols) and all(s in retired for s in declared_symbols)) + ) + + finding_locations = {f.get("location") for f in findings if f.get("location")} + paths_clean = True + if declared_paths: + if any(p in finding_locations for p in declared_paths): + paths_clean = False + elif workspace is not None: + workspace_path = Path(workspace).resolve() + if workspace_path.exists(): + for p in declared_paths: + norm = normalize_repo_path(p) + if norm != "/dev/null" and (workspace_path / norm).resolve().exists(): + paths_clean = False + break + else: + paths_clean = False + else: + paths_clean = False + + all_targets_retired = has_declared_targets and symbols_retired and paths_clean delivery_ready = ( verdict == "passed" diff --git a/loopx/capabilities/anti_entropy/oracles.py b/loopx/capabilities/anti_entropy/oracles.py index 7129e02861..b50b47354c 100644 --- a/loopx/capabilities/anti_entropy/oracles.py +++ b/loopx/capabilities/anti_entropy/oracles.py @@ -306,33 +306,241 @@ def _extract_ts_defs(lines: list[str]) -> set[str]: return defs +def _tokenize_ts_code(text: str) -> tuple[str, bool]: + """Tokenize and strip comments and string-literal spans from TS/JS code. + + Preserves code expressions inside template string interpolations (`${` ... `}`), + ensuring that active references to target symbols are preserved while plain + template text and string literals are replaced with spaces. + Handles nested template literals, brace nesting inside expressions, and + escaped characters. + Returns (stripped_code, is_clean) where is_clean is False if syntax has + unclosed template literals, block comments, or unclosed strings. + """ + out: list[str] = [] + i = 0 + n = len(text) + stack: list[Any] = [] # items: 'template' or {'type': 'expr', 'brace_depth': int} + + while i < n: + if not stack: + # Top-level code + if text[i : i + 2] == "//": + while i < n and text[i] != "\n": + out.append(" ") + i += 1 + if i < n and text[i] == "\n": + out.append("\n") + i += 1 + elif text[i : i + 2] == "/*": + out.append(" ") + out.append(" ") + i += 2 + closed = False + while i < n: + if text[i : i + 2] == "*/": + out.append(" ") + out.append(" ") + i += 2 + closed = True + break + out.append("\n" if text[i] == "\n" else " ") + i += 1 + if not closed: + return "".join(out), False + elif text[i] == '"': + out.append(" ") + i += 1 + closed = False + while i < n: + if text[i] == "\\": + out.append(" ") + out.append(" ") + i += 2 if i + 1 < n else 1 + continue + if text[i] == '"': + out.append(" ") + i += 1 + closed = True + break + if text[i] == "\n": + break + out.append(" ") + i += 1 + if not closed: + return "".join(out), False + elif text[i] == "'": + out.append(" ") + i += 1 + closed = False + while i < n: + if text[i] == "\\": + out.append(" ") + out.append(" ") + i += 2 if i + 1 < n else 1 + continue + if text[i] == "'": + out.append(" ") + i += 1 + closed = True + break + if text[i] == "\n": + break + out.append(" ") + i += 1 + if not closed: + return "".join(out), False + elif text[i] == "`": + out.append(" ") + i += 1 + stack.append("template") + else: + out.append(text[i]) + i += 1 + elif stack[-1] == "template": + # Inside template literal + if text[i : i + 2] == "${": + out.append(" ") + out.append(" ") + i += 2 + stack.append({"type": "expr", "brace_depth": 1}) + elif text[i] == "`": + out.append(" ") + i += 1 + stack.pop() + elif text[i] == "\\": + out.append(" ") + out.append(" ") + i += 2 if i + 1 < n else 1 + else: + out.append("\n" if text[i] == "\n" else " ") + i += 1 + else: + # Inside template interpolation expression + top = stack[-1] + if text[i : i + 2] == "//": + while i < n and text[i] != "\n": + out.append(" ") + i += 1 + if i < n and text[i] == "\n": + out.append("\n") + i += 1 + elif text[i : i + 2] == "/*": + out.append(" ") + out.append(" ") + i += 2 + closed = False + while i < n: + if text[i : i + 2] == "*/": + out.append(" ") + out.append(" ") + i += 2 + closed = True + break + out.append("\n" if text[i] == "\n" else " ") + i += 1 + if not closed: + return "".join(out), False + elif text[i] == '"': + out.append(" ") + i += 1 + closed = False + while i < n: + if text[i] == "\\": + out.append(" ") + out.append(" ") + i += 2 if i + 1 < n else 1 + continue + if text[i] == '"': + out.append(" ") + i += 1 + closed = True + break + if text[i] == "\n": + break + out.append(" ") + i += 1 + if not closed: + return "".join(out), False + elif text[i] == "'": + out.append(" ") + i += 1 + closed = False + while i < n: + if text[i] == "\\": + out.append(" ") + out.append(" ") + i += 2 if i + 1 < n else 1 + continue + if text[i] == "'": + out.append(" ") + i += 1 + closed = True + break + if text[i] == "\n": + break + out.append(" ") + i += 1 + if not closed: + return "".join(out), False + elif text[i] == "`": + out.append(" ") + i += 1 + stack.append("template") + elif text[i] == "{": + top["brace_depth"] += 1 + out.append("{") + i += 1 + elif text[i] == "}": + top["brace_depth"] -= 1 + if top["brace_depth"] == 0: + stack.pop() + out.append(" ") + i += 1 + else: + out.append("}") + i += 1 + else: + out.append(text[i]) + i += 1 + + return "".join(out), len(stack) == 0 + + def _strip_ts_line(line: str) -> str: - """Strip // comments and string-literal spans from a TS/JS-like line.""" - line = re.sub(r"//.*$", "", line) - line = re.sub(r"/\*.*?(?:\*/|$)", "", line) - line = re.sub(r'"(?:\\.|[^"\\])*"', '""', line) - line = re.sub(r"'(?:\\.|[^'\\])*'", "''", line) - line = re.sub(r"`(?:\\.|[^`\\])*`", "``", line) - return line + """Strip // comments and string-literal spans from a TS/JS-like line, preserving template interpolations.""" + stripped, _ = _tokenize_ts_code(line) + return stripped def _extract_ts_refs( lines: list[str], target_symbols: set[str], ) -> set[str]: + refs, _ = _extract_ts_refs_with_syntax_check(lines, target_symbols) + return refs + + +def _extract_ts_refs_with_syntax_check( + lines: list[str], + target_symbols: set[str], +) -> tuple[set[str], bool]: """Extract real references to target symbols in TS/JS-like lines. - Strips comments and string-literal spans, then searches for whole-word identifier - matches. Definition lines only exclude the declared symbol identifier at its definition - site; cross-file imports, member calls (e.g. api.old_api()), and arrow expressions - referencing target_symbols are recorded as active references. + Preserves lexical boundaries: template string text, string literals, and + comments are stripped, while executable code inside template interpolations + is preserved. """ refs: set[str] = set() if not target_symbols: - return refs + return refs, True + + full_text = "\n".join(lines) + stripped_text, is_clean = _tokenize_ts_code(full_text) + stripped_lines = stripped_text.splitlines() - for raw_line in lines: - stripped = _strip_ts_line(raw_line) + for i, raw_line in enumerate(lines): + stripped = stripped_lines[i] if i < len(stripped_lines) else "" if not stripped.strip(): continue @@ -354,7 +562,7 @@ def _extract_ts_refs( if s in words: refs.add(s) - return refs + return refs, is_clean def _extract_defs_for_file(lines: list[str], file_path: str = "", is_full_file: bool = False) -> set[str]: @@ -496,7 +704,12 @@ def _scan_workspace(workspace: Path, symbols: tuple[str, ...]) -> _WorkspaceEvid else: content_lines = content.splitlines() defs = _extract_ts_defs(content_lines) - refs = _extract_ts_refs(content_lines, symbol_set) + refs, syntax_ok = _extract_ts_refs_with_syntax_check(content_lines, symbol_set) + if not syntax_ok: + evidence.scan_incomplete = True + evidence.incomplete_reason = f"Source file '{rel}' has unclosed or unparseable JS/TS syntax" + skipped.append(rel) + continue for s in symbol_set: if s in defs: diff --git a/loopx/capabilities/anti_entropy/turn_context.py b/loopx/capabilities/anti_entropy/turn_context.py index 5655426d74..a5ce30ba08 100644 --- a/loopx/capabilities/anti_entropy/turn_context.py +++ b/loopx/capabilities/anti_entropy/turn_context.py @@ -14,53 +14,105 @@ ANTI_ENTROPY_CONTEXT_MAX_RESULT_BYTES = 64 * 1024 +def _extract_basis_candidate(candidate: Any) -> RetirementBasis | None: + """Recursively search for a valid RetirementBasis in a candidate object or dictionary.""" + if candidate is None: + return None + if isinstance(candidate, RetirementBasis): + return candidate if (candidate.target_symbols or candidate.target_paths) else None + if isinstance(candidate, str): + candidate_clean = candidate.strip() + if (candidate_clean.startswith("{") and candidate_clean.endswith("}")) or ( + candidate_clean.startswith("[") and candidate_clean.endswith("]") + ): + try: + parsed = json.loads(candidate_clean) + return _extract_basis_candidate(parsed) + except Exception: + pass + return None + if isinstance(candidate, Mapping): + # 1. Direct explicit retirement_basis or anti_entropy_basis keys + for key in ("retirement_basis", "anti_entropy_basis"): + if key in candidate: + b = _extract_basis_candidate(candidate[key]) + if b is not None: + return b + + # 2. anti_entropy block + if "anti_entropy" in candidate and isinstance(candidate["anti_entropy"], Mapping): + b = _extract_basis_candidate(candidate["anti_entropy"]) + if b is not None: + return b + + # 3. Containers commonly holding task params or metadata + for container_key in ("metadata", "params", "task_spec", "task_parameters"): + if container_key in candidate: + b = _extract_basis_candidate(candidate[container_key]) + if b is not None: + return b + + # 4. Direct basis shape (target_symbols or target_paths) + if "target_symbols" in candidate or "target_paths" in candidate: + try: + b = RetirementBasis.from_dict(candidate) + if b.target_symbols or b.target_paths: + return b + except Exception: + pass + + # 5. String fields that might store JSON payload + for text_key in ("note", "description", "instruction"): + text_val = candidate.get(text_key) + if isinstance(text_val, str): + b = _extract_basis_candidate(text_val) + if b is not None: + return b + + return None + + def _resolve_retirement_basis( payload: Mapping[str, Any], decision: Mapping[str, Any], ) -> RetirementBasis | None: """Extract a declared retirement basis from turn payload, decision, or selected todo.""" candidates = [ + # Top-level direct fields payload.get("retirement_basis"), payload.get("anti_entropy_basis"), - payload.get("anti_entropy", {}).get("retirement_basis") - if isinstance(payload.get("anti_entropy"), Mapping) - else None, + payload.get("anti_entropy"), decision.get("retirement_basis"), decision.get("anti_entropy_basis"), - decision.get("anti_entropy", {}).get("retirement_basis") - if isinstance(decision.get("anti_entropy"), Mapping) - else None, + decision.get("anti_entropy"), + + # Native route and turn_envelope selected todos + payload.get("route", {}).get("selected_todo") + if isinstance(payload.get("route"), Mapping) else None, + payload.get("turn_envelope", {}).get("action", {}).get("selected_todo") + if isinstance(payload.get("turn_envelope"), Mapping) + and isinstance(payload.get("turn_envelope", {}).get("action"), Mapping) else None, + payload.get("turn_envelope", {}).get("selected_todo") + if isinstance(payload.get("turn_envelope"), Mapping) else None, + + # Top-level and task fields + payload.get("selected_todo"), + payload.get("task"), + + # Decision-side selected todos + decision.get("selected_todo"), + decision.get("action", {}).get("selected_todo") + if isinstance(decision.get("action"), Mapping) else None, + decision.get("route", {}).get("selected_todo") + if isinstance(decision.get("route"), Mapping) else None, + decision.get("task"), ] - selected_todo = payload.get("selected_todo") - if isinstance(selected_todo, Mapping): - candidates.extend( - [ - selected_todo.get("retirement_basis"), - selected_todo.get("anti_entropy_basis"), - ] - ) - - task = payload.get("task") - if isinstance(task, Mapping): - candidates.extend( - [ - task.get("retirement_basis"), - task.get("anti_entropy_basis"), - ] - ) + for cand in candidates: + basis = _extract_basis_candidate(cand) + if basis is not None: + return basis - for item in candidates: - if item is not None: - if isinstance(item, RetirementBasis): - return item - if isinstance(item, Mapping): - try: - basis = RetirementBasis.from_dict(item) - if basis.target_symbols or basis.target_paths: - return basis - except Exception: - pass return None @@ -70,9 +122,12 @@ def _is_anti_entropy_enabled( basis: RetirementBasis | None, ) -> bool: """Determine whether anti-entropy capability is enabled for this turn.""" - if payload.get("anti_entropy_enabled") is True: + # If an explicit retirement basis was declared, capability is implicitly enabled for that task + if basis is not None and (basis.target_symbols or basis.target_paths): return True - if decision.get("anti_entropy_enabled") is True: + + # Check direct payload and decision enablement + if payload.get("anti_entropy_enabled") is True or decision.get("anti_entropy_enabled") is True: return True ae_payload = payload.get("anti_entropy") @@ -83,6 +138,11 @@ def _is_anti_entropy_enabled( if isinstance(ae_decision, Mapping) and ae_decision.get("enabled") is True: return True + # Check route and turn_envelope boundary capabilities + route = payload.get("route") + if isinstance(route, Mapping) and route.get("anti_entropy_enabled") is True: + return True + envelope = payload.get("turn_envelope") if isinstance(envelope, Mapping): boundary = envelope.get("boundary") @@ -95,9 +155,26 @@ def _is_anti_entropy_enabled( if ae_cap is True: return True - # If an explicit retirement basis was declared, capability is implicitly enabled for that task - if basis is not None and (basis.target_symbols or basis.target_paths): - return True + # Check selected todos for anti_entropy_enabled flag + todos_to_check = [ + payload.get("selected_todo"), + payload.get("route", {}).get("selected_todo") if isinstance(payload.get("route"), Mapping) else None, + payload.get("turn_envelope", {}).get("action", {}).get("selected_todo") + if isinstance(payload.get("turn_envelope"), Mapping) + and isinstance(payload.get("turn_envelope", {}).get("action"), Mapping) else None, + decision.get("selected_todo"), + decision.get("action", {}).get("selected_todo") if isinstance(decision.get("action"), Mapping) else None, + ] + for td in todos_to_check: + if isinstance(td, Mapping): + if td.get("anti_entropy_enabled") is True: + return True + meta = td.get("metadata") + if isinstance(meta, Mapping) and meta.get("anti_entropy_enabled") is True: + return True + params = td.get("params") + if isinstance(params, Mapping) and params.get("anti_entropy_enabled") is True: + return True return False diff --git a/tests/capabilities/test_anti_entropy.py b/tests/capabilities/test_anti_entropy.py index 1ed03b5a11..b387690a3e 100644 --- a/tests/capabilities/test_anti_entropy.py +++ b/tests/capabilities/test_anti_entropy.py @@ -1035,3 +1035,101 @@ def test_typescript_parenthesized_expression_is_not_function_def() -> None: assert receipt.verdict == "rejected" assert any(f.rule_id == "AE004-INSUFFICIENT_SOURCE_EVIDENCE" for f in receipt.findings) assert not any(f.rule_id == "AE002-ZOMBIE_SYMBOL_REINTRODUCED" for f in receipt.findings) + + +def test_typescript_template_literal_interpolation_detected_as_active_ref(tmp_path: Path) -> None: + """Verify TS expressions inside `${...}` template interpolations are detected as active refs.""" + legacy_file = tmp_path / "legacy.ts" + legacy_file.write_text("export function new_api() { return 1; }\n", encoding="utf-8") + consumer_file = tmp_path / "consumer.ts" + consumer_file.write_text("console.log(`${api.old_api()}`);\n", encoding="utf-8") + + diff = """ +--- a/legacy.ts ++++ b/legacy.ts +@@ -1,4 +1,1 @@ +-export function old_api() { +- return 0; +-} + export function new_api() { return 1; } +""" + basis = RetirementBasis(target_symbols=("old_api",)) + receipt = evaluate_diff_hygiene(diff, basis, workspace=tmp_path) + assert receipt.verdict == "rejected" + assert any(f.rule_id.startswith("AE002") and "consumer.ts" in f.message for f in receipt.findings) + assert "old_api" not in receipt.retired_symbols + + +def test_typescript_url_line_with_code_detected_as_active_ref(tmp_path: Path) -> None: + """Verify // inside string literals (e.g. URLs) does not strip code on the same line.""" + legacy_file = tmp_path / "legacy.ts" + legacy_file.write_text("export function new_api() { return 1; }\n", encoding="utf-8") + consumer_file = tmp_path / "consumer.ts" + consumer_file.write_text( + "const url = 'https://example.invalid'; console.log(api.old_api());\n", + encoding="utf-8", + ) + + diff = """ +--- a/legacy.ts ++++ b/legacy.ts +@@ -1,4 +1,1 @@ +-export function old_api() { +- return 0; +-} + export function new_api() { return 1; } +""" + basis = RetirementBasis(target_symbols=("old_api",)) + receipt = evaluate_diff_hygiene(diff, basis, workspace=tmp_path) + assert receipt.verdict == "rejected" + assert any(f.rule_id.startswith("AE002") and "consumer.ts" in f.message for f in receipt.findings) + assert "old_api" not in receipt.retired_symbols + + +def test_typescript_migrated_template_string_without_interpolation_passes(tmp_path: Path) -> None: + """Verify plain string text inside backticks mentioning the old symbol does not block retirement.""" + legacy_file = tmp_path / "legacy.ts" + legacy_file.write_text("export function new_api() { return 1; }\n", encoding="utf-8") + consumer_file = tmp_path / "consumer.ts" + consumer_file.write_text( + "console.log(`Retired old_api successfully, now running new_api`);\n", + encoding="utf-8", + ) + + diff = """ +--- a/legacy.ts ++++ b/legacy.ts +@@ -1,4 +1,1 @@ +-export function old_api() { +- return 0; +-} + export function new_api() { return 1; } +""" + basis = RetirementBasis(target_symbols=("old_api",)) + receipt = evaluate_diff_hygiene(diff, basis, workspace=tmp_path) + assert receipt.verdict == "passed" + assert "old_api" in receipt.retired_symbols + assert len(receipt.findings) == 0 + + +def test_typescript_unclosed_syntax_reports_insufficient_evidence(tmp_path: Path) -> None: + """Verify unclosed/unparseable JS/TS syntax reports AE004 rather than false pass.""" + legacy_file = tmp_path / "legacy.ts" + legacy_file.write_text("export function new_api() { return 1; }\n", encoding="utf-8") + broken_file = tmp_path / "broken.ts" + broken_file.write_text("const x = `hello ${old_api;\n", encoding="utf-8") + + diff = """ +--- a/legacy.ts ++++ b/legacy.ts +@@ -1,4 +1,1 @@ +-export function old_api() { +- return 0; +-} + export function new_api() { return 1; } +""" + basis = RetirementBasis(target_symbols=("old_api",)) + receipt = evaluate_diff_hygiene(diff, basis, workspace=tmp_path) + assert receipt.verdict == "rejected" + assert any(f.rule_id.startswith("AE004") for f in receipt.findings) + assert "old_api" not in receipt.retired_symbols diff --git a/tests/capabilities/test_anti_entropy_feedback.py b/tests/capabilities/test_anti_entropy_feedback.py index d7e642c1a2..9375c1717c 100644 --- a/tests/capabilities/test_anti_entropy_feedback.py +++ b/tests/capabilities/test_anti_entropy_feedback.py @@ -14,6 +14,8 @@ build_anti_entropy_turn_context, run_anti_entropy_feedback_cycle, ) +from loopx.capabilities.anti_entropy.contract import RetirementBasis +from loopx.capabilities.anti_entropy.turn_context import anti_entropy_context_result CLEAN_DIFF = """ --- a/service.py @@ -473,3 +475,174 @@ def test_attach_turn_context_integration(tmp_path: Path) -> None: assert "anti_entropy_guidance" in payload assert payload["anti_entropy_guidance"]["status"] == "active" assert payload["anti_entropy_guidance"]["target_symbols"] == ["old_api"] + + +def test_feedback_cycle_pure_path_delivery_ready(tmp_path: Path) -> None: + """A pure path retirement task with file deleted in diff and absent on disk is delivery ready.""" + (tmp_path / "service.py").write_text("pass\n", encoding="utf-8") + # legacy.py does not exist on disk + diff = """ +--- a/legacy.py ++++ /dev/null +@@ -1,3 +0,0 @@ +-def dead(): +- pass +""" + basis = RetirementBasis(target_paths=("legacy.py",)) + result = run_anti_entropy_feedback_cycle( + diff_text=diff, basis=basis, goal_id="g1", workspace=tmp_path + ) + assert result["ok"] is True + assert result["verdict"] == "passed" + assert result["delivery_ready"] is True + assert result["is_progress_fact"] is False + assert result["retired_symbols"] == [] + assert len(result["actionable_feedback"]) == 0 + + +def test_feedback_cycle_pure_path_retained_in_workspace_blocks(tmp_path: Path) -> None: + """A path deleted in diff but still physically present in workspace blocks with AE004.""" + (tmp_path / "legacy.py").write_text("def dead(): pass\n", encoding="utf-8") + diff = """ +--- a/legacy.py ++++ /dev/null +@@ -1,3 +0,0 @@ +-def dead(): +- pass +""" + basis = RetirementBasis(target_paths=("legacy.py",)) + result = run_anti_entropy_feedback_cycle( + diff_text=diff, basis=basis, goal_id="g1", workspace=tmp_path + ) + assert result["verdict"] == "rejected" + assert result["delivery_ready"] is False + assert result["is_progress_fact"] is True + assert any(f["rule_id"].startswith("AE004") for f in result["actionable_feedback"]) + + +def test_feedback_cycle_pure_path_not_deleted_in_diff_blocks(tmp_path: Path) -> None: + """A path not deleted in the diff blocks with AE001.""" + diff = """ +--- a/legacy.py ++++ b/legacy.py +@@ -1,3 +1,3 @@ +-def dead(): ++def modified(): + pass +""" + basis = RetirementBasis(target_paths=("legacy.py",)) + result = run_anti_entropy_feedback_cycle( + diff_text=diff, basis=basis, goal_id="g1", workspace=tmp_path + ) + assert result["verdict"] == "rejected" + assert result["delivery_ready"] is False + assert any(f["rule_id"].startswith("AE001") for f in result["actionable_feedback"]) + + +def test_feedback_cycle_empty_basis_not_delivery_ready(tmp_path: Path) -> None: + """An empty retirement basis without targets never becomes delivery ready.""" + basis = RetirementBasis() + result = run_anti_entropy_feedback_cycle( + diff_text=CLEAN_DIFF, basis=basis, goal_id="g1", workspace=tmp_path + ) + assert result["delivery_ready"] is False + + +def test_feedback_cycle_mixed_targets_both_required(tmp_path: Path) -> None: + """Mixed targets (symbol + path) require both to be certified for delivery readiness.""" + service = tmp_path / "service.py" + service.write_text("def new_api(): return 1\n", encoding="utf-8") + # legacy.py absent from workspace + + clean_both_diff = """ +--- a/service.py ++++ b/service.py +@@ -1,3 +1,1 @@ +-def old_api(): +- return 0 + def new_api(): return 1 +--- a/legacy.py ++++ /dev/null +@@ -1,2 +0,0 @@ +-pass +""" + basis = RetirementBasis(target_symbols=("old_api",), target_paths=("legacy.py",)) + res_both = run_anti_entropy_feedback_cycle( + diff_text=clean_both_diff, basis=basis, goal_id="g1", workspace=tmp_path + ) + assert res_both["verdict"] == "passed" + assert res_both["delivery_ready"] is True + assert res_both["retired_symbols"] == ["old_api"] + + # Symbol retired, but legacy.py still present on disk + (tmp_path / "legacy.py").write_text("pass\n", encoding="utf-8") + res_retained_path = run_anti_entropy_feedback_cycle( + diff_text=clean_both_diff, basis=basis, goal_id="g1", workspace=tmp_path + ) + assert res_retained_path["verdict"] == "rejected" + assert res_retained_path["delivery_ready"] is False + (tmp_path / "legacy.py").unlink() + + # Path deleted, but symbol definition still on disk + service.write_text("def new_api(): return 1\ndef old_api(): return 0\n", encoding="utf-8") + res_retained_symbol = run_anti_entropy_feedback_cycle( + diff_text=clean_both_diff, basis=basis, goal_id="g1", workspace=tmp_path + ) + assert res_retained_symbol["verdict"] == "rejected" + assert res_retained_symbol["delivery_ready"] is False + + +def test_turn_context_extracts_native_route_and_envelope_selected_todos() -> None: + """Verify retirement basis is extracted from native route and turn_envelope structures.""" + # 1. Route selected_todo + payload_route = { + "route": { + "selected_todo": { + "todo_id": "todo-rt-1", + "retirement_basis": {"target_symbols": ["old_route_func"]}, + } + } + } + res_route = anti_entropy_context_result(payload_route, goal_id="g1", decision={}) + assert res_route["readback"]["anti_entropy_guidance"]["status"] == "active" + assert res_route["readback"]["anti_entropy_guidance"]["target_symbols"] == ["old_route_func"] + assert "anti_entropy_context" in res_route["context_fields"] + + # 2. Turn envelope action selected_todo with metadata + payload_envelope = { + "turn_envelope": { + "action": { + "selected_todo": { + "todo_id": "todo-env-1", + "metadata": {"retirement_basis": {"target_symbols": ["old_env_func"]}}, + } + } + } + } + res_env = anti_entropy_context_result(payload_envelope, goal_id="g1", decision={}) + assert res_env["readback"]["anti_entropy_guidance"]["status"] == "active" + assert res_env["readback"]["anti_entropy_guidance"]["target_symbols"] == ["old_env_func"] + + # 3. Decision selected_todo with params + decision_params = { + "selected_todo": { + "todo_id": "todo-dec-1", + "params": {"retirement_basis": {"target_paths": ["old_dec.py"]}}, + } + } + res_dec = anti_entropy_context_result({}, goal_id="g1", decision=decision_params) + assert res_dec["readback"]["anti_entropy_guidance"]["status"] == "active" + assert res_dec["readback"]["anti_entropy_guidance"]["target_paths"] == ["old_dec.py"] + + # 4. Route selected_todo with JSON string in note + payload_note = { + "route": { + "selected_todo": { + "todo_id": "todo-note-1", + "note": '{"target_symbols": ["old_note_func"]}', + } + } + } + res_note = anti_entropy_context_result(payload_note, goal_id="g1", decision={}) + assert res_note["readback"]["anti_entropy_guidance"]["status"] == "active" + assert res_note["readback"]["anti_entropy_guidance"]["target_symbols"] == ["old_note_func"] From fd825311288244907b8b3ddd599bc02fed520ed9 Mon Sep 17 00:00:00 2001 From: LoopDesk Date: Fri, 30 Oct 2026 17:44:25 +0800 Subject: [PATCH 06/21] fix(anti-entropy): bind real task intent, computed member refs, and fail-closed verify Resolve the remaining review blockers on PR #7545: - Turn context now reads the selected Todo's own authoritative input: the native work_context sources (canonical Todo note/instruction/body) and the action's selected_todo.text / recommended_action, in addition to direct payload keys. A retirement declaration opts a task in; the post-change workspace is the Goal repository from the registry, never the process CWD. Disabled turns declare no readback slot, so no empty guidance field is injected into Host input. - The TS/JS lexer preserves statically named computed member accesses (api['old_api']) as executable references while array literals, plain strings and comments remain non-references. - verify fails closed: a missing basis or an empty target set is an input error (exit 2), never a passing verification; catalog entry_command / next_real_step now show the complete, certifying command and the README documents the actual activation and tool-delivery boundary. Validation: 67 focused tests (including two tests that build the real TS-owned Turn envelope from the canonical decision fixture and assert the composed Host input), full tests/capabilities suite, ruff, smoke and comparator pass. Signed-off-by: LoopDesk --- loopx/capabilities/README.md | 1 + loopx/capabilities/anti_entropy/README.md | 42 +- .../anti_entropy/catalog_entry.py | 10 +- loopx/capabilities/anti_entropy/oracles.py | 60 +++ .../capabilities/anti_entropy/turn_context.py | 477 +++++++++++++----- loopx/cli_commands/anti_entropy.py | 19 +- tests/capabilities/test_anti_entropy.py | 109 ++++ .../test_anti_entropy_feedback.py | 254 +++++++++- 8 files changed, 822 insertions(+), 150 deletions(-) diff --git a/loopx/capabilities/README.md b/loopx/capabilities/README.md index d3af15ad73..925b7f1d5c 100644 --- a/loopx/capabilities/README.md +++ b/loopx/capabilities/README.md @@ -87,6 +87,7 @@ availability and maturity in the installed release. | Review a changing public PR queue against exact-head evidence and typed completion rules | [Pull Request Review](pr_review_queue/README.md) | | Detect source-head drift and safely rebuild a local stack of already reviewed branches | [Integration Branch](integration_branch/README.md) | | Gate local repository changes by a typed schedule and retain blocked unmerged work across restarts | [Repository Change Window](repository_change_window/README.md) | +| Verify that declared legacy symbols and paths are genuinely retired by a refactoring change | [Anti-Entropy Governance](anti_entropy/README.md) | ### Research And Decision Continuity diff --git a/loopx/capabilities/anti_entropy/README.md b/loopx/capabilities/anti_entropy/README.md index 4ea1e6e71b..45af4d533d 100644 --- a/loopx/capabilities/anti_entropy/README.md +++ b/loopx/capabilities/anti_entropy/README.md @@ -62,17 +62,31 @@ The capability ships disabled (`default_enabled: false`); enabling it for a task is an explicit choice. When enabled, one closed loop per task is: 1. **Task context** — `anti_entropy_context_registration` is composed into - the shared `loopx.capabilities.turn_context.attach_turn_context` owner: - a turn whose task declares a `RetirementBasis` (or explicitly enables the - capability) receives the targets, the inspect tool, and delete-first - guidance in its host input; disabled turns get zero injection, and - enabled-but-inapplicable turns only read back `enabled/applicable=false`. + the shared `loopx.capabilities.turn_context.attach_turn_context` owner and + reads the Turn's own authoritative input, never a hand-built payload shape: + + - the selected Todo record carried by `turn_envelope.work_context.sources[]` + (its `note` / instruction text, `required_capabilities`, and the full + task body when the compact display text was truncated), plus + `turn_envelope.action.selected_todo.text` / `action.recommended_action`; + - a task opts in by declaring an explicit `retirement_basis` / + `anti_entropy_basis` JSON object in that text, by listing + `anti-entropy-governance` in the Todo's `required_capabilities`, or by a + Goal-level available-capability declaration; + - the post-change workspace is the Goal repository from the registry + (`load_goal_from_registry`), never the process working directory. + + Disabled turns write no readback slot and inject nothing; an + enabled-but-inapplicable turn only reads back `enabled/applicable=false`. Recovery turns (the payload carries the previous inspect outcome) get remediation guidance instead. -2. **In-flight check** — agents call the `anti_entropy_inspect` tool (or - `loopx anti-entropy inspect`). Blocking findings come back as - **actionable feedback with per-rule next actions**, classified as progress - facts, not final delivery failures; the CLI exits 0 whenever the check ran. +2. **In-flight check** — agents call `anti_entropy_inspect` through the Python + entry or `loopx anti-entropy inspect`. The turn context carries the tool + descriptor and CLI route as data; LoopX exposes no capability-owned host + tool registry, so the CLI remains the callable route. Blocking findings come + back as **actionable feedback with per-rule next actions**, classified as + progress facts, not final delivery failures; the CLI exits 0 whenever the + check ran. 3. **Delivery verification** — final acceptance stays owned by the existing change-quality owner (`loopx change-quality prepare/record/verify`, exact diff scope and receipt invalidation). This capability supplies evidence and @@ -105,12 +119,17 @@ In-flight inspection with actionable feedback (progress-fact semantics): loopx anti-entropy inspect --goal-id --diff-file --basis-json --workspace --format json ``` -Delivery-gate verification (non-zero exit on rejection): +Delivery-gate verification (non-zero exit on rejection). `verify` requires an +explicit basis with at least one target, and certifies symbol retirement only +when `--workspace` is bound: ```bash loopx anti-entropy verify --goal-id --diff-file --basis-json --workspace --format json ``` +`verify` without `--basis-json` (or with an empty target set) exits 2: an +undeclared retirement is an input error, not a passing verification. + Example `basis.json`: ```json @@ -131,7 +150,8 @@ no model runs and measures no agent-runtime behavior. ## Boundaries -- The capability is default-off and task-scoped: turns receive the retirement context only when the task explicitly declares a retirement basis or enables the capability; the CLI (verify/inspect) remains the shared verification entry. +- The capability is default-off and task-scoped: turns receive the retirement context only when the selected task explicitly declares a retirement basis or enables the capability in its own canonical input; the CLI (verify/inspect) remains the shared verification entry. +- The selected Todo is the intent source. The hook reads the native work context and the Goal registry; it never infers targets from unrelated business fields and never reads the process working directory as source evidence. - Delete-first requires target old logic to be cleanly decommissioned in the diff without re-introducing wrappers or zombie symbols. - Symbol retirement certification requires an explicitly bound workspace; diff-only evidence can reject but never certifies. - AST-based diff hygiene verifies exact code diffs without mutating repository code or performing speculative git writes. diff --git a/loopx/capabilities/anti_entropy/catalog_entry.py b/loopx/capabilities/anti_entropy/catalog_entry.py index 17c006ca30..4cf80d21c5 100644 --- a/loopx/capabilities/anti_entropy/catalog_entry.py +++ b/loopx/capabilities/anti_entropy/catalog_entry.py @@ -31,7 +31,8 @@ "re-introducing wrappers, zombie symbols, or ghost code." ), "entry_command": ( - "loopx anti-entropy inspect --goal-id --diff-file --workspace --format json" + "loopx anti-entropy inspect --goal-id --diff-file " + "--basis-json --workspace --format json" ), "workflow_skill": { "name": "loopx-anti-entropy", @@ -90,7 +91,9 @@ "docs": [_README], "boundaries": [ "The capability is default-off and task-scoped: a turn receives the retirement context only when the task explicitly declares a retirement basis or enables the capability; disabled turns get zero injection.", - "The turn-context hook projects declared targets, the inspect tool, and delete-first guidance into host input via the shared attach_turn_context composition; it grants no execution authority.", + "The turn-context hook projects declared targets, the inspect descriptor, and delete-first guidance into host input via the shared attach_turn_context composition; it grants no execution authority.", + "Task intent comes from the selected Todo's own instruction/note text or an explicit capability id in its required capabilities; the post-change workspace is the Goal repository, never the process working directory.", + "LoopX has no capability-owned host tool registry: the inspect descriptor is projected as turn-context data, and the CLI (or the Python entry it wraps) remains the callable route.", "The CLI (verify/inspect) remains the shared verification entry for humans and agents; final delivery acceptance is owned by the existing change-quality prepare/record/verify owner.", "Delete-first requires target old logic to be cleanly decommissioned in the diff without re-introducing wrappers or zombie symbols.", "AST-based diff hygiene verifies exact code diffs without mutating repository code or performing speculative git writes.", @@ -100,6 +103,7 @@ "Inspired by and adopting core engineering principles of delete-first code retirement from Aegis.", ], "next_real_step": ( - "Run verification: loopx anti-entropy verify --diff-file --basis-json " + "Run verification: loopx anti-entropy verify --diff-file " + "--basis-json --workspace --format json" ), } diff --git a/loopx/capabilities/anti_entropy/oracles.py b/loopx/capabilities/anti_entropy/oracles.py index b50b47354c..4a644170ce 100644 --- a/loopx/capabilities/anti_entropy/oracles.py +++ b/loopx/capabilities/anti_entropy/oracles.py @@ -306,6 +306,58 @@ def _extract_ts_defs(lines: list[str]) -> set[str]: return defs +_TS_IDENTIFIER_RE = re.compile(r"[A-Za-z_$][A-Za-z0-9_$]*\Z") +_TS_QUOTE_CHARS = ("\"", "'", chr(96)) + + +def _static_computed_member( + text: str, start: int, out: list[str] +) -> tuple[str, int] | None: + """Preserve a statically named computed member access (api[name]). + + Only a member name in a value position is preserved. Array literals, + declarations, and dynamic expressions stay string-literal text and never + become executable references; unparseable forms keep the normal string + handling so they cannot be certified as absent evidence. + """ + index = start + 1 + length = len(text) + while index < length and text[index] in " \t": + index += 1 + if index >= length or text[index] not in _TS_QUOTE_CHARS: + return None + quote = text[index] + cursor = index + 1 + content_start = cursor + while cursor < length: + char = text[cursor] + if char == "\\" or char == "\n": + return None + if char == quote: + break + cursor += 1 + if cursor >= length: + return None + name = text[content_start:cursor] + if not _TS_IDENTIFIER_RE.match(name): + return None + after = cursor + 1 + while after < length and text[after] in " \t": + after += 1 + if after >= length or text[after] != "]": + return None + previous = "" + for position in range(len(out) - 1, -1, -1): + token = out[position] + stripped = token.rstrip() + if stripped: + previous = stripped[-1] + break + if not (previous.isalnum() or previous in "_$)]."): + return None + return name, after + 1 + + def _tokenize_ts_code(text: str) -> tuple[str, bool]: """Tokenize and strip comments and string-literal spans from TS/JS code. @@ -323,6 +375,14 @@ def _tokenize_ts_code(text: str) -> tuple[str, bool]: stack: list[Any] = [] # items: 'template' or {'type': 'expr', 'brace_depth': int} while i < n: + if text[i] == "[" and (not stack or stack[-1] != "template"): + computed = _static_computed_member(text, i, out) + if computed is not None: + name, next_index = computed + out.append(" ") + out.append(name) + i = next_index + continue if not stack: # Top-level code if text[i : i + 2] == "//": diff --git a/loopx/capabilities/anti_entropy/turn_context.py b/loopx/capabilities/anti_entropy/turn_context.py index a5ce30ba08..c612d4dc88 100644 --- a/loopx/capabilities/anti_entropy/turn_context.py +++ b/loopx/capabilities/anti_entropy/turn_context.py @@ -8,204 +8,397 @@ from typing import Any from ...control_plane.capability_hooks import TurnContextHookRegistration -from .contract import RetirementBasis +from .contract import ANTI_ENTROPY_CAPABILITY_ID, RetirementBasis from .feedback import build_anti_entropy_turn_context ANTI_ENTROPY_CONTEXT_MAX_RESULT_BYTES = 64 * 1024 +_BASIS_KEYS = ("retirement_basis", "anti_entropy_basis") +_BASIS_CONTAINER_KEYS = ("metadata", "params", "task_spec", "task_parameters") +_TEXT_CARRIER_KEYS = ( + "note", + "text", + "description", + "instruction", + "evidence", + "rationale", +) +_ENABLE_CAPABILITY_IDS = frozenset( + {"anti-entropy-governance", "anti_entropy_governance", "anti_entropy"} +) +_UNSET = object() + + +def _embedded_json_objects(text: str): + """Yield JSON objects embedded in free text, outermost first. + + Task instructions and notes are plain strings; an explicit retirement + declaration may sit inside surrounding prose. A balanced scan is used + instead of assuming the whole string is JSON, and a failed outer parse + keeps scanning for an inner declaration. + """ + index = 0 + length = len(text) + while index < length: + if text[index] != "{": + index += 1 + continue + depth = 0 + in_string = False + escaped = False + cursor = index + parsed: Any = None + while cursor < length: + char = text[cursor] + if in_string: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + in_string = False + elif char == '"': + in_string = True + elif char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + try: + parsed = json.loads(text[index : cursor + 1]) + except Exception: + parsed = None + break + cursor += 1 + if isinstance(parsed, Mapping): + yield parsed + index = cursor + 1 + else: + index += 1 def _extract_basis_candidate(candidate: Any) -> RetirementBasis | None: - """Recursively search for a valid RetirementBasis in a candidate object or dictionary.""" + """Recursively search a task carrier for an explicit retirement basis.""" if candidate is None: return None if isinstance(candidate, RetirementBasis): - return candidate if (candidate.target_symbols or candidate.target_paths) else None + return ( + candidate + if (candidate.target_symbols or candidate.target_paths) + else None + ) if isinstance(candidate, str): - candidate_clean = candidate.strip() - if (candidate_clean.startswith("{") and candidate_clean.endswith("}")) or ( - candidate_clean.startswith("[") and candidate_clean.endswith("]") - ): - try: - parsed = json.loads(candidate_clean) - return _extract_basis_candidate(parsed) - except Exception: - pass + for embedded in _embedded_json_objects(candidate): + parsed = _extract_basis_candidate(embedded) + if parsed is not None: + return parsed return None - if isinstance(candidate, Mapping): - # 1. Direct explicit retirement_basis or anti_entropy_basis keys - for key in ("retirement_basis", "anti_entropy_basis"): - if key in candidate: - b = _extract_basis_candidate(candidate[key]) - if b is not None: - return b - - # 2. anti_entropy block - if "anti_entropy" in candidate and isinstance(candidate["anti_entropy"], Mapping): - b = _extract_basis_candidate(candidate["anti_entropy"]) - if b is not None: - return b - - # 3. Containers commonly holding task params or metadata - for container_key in ("metadata", "params", "task_spec", "task_parameters"): - if container_key in candidate: - b = _extract_basis_candidate(candidate[container_key]) - if b is not None: - return b - - # 4. Direct basis shape (target_symbols or target_paths) - if "target_symbols" in candidate or "target_paths" in candidate: - try: - b = RetirementBasis.from_dict(candidate) - if b.target_symbols or b.target_paths: - return b - except Exception: - pass - - # 5. String fields that might store JSON payload - for text_key in ("note", "description", "instruction"): - text_val = candidate.get(text_key) - if isinstance(text_val, str): - b = _extract_basis_candidate(text_val) - if b is not None: - return b - + if not isinstance(candidate, Mapping): + return None + for key in _BASIS_KEYS: + if key in candidate: + parsed = _extract_basis_candidate(candidate[key]) + if parsed is not None: + return parsed + anti_entropy = candidate.get("anti_entropy") + if isinstance(anti_entropy, Mapping): + parsed = _extract_basis_candidate(anti_entropy) + if parsed is not None: + return parsed + for container_key in _BASIS_CONTAINER_KEYS: + if container_key in candidate: + parsed = _extract_basis_candidate(candidate[container_key]) + if parsed is not None: + return parsed + # A dedicated basis object (not arbitrary task metadata) carries the + # declared targets directly. + if "target_symbols" in candidate or "target_paths" in candidate: + try: + parsed = RetirementBasis.from_dict(candidate) + except Exception: + parsed = None + if parsed is not None and (parsed.target_symbols or parsed.target_paths): + return parsed + for text_key in _TEXT_CARRIER_KEYS: + text_value = candidate.get(text_key) + if isinstance(text_value, str): + parsed = _extract_basis_candidate(text_value) + if parsed is not None: + return parsed return None +def _work_context_sources(container: Any) -> list[Mapping[str, Any]]: + """Return the bounded work-context sources from a payload or plan owner.""" + if not isinstance(container, Mapping): + return [] + contexts: list[Mapping[str, Any]] = [] + direct = container.get("work_context") + if isinstance(direct, Mapping): + contexts.append(direct) + channel = container.get("agent_channel") + if isinstance(channel, Mapping): + nested = channel.get("work_context") + if isinstance(nested, Mapping): + contexts.append(nested) + sources: list[Mapping[str, Any]] = [] + for context in contexts: + for source in context.get("sources") or (): + if isinstance(source, Mapping): + sources.append(source) + return sources + + +def _selected_todo_records( + payload: Mapping[str, Any], + decision: Mapping[str, Any], +) -> list[Mapping[str, Any]]: + """Collect every native selected-Todo projection, richest form first.""" + records: list[Mapping[str, Any]] = [] + + def add(value: Any) -> None: + if isinstance(value, Mapping): + records.append(value) + + envelope = payload.get("turn_envelope") + if isinstance(envelope, Mapping): + action = envelope.get("action") + if isinstance(action, Mapping): + add(action.get("selected_todo")) + add(envelope.get("selected_todo")) + for source in _work_context_sources(envelope): + content = source.get("content") + if isinstance(content, Mapping): + add(content.get("todo")) + route = payload.get("route") + if isinstance(route, Mapping): + add(route.get("selected_todo")) + add(payload.get("selected_todo")) + add(payload.get("task")) + if isinstance(decision, Mapping): + add(decision.get("selected_todo")) + action = decision.get("action") + if isinstance(action, Mapping): + add(action.get("selected_todo")) + route = decision.get("route") + if isinstance(route, Mapping): + add(route.get("selected_todo")) + for container in (decision, decision.get("interaction_contract")): + for source in _work_context_sources(container): + content = source.get("content") + if isinstance(content, Mapping): + add(content.get("todo")) + return records + + def _resolve_retirement_basis( payload: Mapping[str, Any], decision: Mapping[str, Any], ) -> RetirementBasis | None: - """Extract a declared retirement basis from turn payload, decision, or selected todo.""" - candidates = [ - # Top-level direct fields + """Extract a declared retirement basis from the authoritative task input. + + The native Turn carries the selected Todo as a compact projection plus the + bounded work context. The task's own instruction/note text and the + recommended action are the intent sources; a hand-built top-level payload + key is accepted for direct callers but is never the only source. + """ + candidates: list[Any] = [ payload.get("retirement_basis"), payload.get("anti_entropy_basis"), payload.get("anti_entropy"), decision.get("retirement_basis"), decision.get("anti_entropy_basis"), decision.get("anti_entropy"), - - # Native route and turn_envelope selected todos payload.get("route", {}).get("selected_todo") - if isinstance(payload.get("route"), Mapping) else None, + if isinstance(payload.get("route"), Mapping) + else None, payload.get("turn_envelope", {}).get("action", {}).get("selected_todo") if isinstance(payload.get("turn_envelope"), Mapping) - and isinstance(payload.get("turn_envelope", {}).get("action"), Mapping) else None, - payload.get("turn_envelope", {}).get("selected_todo") - if isinstance(payload.get("turn_envelope"), Mapping) else None, - - # Top-level and task fields + and isinstance(payload.get("turn_envelope", {}).get("action"), Mapping) + else None, payload.get("selected_todo"), payload.get("task"), - - # Decision-side selected todos decision.get("selected_todo"), decision.get("action", {}).get("selected_todo") - if isinstance(decision.get("action"), Mapping) else None, + if isinstance(decision.get("action"), Mapping) + else None, decision.get("route", {}).get("selected_todo") - if isinstance(decision.get("route"), Mapping) else None, + if isinstance(decision.get("route"), Mapping) + else None, decision.get("task"), ] - - for cand in candidates: - basis = _extract_basis_candidate(cand) + for candidate in candidates: + basis = _extract_basis_candidate(candidate) + if basis is not None: + return basis + envelope = payload.get("turn_envelope") + if isinstance(envelope, Mapping): + action = envelope.get("action") + if isinstance(action, Mapping): + for text_key in ("recommended_action", "text"): + basis = _extract_basis_candidate(action.get(text_key)) + if basis is not None: + return basis + for record in _selected_todo_records(payload, decision): + basis = _extract_basis_candidate(record) if basis is not None: return basis - return None +def _load_goal(registry_path: Path | str | None, goal_id: str | None) -> Mapping[str, Any] | None: + if registry_path is None or not goal_id: + return None + try: + from ...agent_registry import load_goal_from_registry + + return load_goal_from_registry(Path(registry_path), goal_id) + except Exception: # noqa: BLE001 - optional read-only enablement hint + return None + + +def _declared_capability_ids(goal: Mapping[str, Any]) -> set[str]: + declared: set[str] = set() + for container in ( + goal, + goal.get("control_plane"), + goal.get("coordination"), + ): + if not isinstance(container, Mapping): + continue + for key in ("available_capabilities", "required_capabilities"): + value = container.get(key) + if isinstance(value, (list, tuple, set)): + declared.update(str(item).strip() for item in value if isinstance(item, str)) + return declared + + +def _record_required_capabilities(record: Mapping[str, Any]) -> set[str]: + value = record.get("required_capabilities") + if isinstance(value, (list, tuple, set)): + return {str(item).strip() for item in value if isinstance(item, str)} + return set() + + def _is_anti_entropy_enabled( payload: Mapping[str, Any], decision: Mapping[str, Any], basis: RetirementBasis | None, + *, + registry_path: Path | str | None = None, + goal_id: str | None = None, ) -> bool: - """Determine whether anti-entropy capability is enabled for this turn.""" - # If an explicit retirement basis was declared, capability is implicitly enabled for that task + """Determine whether anti-entropy is enabled for this turn. + + A declared retirement basis is the task-scoped opt-in. The explicit + capability id in the selected Todo's required capabilities and a Goal-level + available-capability declaration are the typed enablement paths. + """ if basis is not None and (basis.target_symbols or basis.target_paths): return True - # Check direct payload and decision enablement if payload.get("anti_entropy_enabled") is True or decision.get("anti_entropy_enabled") is True: return True - - ae_payload = payload.get("anti_entropy") - if isinstance(ae_payload, Mapping) and ae_payload.get("enabled") is True: - return True - - ae_decision = decision.get("anti_entropy") - if isinstance(ae_decision, Mapping) and ae_decision.get("enabled") is True: - return True - - # Check route and turn_envelope boundary capabilities + for owner in (payload, decision): + value = owner.get("anti_entropy") + if isinstance(value, Mapping) and value.get("enabled") is True: + return True route = payload.get("route") if isinstance(route, Mapping) and route.get("anti_entropy_enabled") is True: return True - envelope = payload.get("turn_envelope") if isinstance(envelope, Mapping): boundary = envelope.get("boundary") if isinstance(boundary, Mapping): - caps = boundary.get("capabilities") - if isinstance(caps, Mapping): - ae_cap = caps.get("anti_entropy_governance") or caps.get("anti_entropy") - if isinstance(ae_cap, Mapping) and ae_cap.get("enabled") is True: + capabilities = boundary.get("capabilities") + if isinstance(capabilities, Mapping): + capability = capabilities.get("anti_entropy_governance") or capabilities.get( + "anti_entropy" + ) + if isinstance(capability, Mapping) and capability.get("enabled") is True: return True - if ae_cap is True: + if capability is True: return True - # Check selected todos for anti_entropy_enabled flag - todos_to_check = [ - payload.get("selected_todo"), - payload.get("route", {}).get("selected_todo") if isinstance(payload.get("route"), Mapping) else None, - payload.get("turn_envelope", {}).get("action", {}).get("selected_todo") - if isinstance(payload.get("turn_envelope"), Mapping) - and isinstance(payload.get("turn_envelope", {}).get("action"), Mapping) else None, - decision.get("selected_todo"), - decision.get("action", {}).get("selected_todo") if isinstance(decision.get("action"), Mapping) else None, - ] - for td in todos_to_check: - if isinstance(td, Mapping): - if td.get("anti_entropy_enabled") is True: - return True - meta = td.get("metadata") - if isinstance(meta, Mapping) and meta.get("anti_entropy_enabled") is True: - return True - params = td.get("params") - if isinstance(params, Mapping) and params.get("anti_entropy_enabled") is True: + for record in _selected_todo_records(payload, decision): + if record.get("anti_entropy_enabled") is True: + return True + for key in ("metadata", "params"): + value = record.get(key) + if isinstance(value, Mapping) and value.get("anti_entropy_enabled") is True: return True + if _record_required_capabilities(record) & _ENABLE_CAPABILITY_IDS: + return True + goal = _load_goal(registry_path, goal_id) + if goal is not None and _declared_capability_ids(goal) & _ENABLE_CAPABILITY_IDS: + return True return False +def _resolve_workspace( + payload: Mapping[str, Any], + *, + registry_path: Path | str | None, + goal_id: str | None, +) -> Path | None: + """Resolve the post-change workspace from the task or the Goal registry. + + The process working directory is never used: an unrelated checkout must not + change the verdict for the same patch. + """ + for key in ("workspace", "repo_path"): + value = payload.get(key) + if isinstance(value, str) and value.strip(): + path = Path(value).expanduser() + if path.is_dir(): + return path.resolve() + goal = _load_goal(registry_path, goal_id) + repo = str((goal or {}).get("repo") or "").strip() + if repo: + path = Path(repo).expanduser() + if path.is_dir(): + return path.resolve() + return None + + def anti_entropy_context_result( payload: Mapping[str, Any], *, goal_id: str, decision: Mapping[str, Any], workspace: Path | None = None, + resolved_basis: Any = _UNSET, + enabled: Any = _UNSET, ) -> dict[str, Any]: """Produce the bounded turn-context payload covering the four lifecycle states. - 1. Disabled: capability is inactive. - 2. Enabled but not applicable: capability is active but task has no retirement targets. - 3. New task: declared targets projected into context + inspect tool + delete-first guidance. - 4. Failure recovery: previous diff had blocking findings; progress facts guide remediation. + 1. Disabled: no readback slot and no injection. + 2. Enabled but not applicable: capability is active but the task has no + retirement targets. + 3. New task: declared targets, the inspect descriptor, and delete-first + guidance are projected into host input. + 4. Failure recovery: the previous inspect outcome in the payload produces + remediation guidance. """ - basis = _resolve_retirement_basis(payload, decision) - enabled = _is_anti_entropy_enabled(payload, decision, basis) + basis = ( + _resolve_retirement_basis(payload, decision) + if resolved_basis is _UNSET + else resolved_basis + ) + enabled_value = bool( + _is_anti_entropy_enabled(payload, decision, basis) + if enabled is _UNSET + else enabled + ) - # State 1: Disabled - if not enabled: + if not enabled_value: return { - "readback": {"anti_entropy_guidance": None}, + "readback": {}, "context_fields": {}, "instructions": [], "result_fields": {}, } - # State 2: Enabled but not applicable if basis is None or (not basis.target_symbols and not basis.target_paths): return { "readback": { @@ -219,7 +412,6 @@ def anti_entropy_context_result( "result_fields": {}, } - # Determine whether this is a failure recovery turn recovery_evidence = ( payload.get("anti_entropy_progress_fact") or payload.get("anti_entropy_last_result") @@ -227,24 +419,26 @@ def anti_entropy_context_result( ) is_recovery = bool(recovery_evidence) - # State 3 / 4: Active task or failure recovery - resolved_workspace = workspace or Path.cwd() context = build_anti_entropy_turn_context( basis=basis, goal_id=goal_id, - workspace=resolved_workspace, + workspace=workspace, ) if is_recovery: instructions = [ - "Anti-entropy recovery: previous diff check identified unretired symbols or diff hygiene issues. Address blocking findings before attempting delivery.", - "Run 'anti_entropy_inspect' or 'loopx anti-entropy inspect' to verify diff cleanliness; rejections are progress facts guiding the next correction.", + "Anti-entropy recovery: the previous diff check identified unretired symbols " + "or diff hygiene issues. Address blocking findings before attempting delivery.", + "Run 'anti_entropy_inspect' or 'loopx anti-entropy inspect' to verify diff " + "cleanliness; rejections are progress facts guiding the next correction.", ] status = "recovering" else: instructions = [ - "Anti-entropy code governance is active for this task: delete-first symbol and path retirement is required.", - "Call 'anti_entropy_inspect' or 'loopx anti-entropy inspect' to check diff cleanliness; blocking findings are progress facts to fix before delivery.", + "Anti-entropy code governance is active for this task: delete-first symbol " + "and path retirement is required.", + "Call 'anti_entropy_inspect' or 'loopx anti-entropy inspect' to check diff " + "cleanliness; blocking findings are progress facts to fix before delivery.", ] status = "active" @@ -255,6 +449,7 @@ def anti_entropy_context_result( "target_symbols": list(basis.target_symbols), "target_paths": list(basis.target_paths), "status": status, + "workspace_available": workspace is not None, } } @@ -265,9 +460,10 @@ def anti_entropy_context_result( "result_fields": {}, } - encoded = json.dumps(full_result, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + encoded = json.dumps(full_result, ensure_ascii=False, separators=(",", ":")).encode( + "utf-8" + ) if len(encoded) > ANTI_ENTROPY_CONTEXT_MAX_RESULT_BYTES: - # Bounded compaction: trim rationale or inspect tool descriptors if over budget compact_context = dict(context) compact_context.pop("inspect_tool", None) return { @@ -289,11 +485,22 @@ def anti_entropy_context_registration( decision: Mapping[str, Any], turn_instance_id: str, ) -> TurnContextHookRegistration: - """Create a TurnContextHookRegistration connecting anti-entropy into host input.""" - workspace = ( - Path(payload["workspace"]).resolve() - if "workspace" in payload - else (Path(payload["repo_path"]).resolve() if "repo_path" in payload else None) + """Create a TurnContextHookRegistration connecting anti-entropy into host input. + + The task intent and workspace are resolved once from the native Turn input + and the Goal registry. Disabled turns declare no readback slot, so no empty + guidance field is written into the host payload. + """ + workspace = _resolve_workspace( + payload, registry_path=registry_path, goal_id=goal_id + ) + basis = _resolve_retirement_basis(payload, decision) + enabled = _is_anti_entropy_enabled( + payload, + decision, + basis, + registry_path=registry_path, + goal_id=goal_id, ) def produce() -> Mapping[str, Any]: @@ -302,12 +509,14 @@ def produce() -> Mapping[str, Any]: goal_id=goal_id, decision=decision, workspace=workspace, + resolved_basis=basis, + enabled=enabled, ) return TurnContextHookRegistration( hook_id="anti-entropy.turn-context", - capability_id="anti-entropy-governance", - readback_slots=("anti_entropy_guidance",), + capability_id=ANTI_ENTROPY_CAPABILITY_ID, + readback_slots=("anti_entropy_guidance",) if enabled else (), producer=produce, max_result_bytes=ANTI_ENTROPY_CONTEXT_MAX_RESULT_BYTES, ) diff --git a/loopx/cli_commands/anti_entropy.py b/loopx/cli_commands/anti_entropy.py index 508c785a2a..a78587b0e7 100644 --- a/loopx/cli_commands/anti_entropy.py +++ b/loopx/cli_commands/anti_entropy.py @@ -8,7 +8,10 @@ from typing import Any from ..capabilities.anti_entropy.cli import run_anti_entropy_verify -from ..capabilities.anti_entropy.contract import ANTI_ENTROPY_CAPABILITY_ID +from ..capabilities.anti_entropy.contract import ( + ANTI_ENTROPY_CAPABILITY_ID, + RetirementBasis, +) PrintPayload = Callable[ [dict[str, Any], str, Callable[[dict[str, Any]], str]], str | None @@ -176,6 +179,20 @@ def handle_anti_entropy_command( workspace = _resolve_workspace(args) if command == "verify": + # A delivery-gate verification without a declared retirement basis + # would pass without checking anything; diff hygiene alone belongs + # to inspect and can never certify a retirement. + if basis_data is None: + raise ValueError( + "verify requires an explicit retirement basis " + "(--basis-json )" + ) + declared_basis = RetirementBasis.from_dict(basis_data) + if not declared_basis.target_symbols and not declared_basis.target_paths: + raise ValueError( + "verify requires at least one declared retirement target " + "(target_symbols or target_paths)" + ) payload = run_anti_entropy_verify( diff_text=diff_text, basis_data=basis_data, diff --git a/tests/capabilities/test_anti_entropy.py b/tests/capabilities/test_anti_entropy.py index b387690a3e..bcba474876 100644 --- a/tests/capabilities/test_anti_entropy.py +++ b/tests/capabilities/test_anti_entropy.py @@ -405,6 +405,45 @@ def old_api(): ) assert proc_ws.returncode == 2 + # 5. verify without any basis -> input error, never a silent pass + proc_no_basis = subprocess.run( + [ + sys.executable, + "-m", + "loopx.cli", + "anti-entropy", + "verify", + "--diff-file", + "-", + "--format", + "json", + ], + input=b"", + capture_output=True, + ) + assert proc_no_basis.returncode == 2 + assert b"retirement basis" in proc_no_basis.stderr + + # 6. verify with a basis that declares no retirement target -> input error + proc_no_target = subprocess.run( + [ + sys.executable, + "-m", + "loopx.cli", + "anti-entropy", + "verify", + "--diff-file", + "-", + "--basis-json", + "{}", + "--format", + "json", + ], + input=b"", + capture_output=True, + ) + assert proc_no_target.returncode == 2 + def test_docstring_example_deletion_does_not_retire_real_definition(tmp_path: Path) -> None: """Verify deleting a code example in a docstring does not falsely pass a retained real definition.""" @@ -1133,3 +1172,73 @@ def test_typescript_unclosed_syntax_reports_insufficient_evidence(tmp_path: Path assert receipt.verdict == "rejected" assert any(f.rule_id.startswith("AE004") for f in receipt.findings) assert "old_api" not in receipt.retired_symbols + +def test_typescript_static_computed_member_reference_blocks_retirement( + tmp_path: Path, +) -> None: + """A statically named computed member access is an active reference.""" + diff = """ +--- a/service.mjs ++++ b/service.mjs +@@ -1,2 +1,2 @@ +-export function old_api() { return 1; } ++export function new_api() { return 2; } +""" + basis = RetirementBasis(target_symbols=("old_api",)) + workspace = _make_workspace( + tmp_path / "ws_unmigrated", + { + "service.mjs": "export function new_api() { return 2; }\n", + "client.mjs": ( + "import * as api from './service.mjs';\n" + "const first = api['old_api']();\n" + 'const second = api?.["old_api"]();\n' + ), + }, + ) + + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) + assert receipt.verdict == "rejected" + assert any( + finding.rule_id == "AE002-ZOMBIE_SYMBOL_REINTRODUCED" + and "client.mjs" in finding.message + for finding in receipt.findings + ) + assert receipt.retired_symbols == () + + +def test_typescript_computed_member_migrated_and_harmless_strings_pass( + tmp_path: Path, +) -> None: + """Migrated computed calls and plain string/array text never block retirement.""" + diff = """ +--- a/service.mjs ++++ b/service.mjs +@@ -1,2 +1,2 @@ +-export function old_api() { return 1; } ++export function new_api() { return 2; } +--- a/client.mjs ++++ b/client.mjs +@@ -2,1 +2,3 @@ +-export function run() { return api.old_api(); } ++export function run() { return api['new_api'](); } ++export const allowed = ['old_api']; ++export const message = "old_api has been removed"; +""" + basis = RetirementBasis(target_symbols=("old_api",)) + workspace = _make_workspace( + tmp_path / "ws_migrated", + { + "service.mjs": "export function new_api() { return 2; }\n", + "client.mjs": ( + "import * as api from './service.mjs';\n" + "export function run() { return api['new_api'](); }\n" + "export const allowed = ['old_api'];\n" + 'export const message = "old_api has been removed";\n' + ), + }, + ) + + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) + assert receipt.verdict == "passed", f"findings: {receipt.findings}" + assert receipt.retired_symbols == ("old_api",) diff --git a/tests/capabilities/test_anti_entropy_feedback.py b/tests/capabilities/test_anti_entropy_feedback.py index 9375c1717c..6d50485a2f 100644 --- a/tests/capabilities/test_anti_entropy_feedback.py +++ b/tests/capabilities/test_anti_entropy_feedback.py @@ -386,7 +386,8 @@ def test_turn_context_hook_lifecycle_all_states(tmp_path: Path) -> None: res_disabled = anti_entropy_context_result( payload_disabled, goal_id="g-1", decision=decision_disabled ) - assert res_disabled["readback"]["anti_entropy_guidance"] is None + # Disabled turns declare no readback slot: the host payload is untouched. + assert res_disabled["readback"] == {} assert res_disabled["context_fields"] == {} assert res_disabled["instructions"] == [] @@ -646,3 +647,254 @@ def test_turn_context_extracts_native_route_and_envelope_selected_todos() -> Non res_note = anti_entropy_context_result(payload_note, goal_id="g1", decision={}) assert res_note["readback"]["anti_entropy_guidance"]["status"] == "active" assert res_note["readback"]["anti_entropy_guidance"]["target_symbols"] == ["old_note_func"] + +def test_turn_context_reads_native_work_context_todo_note() -> None: + """The hook reads the selected Todo's canonical note from the native work context.""" + basis = { + "retirement_basis": { + "target_symbols": ["old_api"], + "rationale": "replace old_api with new_api", + } + } + payload = { + "goal_id": "g1", + "route": {"selected_todo": {"todo_id": "t1"}}, + "turn_envelope": { + "action": { + "recommended_action": "Replace old_api with new_api", + "selected_todo": { + "todo_id": "t1", + "text_ref": "action.recommended_action", + }, + }, + "work_context": { + "complete": True, + "sources": [ + { + "source": "selected_todo", + "content": { + "todo": { + "todo_id": "t1", + "note": json.dumps(basis), + "required_capabilities": ["anti-entropy-governance"], + } + }, + } + ], + }, + }, + } + res = anti_entropy_context_result(payload, goal_id="g1", decision={}) + guidance = res["readback"]["anti_entropy_guidance"] + assert guidance["enabled"] is True + assert guidance["applicable"] is True + assert guidance["target_symbols"] == ["old_api"] + assert "anti_entropy_context" in res["context_fields"] + assert res["context_fields"]["anti_entropy_context"]["inspect_tool"]["name"] == ( + "anti_entropy_inspect" + ) + assert any("delete-first" in item.lower() for item in res["instructions"]) + + +def test_turn_context_reads_basis_from_action_recommended_action() -> None: + """A retirement declaration inside the task body is read from the native action.""" + instruction = ( + "Replace the legacy API and remove the old export.\n" + '{"retirement_basis": {"target_symbols": ["old_api"], "target_paths": []}}' + ) + payload = { + "turn_envelope": { + "action": { + "recommended_action": instruction, + "selected_todo": { + "todo_id": "t1", + "text_ref": "action.recommended_action", + }, + } + } + } + res = anti_entropy_context_result(payload, goal_id="g1", decision={}) + guidance = res["readback"]["anti_entropy_guidance"] + assert guidance["applicable"] is True + assert guidance["target_symbols"] == ["old_api"] + + +def test_turn_context_ignores_unrelated_json_in_task_text() -> None: + """Unrelated JSON in a task body does not opt a turn into retirement governance.""" + payload = { + "turn_envelope": { + "action": { + "recommended_action": 'Set {"timeout": 30} for the nightly run.', + "selected_todo": {"todo_id": "t1", "text_ref": "action.recommended_action"}, + } + } + } + res = anti_entropy_context_result(payload, goal_id="g1", decision={}) + assert res["readback"] == {} + assert res["context_fields"] == {} + + +def test_disabled_turn_writes_no_readback_slot(tmp_path: Path) -> None: + """A disabled turn declares no slot and leaves the host payload untouched.""" + from loopx.capabilities.anti_entropy.turn_context import ( + anti_entropy_context_registration, + ) + from loopx.control_plane.capability_hooks import dispatch_turn_context_hooks + + payload = {"goal_id": "g1", "agent_id": "a1"} + registration = anti_entropy_context_registration( + payload, + registry_path=tmp_path, + goal_id="g1", + agent_id="a1", + decision={}, + turn_instance_id="t1", + ) + assert registration.readback_slots == () + dispatched = dict(payload) + dispatch_turn_context_hooks(dispatched, (registration,)) + assert "anti_entropy_guidance" not in dispatched + assert "turn_context_contributions" not in dispatched + + +def test_turn_context_resolves_workspace_from_goal_registry(tmp_path: Path) -> None: + """The post-change workspace is the Goal repository, never the process CWD.""" + from loopx.capabilities.anti_entropy.turn_context import ( + anti_entropy_context_registration, + ) + from loopx.control_plane.capability_hooks import ( + dispatch_turn_context_hooks, + project_turn_context, + ) + + repo = tmp_path / "repo" + repo.mkdir() + registry = tmp_path / "registry.json" + registry.write_text( + json.dumps({"goals": [{"id": "g1", "repo": str(repo)}]}), encoding="utf-8" + ) + payload = { + "goal_id": "g1", + "agent_id": "a1", + "retirement_basis": {"target_symbols": ["old_api"]}, + } + registration = anti_entropy_context_registration( + payload, + registry_path=registry, + goal_id="g1", + agent_id="a1", + decision={}, + turn_instance_id="t1", + ) + dispatched = dict(payload) + dispatch_turn_context_hooks(dispatched, (registration,)) + guidance = dispatched["anti_entropy_guidance"] + assert guidance["workspace_available"] is True + context = project_turn_context(dispatched)["anti_entropy_context"] + assert context["workspace"] == str(repo.resolve()) + + +def _native_turn_decision_fixture() -> dict: + """Load the canonical Turn decision fixture used by the envelope tests.""" + import importlib.util + + fixture_path = Path(__file__).resolve().parents[1] / "test_turn_envelope.py" + spec = importlib.util.spec_from_file_location( + "_anti_entropy_turn_envelope_fixture", fixture_path + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module._full_decision() + + +def _dispatch_native_turn_plan(source: dict, registry: Path) -> dict: + """Build the real TS-owned Turn envelope, then dispatch the shared composer.""" + from loopx.capabilities.anti_entropy.turn_context import ( + anti_entropy_context_registration, + ) + from loopx.control_plane.capability_hooks import dispatch_turn_context_hooks + from loopx.control_plane.quota.turn_envelope import build_turn_envelope + + envelope = build_turn_envelope(source) + plan = { + "turn_envelope": envelope, + "route": {"selected_todo": envelope["action"]["selected_todo"]}, + } + registration = anti_entropy_context_registration( + plan, + registry_path=registry, + goal_id="fixture-goal", + agent_id="codex-fixture", + decision=source, + turn_instance_id="t1", + ) + dispatch_turn_context_hooks(plan, (registration,)) + return plan + + +def test_native_turn_envelope_work_context_note_reaches_host_input( + tmp_path: Path, +) -> None: + """The real Turn envelope carries the Todo note into host input.""" + from loopx.control_plane.capability_hooks import project_turn_context + + source = _native_turn_decision_fixture() + source["interaction_contract"]["agent_channel"]["work_context"] = { + "complete": True, + "selected_todo_ref": "selected_todo", + "sources": [ + { + "source": "selected_todo", + "content": { + "todo": { + "todo_id": "todo_fixture0001", + "note": json.dumps( + {"retirement_basis": {"target_symbols": ["old_api"]}} + ), + "required_capabilities": ["anti-entropy-governance"], + } + }, + } + ], + "instruction": "Read current sources before work.", + } + + plan = _dispatch_native_turn_plan(source, tmp_path) + + assert "turn_context_hook_failures" not in plan + guidance = plan["anti_entropy_guidance"] + assert guidance["enabled"] is True + assert guidance["applicable"] is True + assert guidance["target_symbols"] == ["old_api"] + projected = project_turn_context(plan) + assert "anti_entropy_context" in projected["capability_context_fields"] + assert any( + "delete-first" in item.lower() + for item in projected["capability_instructions"] + ) + + +def test_native_turn_envelope_task_text_reaches_host_input(tmp_path: Path) -> None: + """The task body carried by the native action is an intent source too.""" + from loopx.control_plane.capability_hooks import project_turn_context + + source = _native_turn_decision_fixture() + source["selected_todo"]["text"] = ( + "Replace old_api with new_api. " + + json.dumps({"retirement_basis": {"target_symbols": ["old_api"]}}) + ) + source["interaction_contract"]["agent_channel"]["work_context"] = { + "complete": True, + "selected_todo_ref": "selected_todo", + "instruction": "Read current sources before work.", + } + + plan = _dispatch_native_turn_plan(source, tmp_path) + + # The envelope elides a redundant work context and keeps the body in action. + assert "work_context" not in plan["turn_envelope"] + guidance = plan["anti_entropy_guidance"] + assert guidance["applicable"] is True + assert guidance["target_symbols"] == ["old_api"] + projected = project_turn_context(plan) + assert "anti_entropy_context" in projected["capability_context_fields"] From 11fd4326558e1e763eaf29b960fe05d30e626065 Mon Sep 17 00:00:00 2001 From: LoopDesk Date: Fri, 30 Oct 2026 18:00:22 +0800 Subject: [PATCH 07/21] test(anti-entropy): prove real Turn host stdin carries governance guidance Add an end-to-end regression that mirrors the review method: a canonical File-authority Goal, a Todo authored through the real CLI (task text and note carry an explicit retirement basis), then a real turn run-once --execute whose codex Host is a synthetic binary that records argv/stdin and exits 9. The captured Host stdin contains the delete-first guidance, the anti_entropy_inspect route, and the Turn request with anti_entropy_context and its inspect_tool descriptor, while effects.host_invoked=true proves the Host was actually invoked. Document the routing caveat found by this run: a Todo's required_capabilities entry only opts in when the Goal already declares the capability available; otherwise routing stops the Turn before the hook, so the explicit task declaration is the primary opt-in. Signed-off-by: LoopDesk --- loopx/capabilities/anti_entropy/README.md | 8 +- .../test_anti_entropy_native_host.py | 149 ++++++++++++++++++ 2 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 tests/capabilities/test_anti_entropy_native_host.py diff --git a/loopx/capabilities/anti_entropy/README.md b/loopx/capabilities/anti_entropy/README.md index 45af4d533d..fd5b2b32af 100644 --- a/loopx/capabilities/anti_entropy/README.md +++ b/loopx/capabilities/anti_entropy/README.md @@ -70,9 +70,11 @@ task is an explicit choice. When enabled, one closed loop per task is: task body when the compact display text was truncated), plus `turn_envelope.action.selected_todo.text` / `action.recommended_action`; - a task opts in by declaring an explicit `retirement_basis` / - `anti_entropy_basis` JSON object in that text, by listing - `anti-entropy-governance` in the Todo's `required_capabilities`, or by a - Goal-level available-capability declaration; + `anti_entropy_basis` JSON object in that text. A Todo may also list + `anti-entropy-governance` in `required_capabilities`, but only when the + Goal already declares that capability available: otherwise routing treats + the Todo as requiring an unavailable capability and the Turn stops before + the hook, so the explicit task declaration is the primary path; - the post-change workspace is the Goal repository from the registry (`load_goal_from_registry`), never the process working directory. diff --git a/tests/capabilities/test_anti_entropy_native_host.py b/tests/capabilities/test_anti_entropy_native_host.py new file mode 100644 index 0000000000..3093d9ff01 --- /dev/null +++ b/tests/capabilities/test_anti_entropy_native_host.py @@ -0,0 +1,149 @@ +"""Real Turn -> Host stdin regression for anti-entropy task intent. + +This drives the same path the reviewer used: a canonical File-authority Goal, +a Todo authored through the real CLI, and a real `turn run-once --execute` +whose Host is a synthetic `codex` binary that records argv/stdin and exits 9. +It proves the guidance and inspect descriptor reach Host input, not merely the +in-process composer. +""" + +from __future__ import annotations + +import importlib.util +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _load_canonical_fixture(): + fixture_path = REPO_ROOT / "tests/control_plane/canonical_authority_fixture.py" + spec = importlib.util.spec_from_file_location( + "_anti_entropy_native_host_canonical_fixture", fixture_path + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _run_cli(registry: Path, runtime: Path, *args: str, timeout: int = 240): + return subprocess.run( + [ + sys.executable, + "-m", + "loopx.cli", + "--registry", + str(registry), + "--runtime-root", + str(runtime), + *args, + ], + cwd=REPO_ROOT, + capture_output=True, + text=True, + timeout=timeout, + ) + + +@pytest.mark.skipif( + sys.platform == "win32", + reason="the synthetic Host fixture is a POSIX shell script", +) +def test_real_turn_host_stdin_carries_anti_entropy_guidance(tmp_path: Path) -> None: + """A declared retirement task reaches the real Host input of turn run-once.""" + if shutil.which("node") is None: + pytest.skip("canonical File authority fixture requires node") + + fixture = _load_canonical_fixture() + goal_id = "anti-entropy-native-host" + registry, runtime, _state = fixture.promoted_create_fixture( + tmp_path, goal_id=goal_id, registered_agents=["codex-fixture"] + ) + # The fixture Goal predates contract fields the status gate needs. + registry_payload = json.loads(registry.read_text(encoding="utf-8")) + registry_payload["goals"][0].update( + { + "status": "active", + "domain": "software-development", + "adapter": {"kind": "generic_project_goal_v0", "status": "connected"}, + } + ) + registry.write_text(json.dumps(registry_payload), encoding="utf-8") + + basis = {"retirement_basis": {"target_symbols": ["old_api"]}} + todo_text = ( + "Replace old_api with new_api and delete the legacy export. " + + json.dumps(basis) + ) + added = _run_cli( + registry, + runtime, + "todo", + "add", + "--goal-id", + goal_id, + "--role", + "agent", + "--text", + todo_text, + "--note", + json.dumps(basis), + "--execute", + "--format", + "json", + ) + assert added.returncode == 0, added.stdout + added.stderr + assert json.loads(added.stdout)["status"] == "applied" + + capture = tmp_path / "host_capture.txt" + fake_codex = tmp_path / "codex" + fake_codex.write_text( + "#!/bin/sh\n" + "{\n" + " echo '=== ARGV ==='\n" + " printf '%s\\n' \"$@\"\n" + " echo '=== STDIN ==='\n" + " cat\n" + f'}} > "{capture}"\n' + "exit 9\n", + encoding="utf-8", + ) + fake_codex.chmod(0o755) + + turn = _run_cli( + registry, + runtime, + "turn", + "run-once", + "--goal-id", + goal_id, + "--agent-id", + "codex-fixture", + "--host", + "codex-cli", + "--project", + str(tmp_path / "project"), + "--codex-bin", + str(fake_codex), + "--timeout-seconds", + "30", + "--execute", + "--format", + "json", + ) + payload = json.loads(turn.stdout) + # The Host failed on purpose (exit 9) after its input was captured. + assert payload["effects"]["host_invoked"] is True + assert payload["result_kind"] == "host_failure" + assert capture.is_file(), turn.stdout + turn.stderr + + host_input = capture.read_text(encoding="utf-8", errors="replace") + assert "Anti-entropy code governance is active for this task" in host_input + assert "loopx anti-entropy inspect" in host_input + assert '"anti_entropy_context"' in host_input + assert '"inspect_tool"' in host_input From 1929b0e68a593b4b9a09cc15862506cb4a5b5454 Mon Sep 17 00:00:00 2001 From: LoopDesk Date: Mon, 2 Nov 2026 15:52:59 +0800 Subject: [PATCH 08/21] fix(anti-entropy): gate retirement activation on explicit task-scoped intent Base P1 from review R9: an ordinary Todo's params.target_paths activated delete-first guidance. Authorization now requires the reserved retirement_basis/anti_entropy_basis namespace; generic containers and the anti_entropy wrapper are carriers only. Intent is bound to the explicitly selected Todo id, so a stale recommended_action or unselected sibling cannot activate the current task. Goal-level availability and nested metadata/params enable flags no longer activate; the turn-context hook stays composed in the shared attach_turn_context owner on both production paths. Adds composer negative matrices, same-Goal/later-Todo native Host stdin coverage, heartbeat routing, and the R9 exact counterexample. Signed-off-by: LoopDesk --- .../capabilities/anti_entropy/turn_context.py | 195 +++++++---- .../test_anti_entropy_feedback.py | 320 +++++++++++++++++- .../test_anti_entropy_native_host.py | 225 ++++++++++++ 3 files changed, 667 insertions(+), 73 deletions(-) diff --git a/loopx/capabilities/anti_entropy/turn_context.py b/loopx/capabilities/anti_entropy/turn_context.py index c612d4dc88..9218848e21 100644 --- a/loopx/capabilities/anti_entropy/turn_context.py +++ b/loopx/capabilities/anti_entropy/turn_context.py @@ -19,8 +19,6 @@ "text", "description", "instruction", - "evidence", - "rationale", ) _ENABLE_CAPABILITY_IDS = frozenset( {"anti-entropy-governance", "anti_entropy_governance", "anti_entropy"} @@ -76,9 +74,30 @@ def _embedded_json_objects(text: str): index += 1 -def _extract_basis_candidate(candidate: Any) -> RetirementBasis | None: - """Recursively search a task carrier for an explicit retirement basis.""" - if candidate is None: +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 ( @@ -88,42 +107,41 @@ def _extract_basis_candidate(candidate: Any) -> RetirementBasis | None: ) if isinstance(candidate, str): for embedded in _embedded_json_objects(candidate): - parsed = _extract_basis_candidate(embedded) + 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: - parsed = _extract_basis_candidate(candidate[key]) - if parsed is not None: - return parsed + 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): - parsed = _extract_basis_candidate(anti_entropy) - if parsed is not None: - return parsed + 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]) + parsed = _extract_basis_candidate( + candidate[container_key], _depth=_depth + 1 + ) if parsed is not None: return parsed - # A dedicated basis object (not arbitrary task metadata) carries the - # declared targets directly. - if "target_symbols" in candidate or "target_paths" in candidate: - try: - parsed = RetirementBasis.from_dict(candidate) - except Exception: - parsed = None - if parsed is not None and (parsed.target_symbols or parsed.target_paths): - return parsed + for text_key in _TEXT_CARRIER_KEYS: text_value = candidate.get(text_key) if isinstance(text_value, str): - parsed = _extract_basis_candidate(text_value) + parsed = _extract_basis_candidate(text_value, _depth=_depth + 1) if parsed is not None: return parsed + return None @@ -190,6 +208,49 @@ def add(value: Any) -> None: 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") + return isinstance(todo_id, str) and todo_id.strip() in selected_ids + + def _resolve_retirement_basis( payload: Mapping[str, Any], decision: Mapping[str, Any], @@ -202,12 +263,8 @@ def _resolve_retirement_basis( key is accepted for direct callers but is never the only source. """ candidates: list[Any] = [ - payload.get("retirement_basis"), - payload.get("anti_entropy_basis"), - payload.get("anti_entropy"), - decision.get("retirement_basis"), - decision.get("anti_entropy_basis"), - decision.get("anti_entropy"), + payload, + decision, payload.get("route", {}).get("selected_todo") if isinstance(payload.get("route"), Mapping) else None, @@ -226,7 +283,15 @@ def _resolve_retirement_basis( 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 @@ -234,11 +299,28 @@ def _resolve_retirement_basis( if isinstance(envelope, Mapping): action = envelope.get("action") if isinstance(action, Mapping): - for text_key in ("recommended_action", "text"): - basis = _extract_basis_candidate(action.get(text_key)) - if basis is not None: - return basis + 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 @@ -252,26 +334,10 @@ def _load_goal(registry_path: Path | str | None, goal_id: str | None) -> Mapping from ...agent_registry import load_goal_from_registry return load_goal_from_registry(Path(registry_path), goal_id) - except Exception: # noqa: BLE001 - optional read-only enablement hint + except Exception: # noqa: BLE001 - optional read-only workspace hint return None -def _declared_capability_ids(goal: Mapping[str, Any]) -> set[str]: - declared: set[str] = set() - for container in ( - goal, - goal.get("control_plane"), - goal.get("coordination"), - ): - if not isinstance(container, Mapping): - continue - for key in ("available_capabilities", "required_capabilities"): - value = container.get(key) - if isinstance(value, (list, tuple, set)): - declared.update(str(item).strip() for item in value if isinstance(item, str)) - return declared - - def _record_required_capabilities(record: Mapping[str, Any]) -> set[str]: value = record.get("required_capabilities") if isinstance(value, (list, tuple, set)): @@ -283,15 +349,14 @@ def _is_anti_entropy_enabled( payload: Mapping[str, Any], decision: Mapping[str, Any], basis: RetirementBasis | None, - *, - registry_path: Path | str | None = None, - goal_id: str | None = None, ) -> bool: """Determine whether anti-entropy is enabled for this turn. - A declared retirement basis is the task-scoped opt-in. The explicit - capability id in the selected Todo's required capabilities and a Goal-level - available-capability declaration are the typed enablement paths. + 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 @@ -319,19 +384,15 @@ def _is_anti_entropy_enabled( 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 - for key in ("metadata", "params"): - value = record.get(key) - if isinstance(value, Mapping) and value.get("anti_entropy_enabled") is True: - return True if _record_required_capabilities(record) & _ENABLE_CAPABILITY_IDS: return True - goal = _load_goal(registry_path, goal_id) - if goal is not None and _declared_capability_ids(goal) & _ENABLE_CAPABILITY_IDS: - return True return False @@ -495,13 +556,7 @@ def anti_entropy_context_registration( payload, registry_path=registry_path, goal_id=goal_id ) basis = _resolve_retirement_basis(payload, decision) - enabled = _is_anti_entropy_enabled( - payload, - decision, - basis, - registry_path=registry_path, - goal_id=goal_id, - ) + enabled = _is_anti_entropy_enabled(payload, decision, basis) def produce() -> Mapping[str, Any]: return anti_entropy_context_result( diff --git a/tests/capabilities/test_anti_entropy_feedback.py b/tests/capabilities/test_anti_entropy_feedback.py index 6d50485a2f..18a7f499db 100644 --- a/tests/capabilities/test_anti_entropy_feedback.py +++ b/tests/capabilities/test_anti_entropy_feedback.py @@ -635,7 +635,7 @@ def test_turn_context_extracts_native_route_and_envelope_selected_todos() -> Non assert res_dec["readback"]["anti_entropy_guidance"]["status"] == "active" assert res_dec["readback"]["anti_entropy_guidance"]["target_paths"] == ["old_dec.py"] - # 4. Route selected_todo with JSON string in note + # 4. A bare target mapping in a note is ordinary task metadata, never an opt-in. payload_note = { "route": { "selected_todo": { @@ -645,8 +645,322 @@ def test_turn_context_extracts_native_route_and_envelope_selected_todos() -> Non } } res_note = anti_entropy_context_result(payload_note, goal_id="g1", decision={}) - assert res_note["readback"]["anti_entropy_guidance"]["status"] == "active" - assert res_note["readback"]["anti_entropy_guidance"]["target_symbols"] == ["old_note_func"] + 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_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_turn_context_reads_native_work_context_todo_note() -> None: """The hook reads the selected Todo's canonical note from the native work context.""" diff --git a/tests/capabilities/test_anti_entropy_native_host.py b/tests/capabilities/test_anti_entropy_native_host.py index 3093d9ff01..b114f95cd0 100644 --- a/tests/capabilities/test_anti_entropy_native_host.py +++ b/tests/capabilities/test_anti_entropy_native_host.py @@ -147,3 +147,228 @@ def test_real_turn_host_stdin_carries_anti_entropy_guidance(tmp_path: Path) -> N 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 + + 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 From 1ece7adc7c2b1c7dcfbfaa638006c015a69c2aef Mon Sep 17 00:00:00 2001 From: LoopDesk Date: Mon, 2 Nov 2026 15:53:01 +0800 Subject: [PATCH 09/21] fix(anti-entropy): decode escaped executable references and fail closed Base P2 from review R9: api['old\u005fapi']() was certified retired because the workspace prefilter skipped a file without the literal symbol and the computed-member parser dropped anything containing a backslash. One shared JS/TS escape decoder now serves both layers (unicode, brace unicode, hex, legacy octal, identity escapes, line continuations). Decoded identifier members and bare identifier escapes become references; malformed executable escapes set scan_incomplete and produce blocking AE004; ordinary strings, template text, URLs and array literals stay non-references. Scan budgets are unchanged. Adds parameterized escape-form matrices, malformed-escape AE004 cases, a harmless/migrated control, and a real CLI exit-code regression. Signed-off-by: LoopDesk --- loopx/capabilities/anti_entropy/oracles.py | 259 +++++++++++++++++++-- tests/capabilities/test_anti_entropy.py | 182 +++++++++++++++ 2 files changed, 419 insertions(+), 22 deletions(-) diff --git a/loopx/capabilities/anti_entropy/oracles.py b/loopx/capabilities/anti_entropy/oracles.py index 4a644170ce..a8eea2b37e 100644 --- a/loopx/capabilities/anti_entropy/oracles.py +++ b/loopx/capabilities/anti_entropy/oracles.py @@ -308,17 +308,203 @@ def _extract_ts_defs(lines: list[str]) -> set[str]: _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])") +_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}|[_$A-Za-z])" +) +_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) + + +def _normalize_identifier_escapes(code: str) -> str: + """Decode escapes embedded in code identifiers so ``old\\u005fapi`` is visible.""" + + def replace(match: re.Match[str]) -> str: + token = match.group(0) + if "\\" not in token: + 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 + + + +def _previous_identifier_word(out: list[str]) -> str: + """Return the identifier word immediately before the current position.""" + chars: list[str] = [] + seen = False + for position in range(len(out) - 1, -1, -1): + for char in reversed(out[position]): + if char.isspace(): + if seen: + return "".join(reversed(chars)) + continue + if char.isalnum() or char in "_$": + chars.append(char) + seen = True + continue + return "".join(reversed(chars)) + return "".join(reversed(chars)) + + +def _previous_code_char(out: list[str]) -> str: + for position in range(len(out) - 1, -1, -1): + stripped = out[position].rstrip() + if stripped: + return stripped[-1] + return "" def _static_computed_member( text: str, start: int, out: list[str] -) -> tuple[str, int] | None: +) -> _ComputedMember | None: """Preserve a statically named computed member access (api[name]). Only a member name in a value position is preserved. Array literals, declarations, and dynamic expressions stay string-literal text and never - become executable references; unparseable forms keep the normal string - handling so they cannot be certified as absent evidence. + become executable references. A quoted member body that cannot be decoded + (malformed escape) is reported as unresolved so the caller can fail closed + with AE004 instead of certifying absence. """ index = start + 1 length = len(text) @@ -331,31 +517,40 @@ def _static_computed_member( content_start = cursor while cursor < length: char = text[cursor] - if char == "\\" or char == "\n": - return None + if char == "\\": + if cursor + 1 >= length: + return None + cursor += 2 + continue if char == quote: break + if char == "\n": + return None cursor += 1 if cursor >= length: return None - name = text[content_start:cursor] - if not _TS_IDENTIFIER_RE.match(name): - return None + raw_member = text[content_start:cursor] after = cursor + 1 while after < length and text[after] in " \t": after += 1 if after >= length or text[after] != "]": return None - previous = "" - for position in range(len(out) - 1, -1, -1): - token = out[position] - stripped = token.rstrip() - if stripped: - previous = stripped[-1] - break + previous = _previous_code_char(out) if not (previous.isalnum() or previous in "_$)]."): return None - return name, after + 1 + if _previous_identifier_word(out) in _TS_MEMBER_KEYWORDS: + return None + next_index = after + 1 + if quote == "`" and "${" in raw_member: + return _ComputedMember("", next_index, False) + decoded = _decode_js_escape_text(raw_member) + if decoded is None: + return _ComputedMember("", next_index, False) + if not _TS_IDENTIFIER_RE.match(decoded): + # A statically named key that cannot be an identifier cannot equal a + # target symbol; it is a property name, not an active reference. + return _ComputedMember("", next_index, True) + return _ComputedMember(decoded, next_index, True) def _tokenize_ts_code(text: str) -> tuple[str, bool]: @@ -378,10 +573,11 @@ def _tokenize_ts_code(text: str) -> tuple[str, bool]: if text[i] == "[" and (not stack or stack[-1] != "template"): computed = _static_computed_member(text, i, out) if computed is not None: - name, next_index = computed + if not computed.resolved: + return "".join(out), False out.append(" ") - out.append(name) - i = next_index + out.append(computed.name) + i = computed.next_index continue if not stack: # Top-level code @@ -613,7 +809,7 @@ def _extract_ts_refs_with_syntax_check( defined_on_line = candidate break - line_for_refs = stripped + 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) @@ -704,6 +900,23 @@ def _extract_python_refs_from_code(code: str) -> set[str] | None: return refs +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. + """ + if any(symbol in content for symbol in symbols): + return True + if not is_ts or "\\" 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. @@ -747,10 +960,12 @@ def _scan_workspace(workspace: Path, symbols: tuple[str, ...]) -> _WorkspaceEvid continue visited += 1 - if not any(s in content for s in symbol_set): + ext = file_path.suffix.lower() + if not _content_may_reference( + content, symbol_set, is_ts=ext in _TS_EXTENSIONS + ): continue - ext = file_path.suffix.lower() if ext in _PY_EXTENSIONS: defs = _extract_python_defs_from_code(content) refs = _extract_python_refs_from_code(content) diff --git a/tests/capabilities/test_anti_entropy.py b/tests/capabilities/test_anti_entropy.py index bcba474876..b428591358 100644 --- a/tests/capabilities/test_anti_entropy.py +++ b/tests/capabilities/test_anti_entropy.py @@ -29,6 +29,11 @@ def test_catalog_registration() -> None: 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: @@ -1242,3 +1247,180 @@ def test_typescript_computed_member_migrated_and_harmless_strings_pass( receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) assert receipt.verdict == "passed", f"findings: {receipt.findings}" assert receipt.retired_symbols == ("old_api",) + + +_ESCAPED_CALLER_FORMS = ( + r"old\u005fapi", + r"old\u{5f}api", + r"old\x5fapi", + r"old\137api", + r"old\_api", +) + + +@pytest.mark.parametrize("escaped_name", _ESCAPED_CALLER_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_CALLER_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 From b8c4694980d66c86519fb3e5b63c77d9bddf5b7a Mon Sep 17 00:00:00 2001 From: LoopDesk Date: Mon, 2 Nov 2026 15:53:04 +0800 Subject: [PATCH 10/21] docs(anti-entropy): state task-scoped activation and descriptor-only tool metadata Document that the turn-context hook stays composed on both production paths, that Goal availability is not activation, that generic target fields are inert, and that the agent_tools entry is descriptor data for the CLI route rather than a host-registered tool. Correct the receipt wording to per-run construction with change-quality owning the receipt of record. Signed-off-by: LoopDesk --- loopx/capabilities/anti_entropy/README.md | 31 ++++++++++++------- .../anti_entropy/catalog_entry.py | 11 ++++--- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/loopx/capabilities/anti_entropy/README.md b/loopx/capabilities/anti_entropy/README.md index fd5b2b32af..21852c489d 100644 --- a/loopx/capabilities/anti_entropy/README.md +++ b/loopx/capabilities/anti_entropy/README.md @@ -30,10 +30,12 @@ The Anti-Entropy capability addresses this through: 4. **Ghost Code Auditing (`audit_ghost_code`)**: Identifies commented-out code blocks left behind, surfacing them as advisory quality findings without blocking legitimate documentation or explanatory comments. -5. **Durable Receipt Generation (`AntiEntropyReceipt`)**: - Runs deterministic hygiene oracles against the exact git diff and produces - an immutable receipt recording verified retired symbols, findings, and the - evidence provenance (`basis_hash`, `source_root`, `source_evidence`). +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 @@ -69,12 +71,19 @@ task is an explicit choice. When enabled, one closed loop per task is: (its `note` / instruction text, `required_capabilities`, and the full task body when the compact display text was truncated), plus `turn_envelope.action.selected_todo.text` / `action.recommended_action`; - - a task opts in by declaring an explicit `retirement_basis` / - `anti_entropy_basis` JSON object in that text. A Todo may also list - `anti-entropy-governance` in `required_capabilities`, but only when the - Goal already declares that capability available: otherwise routing treats - the Todo as requiring an unavailable capability and the Turn stops before - the hook, so the explicit task declaration is the primary path; + - 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. @@ -152,7 +161,7 @@ no model runs and measures no agent-runtime behavior. ## Boundaries -- The capability is default-off and task-scoped: turns receive the retirement context only when the selected task explicitly declares a retirement basis or enables the capability in its own canonical input; the CLI (verify/inspect) remains the shared verification entry. +- The 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. diff --git a/loopx/capabilities/anti_entropy/catalog_entry.py b/loopx/capabilities/anti_entropy/catalog_entry.py index 4cf80d21c5..7111c2f8c8 100644 --- a/loopx/capabilities/anti_entropy/catalog_entry.py +++ b/loopx/capabilities/anti_entropy/catalog_entry.py @@ -66,8 +66,11 @@ "descriptor": "loopx.capabilities.anti_entropy.feedback.ANTI_ENTROPY_INSPECT_TOOL_DESCRIPTOR", "entrypoint": "loopx.capabilities.anti_entropy.agent_tool.anti_entropy_inspect", "purpose": ( - "Agent-callable inspection returning actionable findings as " - "progress facts plus an immutable receipt." + "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", }, @@ -91,8 +94,8 @@ "docs": [_README], "boundaries": [ "The capability is default-off and task-scoped: a turn receives the retirement context only when the task explicitly declares a retirement basis or enables the capability; disabled turns get zero injection.", - "The turn-context hook projects declared targets, the inspect descriptor, and delete-first guidance into host input via the shared attach_turn_context composition; it grants no execution authority.", - "Task intent comes from the selected Todo's own instruction/note text or an explicit capability id in its required capabilities; the post-change workspace is the Goal repository, never the process working directory.", + "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.", From 7ecfc2ac05ee698e1d447d73aeec69217a0ecc22 Mon Sep 17 00:00:00 2001 From: LoopDesk Date: Mon, 2 Nov 2026 15:59:10 +0800 Subject: [PATCH 11/21] fix(anti-entropy): accept canonical id-less Todo note as selected carrier The canonical work-context Todo read record can omit todo_id. The first selection-binding pass dropped every such record, silently severing the note carrier and regressing the reviewer's explicit-basis positive control. Id-less records are now treated as the selected task's own carrier; only an explicitly different todo_id is excluded, so same-Goal sibling scope stays. Adds composer and real native Host stdin note-only positive regressions. Signed-off-by: LoopDesk --- .../capabilities/anti_entropy/turn_context.py | 6 +++- .../test_anti_entropy_feedback.py | 36 +++++++++++++++++++ .../test_anti_entropy_native_host.py | 20 +++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/loopx/capabilities/anti_entropy/turn_context.py b/loopx/capabilities/anti_entropy/turn_context.py index 9218848e21..dad628d192 100644 --- a/loopx/capabilities/anti_entropy/turn_context.py +++ b/loopx/capabilities/anti_entropy/turn_context.py @@ -248,7 +248,11 @@ def _record_matches_selection( if not selected_ids: return True todo_id = record.get("todo_id") - return isinstance(todo_id, str) and todo_id.strip() in selected_ids + # 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( diff --git a/tests/capabilities/test_anti_entropy_feedback.py b/tests/capabilities/test_anti_entropy_feedback.py index 18a7f499db..fe2d290a79 100644 --- a/tests/capabilities/test_anti_entropy_feedback.py +++ b/tests/capabilities/test_anti_entropy_feedback.py @@ -909,6 +909,42 @@ def fake_attach(payload: dict, **kwargs: object) -> None: 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 = { diff --git a/tests/capabilities/test_anti_entropy_native_host.py b/tests/capabilities/test_anti_entropy_native_host.py index b114f95cd0..66317aa558 100644 --- a/tests/capabilities/test_anti_entropy_native_host.py +++ b/tests/capabilities/test_anti_entropy_native_host.py @@ -354,6 +354,26 @@ def test_native_turn_activation_scope_matrix(tmp_path: Path) -> None: 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, From 6878f1e4665dfa99169efe718ba735034d47ed77 Mon Sep 17 00:00:00 2001 From: LoopDesk Date: Mon, 2 Nov 2026 15:59:13 +0800 Subject: [PATCH 12/21] fix(anti-entropy): treat line continuations as escapes and keep regex text inert Adversarial review found two escape-boundary holes after the first P2 pass: a quoted computed member spanning a JS line continuation was skipped by the workspace prefilter, and escape normalization decoded target-like text inside a regex literal. The prefilter/decoder now recognize backslash line terminators (LF and CRLF), and identifier-escape normalization skips heuristic regex-literal spans so pattern text stays non-executable. Adds line-continuation member forms and an escaped-regex negative control. Signed-off-by: LoopDesk --- loopx/capabilities/anti_entropy/oracles.py | 81 +++++++++++++++++++++- tests/capabilities/test_anti_entropy.py | 39 ++++++++++- 2 files changed, 115 insertions(+), 5 deletions(-) diff --git a/loopx/capabilities/anti_entropy/oracles.py b/loopx/capabilities/anti_entropy/oracles.py index a8eea2b37e..9d0867df69 100644 --- a/loopx/capabilities/anti_entropy/oracles.py +++ b/loopx/capabilities/anti_entropy/oracles.py @@ -322,9 +322,12 @@ def _extract_ts_defs(lines: list[str]) -> set[str]: "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])") +_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}|[_$A-Za-z])" + 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])" @@ -446,13 +449,87 @@ def replace(match: re.Match[str]) -> str: 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 diff --git a/tests/capabilities/test_anti_entropy.py b/tests/capabilities/test_anti_entropy.py index b428591358..4cf893077e 100644 --- a/tests/capabilities/test_anti_entropy.py +++ b/tests/capabilities/test_anti_entropy.py @@ -1249,16 +1249,23 @@ def test_typescript_computed_member_migrated_and_harmless_strings_pass( assert receipt.retired_symbols == ("old_api",) -_ESCAPED_CALLER_FORMS = ( +_ESCAPED_MEMBER_FORMS = ( r"old\u005fapi", r"old\u{5f}api", r"old\x5fapi", r"old\137api", r"old\_api", + "old_\ +api", + "old_\ +api", +) +_ESCAPED_IDENTIFIER_FORMS = tuple( + name for name in _ESCAPED_MEMBER_FORMS if "\n" not in name ) -@pytest.mark.parametrize("escaped_name", _ESCAPED_CALLER_FORMS) +@pytest.mark.parametrize("escaped_name", _ESCAPED_MEMBER_FORMS) def test_typescript_escaped_static_computed_member_blocks_retirement( tmp_path: Path, escaped_name: str ) -> None: @@ -1288,7 +1295,7 @@ def test_typescript_escaped_static_computed_member_blocks_retirement( assert receipt.retired_symbols == () -@pytest.mark.parametrize("escaped_name", _ESCAPED_CALLER_FORMS) +@pytest.mark.parametrize("escaped_name", _ESCAPED_IDENTIFIER_FORMS) def test_typescript_escaped_bare_identifier_blocks_retirement( tmp_path: Path, escaped_name: str ) -> None: @@ -1424,3 +1431,29 @@ def test_typescript_escaped_call_real_cli_exit_code(tmp_path: Path) -> None: 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",) From a0fb7c8b448065112a4eb9f74290332da240653c Mon Sep 17 00:00:00 2001 From: LoopDesk Date: Mon, 2 Nov 2026 16:08:22 +0800 Subject: [PATCH 13/21] docs(anti-entropy): document known reference-analysis limitations State the dynamic computed-member gap, the heuristic regex-literal guard, the id-less canonical Todo assumption, malformed-basis fail-closed behavior, and the remaining native-recovery evidence boundary. Signed-off-by: LoopDesk --- loopx/capabilities/anti_entropy/README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/loopx/capabilities/anti_entropy/README.md b/loopx/capabilities/anti_entropy/README.md index 21852c489d..2599844520 100644 --- a/loopx/capabilities/anti_entropy/README.md +++ b/loopx/capabilities/anti_entropy/README.md @@ -169,3 +169,11 @@ no model runs and measures no agent-runtime behavior. - 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 + +- Dynamic computed member access (`api[dynamicKey]`, concatenated keys, template-interpolated keys) is 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. +- 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. +- Escaped definition names and escapes outside value-position member/identifier references are outside the reference model. From 7bb8640c1479bd4f5ed211a6e9e3f0e1653528a1 Mon Sep 17 00:00:00 2001 From: LoopDesk Date: Mon, 2 Nov 2026 16:11:31 +0800 Subject: [PATCH 14/21] test(anti-entropy): build line-continuation fixtures without trailing backslash lines Keep the same LF/CRLF escape cases while avoiding a source line that ends in a backslash, so the diff whitespace gate stays clean. Signed-off-by: LoopDesk --- tests/capabilities/test_anti_entropy.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/capabilities/test_anti_entropy.py b/tests/capabilities/test_anti_entropy.py index 4cf893077e..1a6cf68101 100644 --- a/tests/capabilities/test_anti_entropy.py +++ b/tests/capabilities/test_anti_entropy.py @@ -1255,10 +1255,8 @@ def test_typescript_computed_member_migrated_and_harmless_strings_pass( r"old\x5fapi", r"old\137api", r"old\_api", - "old_\ -api", - "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 From d027028e1e6ed976e07516a3216a8234f13374a4 Mon Sep 17 00:00:00 2001 From: LoopDesk Date: Tue, 3 Nov 2026 10:16:57 +0800 Subject: [PATCH 15/21] fix(anti-entropy): parse static member keys across JS trivia and expressions Review R9 follow-up: a static computed member with newlines/comments between tokens was certified retired. Replace the quote-only member parser with a bounded static key expression parser: - shared _skip_js_trivia for whitespace (including U+FEFF/U+2028/U+2029) and // or block comments at every member token boundary; - string/template literals, parentheses, constant + concatenation, String() and String.raw, simple TS as/satisfies/non-null/array-type wrappers; - receiver non-null suffix and ASI-safe 'of' contextual handling; - multiline line-count preservation and fail-closed AE004 for unclosed comments; - candidate gate now detects static-member-shaped files, including composed keys that do not contain the literal symbol. Adds class matrices for trivia, key expressions, TS wrappers, Unicode trivia, String tails, contextual identifiers, for-of arrays, line mapping, and real CLI exit codes. Documents remaining dynamic/destructuring/reference false-pass limits and conservative false rejects. Signed-off-by: LoopDesk --- loopx/capabilities/anti_entropy/README.md | 6 +- loopx/capabilities/anti_entropy/oracles.py | 435 ++++++++++++++++--- tests/capabilities/test_anti_entropy.py | 482 +++++++++++++++++++++ 3 files changed, 865 insertions(+), 58 deletions(-) diff --git a/loopx/capabilities/anti_entropy/README.md b/loopx/capabilities/anti_entropy/README.md index 2599844520..d184797a3d 100644 --- a/loopx/capabilities/anti_entropy/README.md +++ b/loopx/capabilities/anti_entropy/README.md @@ -172,8 +172,12 @@ no model runs and measures no agent-runtime behavior. ## Known Limitations -- Dynamic computed member access (`api[dynamicKey]`, concatenated keys, template-interpolated keys) is 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 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. +- Destructuring property reads (`const { ['old_api']: fn } = api`) are not detected by the member-expression parser; this is a known executable-read gap that needs pattern analysis or a real JS/TS parser, not a wider text heuristic. +- Reference extraction is token-kind conservative: 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. - 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/oracles.py b/loopx/capabilities/anti_entropy/oracles.py index 9d0867df69..57d067f3e1 100644 --- a/loopx/capabilities/anti_entropy/oracles.py +++ b/loopx/capabilities/anti_entropy/oracles.py @@ -543,91 +543,379 @@ class _ComputedMember: 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_identifier_word(out: list[str]) -> str: - """Return the identifier word immediately before the current position.""" - chars: list[str] = [] - seen = False +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(): - if seen: - return "".join(reversed(chars)) + if char.isspace() or char == "!": continue - if char.isalnum() or char in "_$": - chars.append(char) - seen = True - continue - return "".join(reversed(chars)) - return "".join(reversed(chars)) + return char + return "" -def _previous_code_char(out: list[str]) -> str: - for position in range(len(out) - 1, -1, -1): - stripped = out[position].rstrip() - if stripped: - return stripped[-1] - 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] -def _static_computed_member( - text: str, start: int, out: list[str] -) -> _ComputedMember | None: - """Preserve a statically named computed member access (api[name]). +_TS_CONTEXTUAL_MEMBER_WORDS = frozenset({"of"}) - Only a member name in a value position is preserved. Array literals, - declarations, and dynamic expressions stay string-literal text and never - become executable references. A quoted member body that cannot be decoded - (malformed escape) is reported as unresolved so the caller can fail closed - with AE004 instead of certifying absence. - """ - index = start + 1 - length = len(text) - while index < length and text[index] in " \t": - index += 1 - if index >= length or text[index] not in _TS_QUOTE_CHARS: - return None + +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 - content_start = cursor while cursor < length: char = text[cursor] if char == "\\": if cursor + 1 >= length: - return None + return _StaticKey("", length, False) cursor += 2 continue if char == quote: - break - if char == "\n": + 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 - if cursor >= length: + 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 - raw_member = text[content_start:cursor] - after = cursor + 1 - while after < length and text[after] in " \t": - after += 1 + 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_code_char(out) + previous = _previous_receiver_char(out) if not (previous.isalnum() or previous in "_$)]."): return None - if _previous_identifier_word(out) in _TS_MEMBER_KEYWORDS: + 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 - if quote == "`" and "${" in raw_member: - return _ComputedMember("", next_index, False) - decoded = _decode_js_escape_text(raw_member) - if decoded is None: - return _ComputedMember("", next_index, False) - if not _TS_IDENTIFIER_RE.match(decoded): - # A statically named key that cannot be an identifier cannot equal a - # target symbol; it is a property name, not an active reference. - return _ComputedMember("", next_index, True) - return _ComputedMember(decoded, next_index, True) + name = key.value if _TS_IDENTIFIER_RE.match(key.value) else "" + return _ComputedMember( + name, + next_index, + True, + text.count("\n", start, next_index), + ) def _tokenize_ts_code(text: str) -> tuple[str, bool]: @@ -654,6 +942,8 @@ def _tokenize_ts_code(text: str) -> tuple[str, bool]: 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: @@ -977,6 +1267,30 @@ def _extract_python_refs_from_code(code: str) -> set[str] | None: return refs +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 _content_may_reference(content: str, symbols: set[str], *, is_ts: bool) -> bool: """Cheap gate before parsing a workspace file. @@ -987,7 +1301,14 @@ def _content_may_reference(content: str, symbols: set[str], *, is_ts: bool) -> b """ if any(symbol in content for symbol in symbols): return True - if not is_ts or "\\" not in content: + if not is_ts: + return False + # 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): + return True + if "\\" not in content: return False if any(symbol in _decode_js_escapes_lenient(content) for symbol in symbols): return True diff --git a/tests/capabilities/test_anti_entropy.py b/tests/capabilities/test_anti_entropy.py index 1a6cf68101..e5252aef66 100644 --- a/tests/capabilities/test_anti_entropy.py +++ b/tests/capabilities/test_anti_entropy.py @@ -1455,3 +1455,485 @@ def test_typescript_escaped_regex_pattern_is_not_a_reference(tmp_path: Path) -> 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 == () From 860e0f00eb8e1c7412383087380b4cd06d21e0a3 Mon Sep 17 00:00:00 2001 From: LoopDesk Date: Tue, 3 Nov 2026 14:26:44 +0800 Subject: [PATCH 16/21] fix(anti-entropy): preserve Python NFKC identifier identity in candidate scan A raw-substring Python candidate gate skipped files whose only old_api identity was written with NFKC-equivalent characters (for example fullwidth letters), even though ast.parse normalizes the name to old_api. The gate now also checks the NFKC-normalized source against NFKC-normalized targets, and AST def/ref matching maps normalized names back to the declared target. Adds real fullwidth, ligature and combining-mark definition/call/import tests plus a correct-deletion control. Signed-off-by: LoopDesk --- loopx/capabilities/anti_entropy/oracles.py | 36 +++++++-- tests/capabilities/test_anti_entropy.py | 90 ++++++++++++++++++++++ 2 files changed, 121 insertions(+), 5 deletions(-) diff --git a/loopx/capabilities/anti_entropy/oracles.py b/loopx/capabilities/anti_entropy/oracles.py index 57d067f3e1..3be18574ee 100644 --- a/loopx/capabilities/anti_entropy/oracles.py +++ b/loopx/capabilities/anti_entropy/oracles.py @@ -7,6 +7,7 @@ import os import re import textwrap +import unicodedata from collections.abc import Mapping from dataclasses import dataclass, field from pathlib import Path @@ -1291,6 +1292,11 @@ def _has_static_member_hint(content: str) -> bool: return False +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. @@ -1298,11 +1304,21 @@ def _content_may_reference(content: str, symbols: set[str], *, is_ts: bool) -> b 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: - return False + if content.isascii(): + return False + normalized_content = unicodedata.normalize("NFKC", content) + return any( + normalized in normalized_content + for normalized in _normalized_python_symbols(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. @@ -1369,11 +1385,21 @@ def _scan_workspace(workspace: Path, symbols: tuple[str, ...]) -> _WorkspaceEvid refs = _extract_python_refs_from_code(content) if defs is None or refs is None: # Unparseable source cannot prove absence; treat the symbol - # as still defined to avoid false certification. - for s in symbol_set: - if s in content: - evidence.defined_symbols.setdefault(s, set()).add(rel) + # as still defined to avoid false certification. NFKC keeps + # the check aligned with Python identifier identity. + normalized_content = unicodedata.normalize("NFKC", content) + for target, normalized in _normalized_python_symbols( + symbol_set + ).items(): + if normalized in normalized_content: + evidence.defined_symbols.setdefault(target, set()).add(rel) continue + for target, normalized 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) diff --git a/tests/capabilities/test_anti_entropy.py b/tests/capabilities/test_anti_entropy.py index e5252aef66..15344b576f 100644 --- a/tests/capabilities/test_anti_entropy.py +++ b/tests/capabilities/test_anti_entropy.py @@ -1937,3 +1937,93 @@ def test_typescript_asi_contextual_identifier_receiver_blocks_retirement( 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 == () From 448d7268d7eff62bfb721ba9d81c9e0db82c1aac Mon Sep 17 00:00:00 2001 From: LoopDesk Date: Tue, 3 Nov 2026 14:43:55 +0800 Subject: [PATCH 17/21] fix(anti-entropy): classify destructuring reads and ambiguous property keys Add a bounded three-state property-key classifier for JS/TS sources: - clear destructuring/binding/parameter/assignment/for-of property keys are references (static string/template/computed keys now reach evidence); - clear object-literal keys are non-references; - target keys in a context the classifier cannot place, and dynamic destructuring keys, mark the scan incomplete and return blocking AE004 with an actionable next action instead of silently passing. The candidate gate now routes object-pattern/computed-key sources, and AE004 feedback explains the object-literal/destructuring remediation choice. Adds destructuring/object-literal/AE004 tests, real CLI regressions, and README boundary updates. Python NFKC candidate identity is preserved from the prior slice. Signed-off-by: LoopDesk --- loopx/capabilities/anti_entropy/README.md | 6 +- loopx/capabilities/anti_entropy/feedback.py | 8 + loopx/capabilities/anti_entropy/oracles.py | 352 +++++++++++++++++- tests/capabilities/test_anti_entropy.py | 191 ++++++++++ .../test_anti_entropy_feedback.py | 31 ++ 5 files changed, 579 insertions(+), 9 deletions(-) diff --git a/loopx/capabilities/anti_entropy/README.md b/loopx/capabilities/anti_entropy/README.md index d184797a3d..1c63436202 100644 --- a/loopx/capabilities/anti_entropy/README.md +++ b/loopx/capabilities/anti_entropy/README.md @@ -174,10 +174,12 @@ no model runs and measures no agent-runtime behavior. - 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. -- Destructuring property reads (`const { ['old_api']: fn } = api`) are not detected by the member-expression parser; this is a known executable-read gap that needs pattern analysis or a real JS/TS parser, not a wider text heuristic. -- Reference extraction is token-kind conservative: 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. +- 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/feedback.py b/loopx/capabilities/anti_entropy/feedback.py index 0ccf8c6a88..4589673b40 100644 --- a/loopx/capabilities/anti_entropy/feedback.py +++ b/loopx/capabilities/anti_entropy/feedback.py @@ -92,6 +92,14 @@ def _rule_next_action(finding: AntiEntropyFinding) -> str: 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." diff --git a/loopx/capabilities/anti_entropy/oracles.py b/loopx/capabilities/anti_entropy/oracles.py index 3be18574ee..e333bbff41 100644 --- a/loopx/capabilities/anti_entropy/oracles.py +++ b/loopx/capabilities/anti_entropy/oracles.py @@ -919,6 +919,316 @@ def _static_computed_member( ) + +@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", + ): + 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_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 _tokenize_ts_code(text: str) -> tuple[str, bool]: """Tokenize and strip comments and string-literal spans from TS/JS code. @@ -1141,14 +1451,14 @@ def _extract_ts_refs( lines: list[str], target_symbols: set[str], ) -> set[str]: - refs, _ = _extract_ts_refs_with_syntax_check(lines, target_symbols) + 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]: +) -> 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 @@ -1157,11 +1467,17 @@ def _extract_ts_refs_with_syntax_check( """ refs: set[str] = set() if not target_symbols: - return refs, True + return refs, True, "" full_text = "\n".join(lines) stripped_text, is_clean = _tokenize_ts_code(full_text) stripped_lines = stripped_text.splitlines() + 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 "" @@ -1186,7 +1502,9 @@ def _extract_ts_refs_with_syntax_check( if s in words: refs.add(s) - return refs, is_clean + 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]: @@ -1297,6 +1615,22 @@ def _normalized_python_symbols(symbols: set[str]) -> dict[str, str]: return {unicodedata.normalize("NFKC", symbol): symbol for symbol in symbols} +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 + + def _content_may_reference(content: str, symbols: set[str], *, is_ts: bool) -> bool: """Cheap gate before parsing a workspace file. @@ -1322,7 +1656,7 @@ def _content_may_reference(content: str, symbols: set[str], *, is_ts: bool) -> b # 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): + if _has_static_member_hint(content) or _has_property_key_hint(content): return True if "\\" not in content: return False @@ -1403,10 +1737,14 @@ def _scan_workspace(workspace: Path, symbols: tuple[str, ...]) -> _WorkspaceEvid else: content_lines = content.splitlines() defs = _extract_ts_defs(content_lines) - refs, syntax_ok = _extract_ts_refs_with_syntax_check(content_lines, symbol_set) + 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 unclosed or unparseable JS/TS syntax" + evidence.incomplete_reason = ( + f"Source file '{rel}' has incomplete reference evidence: {syntax_reason}" + ) skipped.append(rel) continue diff --git a/tests/capabilities/test_anti_entropy.py b/tests/capabilities/test_anti_entropy.py index 15344b576f..9e8be0d22a 100644 --- a/tests/capabilities/test_anti_entropy.py +++ b/tests/capabilities/test_anti_entropy.py @@ -2027,3 +2027,194 @@ def test_python_nfkc_ligature_and_combining_identifiers( 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();", +) + + +@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 };", +) + + +@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_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 index fe2d290a79..73d5bd5b81 100644 --- a/tests/capabilities/test_anti_entropy_feedback.py +++ b/tests/capabilities/test_anti_entropy_feedback.py @@ -998,6 +998,37 @@ def test_unselected_sibling_todo_basis_does_not_activate_selected_todo() -> None 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 = { From 19d1a260bfdd5d2c825891840f1e90bae0d4f7e9 Mon Sep 17 00:00:00 2001 From: LoopDesk Date: Tue, 3 Nov 2026 14:57:06 +0800 Subject: [PATCH 18/21] fix(anti-entropy): harden property-key and NFKC reference coverage Fix the adversarial findings on the new classifier: - correct JS string/comment skipping escapes so destructuring detection cannot depend on unrelated strings; let-bound string keys and escaped computed keys now reject; - detect static string names in import/export clauses as executable references; - classify export-default object literals as non-references; - fix Python NFKC target/source mapping direction so a declared variant target matches the ASCII AST identity. Adds regression forms for all four cases. Signed-off-by: LoopDesk --- loopx/capabilities/anti_entropy/oracles.py | 92 ++++++++++++++++++++-- tests/capabilities/test_anti_entropy.py | 59 ++++++++++++++ 2 files changed, 144 insertions(+), 7 deletions(-) diff --git a/loopx/capabilities/anti_entropy/oracles.py b/loopx/capabilities/anti_entropy/oracles.py index e333bbff41..2685ec9f24 100644 --- a/loopx/capabilities/anti_entropy/oracles.py +++ b/loopx/capabilities/anti_entropy/oracles.py @@ -935,12 +935,12 @@ def _skip_js_string_literal(text: str, index: int) -> int: cursor = index + 1 while cursor < length: char = text[cursor] - if char == "\\\\": + if char == "\\": cursor += 2 continue if char == quote: return cursor + 1 - if quote != "`" and char in "\\r\\n\\u2028\\u2029": + if quote != "`" and char in "\r\n\u2028\u2029": return cursor cursor += 1 return length @@ -957,7 +957,7 @@ def _scan_js_bracket_groups(text: str) -> list[_JsBracketGroup]: char = text[index] if text.startswith("//", index): newline = index + 2 - while newline < length and text[newline] not in "\\r\\n\\u2028\\u2029": + while newline < length and text[newline] not in "\r\n\u2028\u2029": newline += 1 index = newline continue @@ -1063,7 +1063,7 @@ def _classify_js_group( kind = "params" else: kind = "expression" - elif group.open_char in "{[": + 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"): @@ -1092,6 +1092,7 @@ def _classify_js_group( "instanceof", "of", "in", + "default", ): kind = "literal" else: @@ -1117,6 +1118,79 @@ def _enclosing_object_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]: @@ -1472,6 +1546,7 @@ def _extract_ts_refs_with_syntax_check( 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 ) @@ -1646,12 +1721,15 @@ def _content_may_reference(content: str, symbols: set[str], *, is_ts: bool) -> b 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_python_symbols(symbols) + for normalized in normalized_symbols ) # A composed static member key (parentheses, concatenation, template, # comments/newlines, String(...)) need not contain the literal symbol, so @@ -1722,13 +1800,13 @@ def _scan_workspace(workspace: Path, symbols: tuple[str, ...]) -> _WorkspaceEvid # as still defined to avoid false certification. NFKC keeps # the check aligned with Python identifier identity. normalized_content = unicodedata.normalize("NFKC", content) - for target, normalized in _normalized_python_symbols( + 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 target, normalized in _normalized_python_symbols(symbol_set).items(): + 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: diff --git a/tests/capabilities/test_anti_entropy.py b/tests/capabilities/test_anti_entropy.py index 9e8be0d22a..99ecc88a49 100644 --- a/tests/capabilities/test_anti_entropy.py +++ b/tests/capabilities/test_anti_entropy.py @@ -2045,6 +2045,9 @@ def test_python_nfkc_ligature_and_combining_identifiers( "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();", ) @@ -2085,6 +2088,7 @@ def test_typescript_destructuring_static_key_blocks_retirement( "const v = !{ 'old_api': 1 };", "const v = true && { 'old_api': 1 };", "const config = { 'new_api': 1 };", + "export default { 'old_api': 1 };", ) @@ -2141,6 +2145,61 @@ def test_typescript_ambiguous_static_property_key_reports_ae004( 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 = """ From 7753404c54c3fb787380a68dce47f57701d9bf4e Mon Sep 17 00:00:00 2001 From: LoopDesk Date: Tue, 3 Nov 2026 15:09:59 +0800 Subject: [PATCH 19/21] chore(canary): record reviewed module ceiling for anti-entropy oracle The anti-entropy oracle is a new capability module that now exceeds the default 2000-line module metric budget. Record its reviewed metrics (lines=2355, any_count=3, dict_any_count=0) so the maintainability ratchet reflects the accepted capability surface instead of failing as unreviewed debt. Signed-off-by: LoopDesk --- loopx/canary/module_metric_baseline.json | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/loopx/canary/module_metric_baseline.json b/loopx/canary/module_metric_baseline.json index 5b498482ac..c79fef1ec7 100644 --- a/loopx/canary/module_metric_baseline.json +++ b/loopx/canary/module_metric_baseline.json @@ -14,6 +14,11 @@ "any_count": 22, "dict_any_count": 0 }, + "loopx/capabilities/anti_entropy/oracles.py": { + "any_count": 3, + "dict_any_count": 0, + "lines": 2355 + }, "loopx/capabilities/catalog.py": { "any_count": 8, "dict_any_count": 0 @@ -46,13 +51,13 @@ "loopx/chat_store.py": { "lines": 2066 }, - "loopx/collaboration_mcp.py": { - "lines": 2058 - }, "loopx/codex_cli_probe.py": { "any_count": 37, "dict_any_count": 0 }, + "loopx/collaboration_mcp.py": { + "lines": 2058 + }, "loopx/control_plane/agents/agent_scope.py": { "any_count": 107, "dict_any_count": 0 @@ -92,14 +97,14 @@ "any_count": 51, "dict_any_count": 0 }, - "loopx/presentation/renderers/status_markdown.py": { - "any_count": 47, - "dict_any_count": 0 - }, "loopx/pr_review.py": { "any_count": 49, "dict_any_count": 0 }, + "loopx/presentation/renderers/status_markdown.py": { + "any_count": 47, + "dict_any_count": 0 + }, "loopx/quota.py": { "any_count": 219, "dict_any_count": 0, From 4a11caec606fb128aaadc91bedfb0c3e953c8f29 Mon Sep 17 00:00:00 2001 From: LoopDesk Date: Tue, 3 Nov 2026 15:31:31 +0800 Subject: [PATCH 20/21] refactor(anti-entropy): split JS reference analysis out of oracle core Keep the new capability's modules under the default 2000-line maintainability ceiling instead of recording a canary baseline exception. Move the JS/TS lexer helpers, static-key parser, computed-member parser, module string-name scanner and property-key classifier into capabilities/anti_entropy/js_reference.py; the oracle core imports them. Revert the module_metric_baseline.json reviewed-exception entry for oracles.py and restore the file to its pre-change content. No product behavior change. Signed-off-by: LoopDesk --- loopx/canary/module_metric_baseline.json | 19 +- .../capabilities/anti_entropy/js_reference.py | 1047 +++++++++++++++++ loopx/capabilities/anti_entropy/oracles.py | 1046 +--------------- 3 files changed, 1064 insertions(+), 1048 deletions(-) create mode 100644 loopx/capabilities/anti_entropy/js_reference.py diff --git a/loopx/canary/module_metric_baseline.json b/loopx/canary/module_metric_baseline.json index c79fef1ec7..5b498482ac 100644 --- a/loopx/canary/module_metric_baseline.json +++ b/loopx/canary/module_metric_baseline.json @@ -14,11 +14,6 @@ "any_count": 22, "dict_any_count": 0 }, - "loopx/capabilities/anti_entropy/oracles.py": { - "any_count": 3, - "dict_any_count": 0, - "lines": 2355 - }, "loopx/capabilities/catalog.py": { "any_count": 8, "dict_any_count": 0 @@ -51,13 +46,13 @@ "loopx/chat_store.py": { "lines": 2066 }, + "loopx/collaboration_mcp.py": { + "lines": 2058 + }, "loopx/codex_cli_probe.py": { "any_count": 37, "dict_any_count": 0 }, - "loopx/collaboration_mcp.py": { - "lines": 2058 - }, "loopx/control_plane/agents/agent_scope.py": { "any_count": 107, "dict_any_count": 0 @@ -97,14 +92,14 @@ "any_count": 51, "dict_any_count": 0 }, - "loopx/pr_review.py": { - "any_count": 49, - "dict_any_count": 0 - }, "loopx/presentation/renderers/status_markdown.py": { "any_count": 47, "dict_any_count": 0 }, + "loopx/pr_review.py": { + "any_count": 49, + "dict_any_count": 0 + }, "loopx/quota.py": { "any_count": 219, "dict_any_count": 0, 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 index 2685ec9f24..1544f8efb3 100644 --- a/loopx/capabilities/anti_entropy/oracles.py +++ b/loopx/capabilities/anti_entropy/oracles.py @@ -18,6 +18,16 @@ 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_]*)") @@ -307,1002 +317,6 @@ def _extract_ts_defs(lines: list[str]) -> set[str]: return defs -_TS_IDENTIFIER_RE = re.compile(r"[A-Za-z_$][A-Za-z0-9_$]*\Z") -_TS_QUOTE_CHARS = ("\"", "'", chr(96)) -_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 _tokenize_ts_code(text: str) -> tuple[str, bool]: """Tokenize and strip comments and string-literal spans from TS/JS code. @@ -1661,51 +675,11 @@ def _extract_python_refs_from_code(code: str) -> set[str] | None: return refs -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 _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 _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 - - def _content_may_reference(content: str, symbols: set[str], *, is_ts: bool) -> bool: """Cheap gate before parsing a workspace file. From 3a0ef6a0f0311d04b56a6ea40d89065c9948bcca Mon Sep 17 00:00:00 2001 From: LoopDesk Date: Tue, 3 Nov 2026 17:22:31 +0800 Subject: [PATCH 21/21] =?UTF-8?q?fix(anti-entropy):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=85=AB=E8=BF=9B=E5=88=B6=E5=B1=9E=E6=80=A7=E5=90=8D=E4=B8=8E?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E6=8F=92=E5=80=BC=E8=A7=A3=E6=9E=84=E6=BC=8F?= =?UTF-8?q?=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修正 JS legacy octal 转义解码顺序与 Annex B 位数规则:`\062` 解析为 字符 `2`,`\400` 解析为 `\40` + `'0'`,而不是 NUL/畸形转义。 - 递归分析模板 `${...}` 可执行插值中的解构属性读取;嵌套模板、正则 字面量中的 `}`、括号/字符串/注释均不会截断插值表达式。 - 无法可靠匹配或分类的插值保持 AE004 fail-closed,不误认证退休。 - 增加真实 Git/CLI/Node `.cjs`/`.mjs` 回归、nested/regex/Annex B 对照, 以及 ordinary/migrated 双向控制。 Signed-off-by: LoopDesk --- loopx/capabilities/anti_entropy/README.md | 3 +- .../capabilities/anti_entropy/js_reference.py | 153 ++++++-- tests/capabilities/test_anti_entropy.py | 343 ++++++++++++++++++ 3 files changed, 471 insertions(+), 28 deletions(-) diff --git a/loopx/capabilities/anti_entropy/README.md b/loopx/capabilities/anti_entropy/README.md index 1c63436202..ce010bc1c7 100644 --- a/loopx/capabilities/anti_entropy/README.md +++ b/loopx/capabilities/anti_entropy/README.md @@ -172,9 +172,10 @@ no model runs and measures no agent-runtime behavior. ## 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. +- 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. Legacy JS octal escapes are decoded with JS semantics (`\062` is one character `2`, not NUL plus digits); malformed octal escapes 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. +- Template literal text is non-executable, but `${...}` interpolation expressions are recursively analyzed; a static destructuring read inside an interpolation blocks retirement rather than being skipped as string text. - 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. diff --git a/loopx/capabilities/anti_entropy/js_reference.py b/loopx/capabilities/anti_entropy/js_reference.py index ea56253655..c3b8dcc7e5 100644 --- a/loopx/capabilities/anti_entropy/js_reference.py +++ b/loopx/capabilities/anti_entropy/js_reference.py @@ -25,7 +25,6 @@ "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)" @@ -109,27 +108,23 @@ def _decode_js_escape_text(raw: str) -> str | 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. + # Legacy octal escape (non-strict JS strings). A lone \0 is NUL; + # \062 is one character "2", not NUL plus "62". if nxt == "0" and ( index + 2 >= length or raw[index + 2] not in "01234567" ): - output.append("\0") + output.append(chr(0)) index += 2 continue cursor = index + 1 digits = "" - while cursor < length and len(digits) < 3 and raw[cursor] in "01234567": + max_digits = 3 if nxt in "0123" else 2 + while ( + cursor < length + and len(digits) < max_digits + and raw[cursor] in "01234567" + ): digits += raw[cursor] cursor += 1 code = int(digits, 8) @@ -138,6 +133,16 @@ def _decode_js_escape_text(raw: str) -> str | None: output.append(chr(code)) index = cursor 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 # NonEscapeCharacter: the escaped character has no special meaning. output.append(nxt) index += 2 @@ -174,6 +179,19 @@ def replace(match: re.Match[str]) -> str: ) +def _regex_starts_at(code: str, index: int) -> bool: + """Heuristic: does a slash at ``index`` start a regex literal, not division?""" + 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 "" + return ( + not prefix + or previous_char in _REGEX_ALLOWED_AFTER + or previous_word in _REGEX_ALLOWED_AFTER_WORDS + ) + + def _regex_literal_spans(code: str) -> list[tuple[int, int]]: """Return heuristic regex-literal spans in stripped code. @@ -187,16 +205,7 @@ def _regex_literal_spans(code: str) -> list[tuple[int, int]]: 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: + if not _regex_starts_at(code, index): index += 1 continue cursor = index + 1 @@ -895,6 +904,89 @@ def _extract_module_string_name_refs( return refs +def _find_template_expression_end(text: str, start: int) -> int: + """Return the closing brace index for a template ``${...}`` expression.""" + cursor = start + depth = 1 + length = len(text) + while cursor < length: + char = text[cursor] + if char in _TS_QUOTE_CHARS: + 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 == "/" and _regex_starts_at(text, cursor): + regex_cursor = cursor + 1 + in_class = False + closed = False + while regex_cursor < length: + regex_char = text[regex_cursor] + if regex_char == "\\": + regex_cursor += 2 + continue + if regex_char in "\r\n\u2028\u2029": + break + if regex_char == "[": + in_class = True + elif regex_char == "]": + in_class = False + elif regex_char == "/" and not in_class: + closed = True + break + regex_cursor += 1 + if not closed: + return -1 + cursor = regex_cursor + 1 + continue + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return cursor + cursor += 1 + return -1 + + +def _template_interpolation_ranges( + text: str, index: int +) -> tuple[list[tuple[int, int]], int]: + """Collect executable ``${...}`` ranges in a template literal. + + Returns the expression ranges and the index just past the closing backtick. + Interpolation bodies are scanned independently so destructuring reads inside + them cannot be erased when the template is treated as a string value. + """ + ranges: list[tuple[int, int]] = [] + cursor = index + 1 + length = len(text) + while cursor < length: + char = text[cursor] + if char == "\\": + cursor += 2 + continue + if char == "`": + return ranges, cursor + 1 + if text.startswith("${", cursor): + expression_start = cursor + 2 + expression_end = _find_template_expression_end(text, expression_start) + if expression_end == -1: + return ranges, length + ranges.append((expression_start, expression_end)) + cursor = expression_end + 1 + continue + cursor += 1 + return ranges, length + + def _extract_static_property_key_refs( text: str, target_symbols: set[str] ) -> tuple[set[str], str]: @@ -909,8 +1001,6 @@ def _extract_static_property_key_refs( 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 @@ -936,7 +1026,9 @@ def _extract_static_property_key_refs( 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) + interpolation_ranges, next_index = _template_interpolation_ranges( + text, index + ) group = _enclosing_object_group(groups, index) if group is not None: kind = _classify_js_group(groups, groups.index(group), text) @@ -951,6 +1043,13 @@ def _extract_static_property_key_refs( incomplete_reason = ( "a static property key cannot be classified as object literal or destructuring read" ) + for expression_start, expression_end in interpolation_ranges: + inner_refs, inner_reason = _extract_static_property_key_refs( + text[expression_start:expression_end], target_symbols + ) + refs.update(inner_refs) + if inner_reason and not incomplete_reason: + incomplete_reason = inner_reason index = next_index continue next_index = parsed.next_index diff --git a/tests/capabilities/test_anti_entropy.py b/tests/capabilities/test_anti_entropy.py index 99ecc88a49..0a2803ec14 100644 --- a/tests/capabilities/test_anti_entropy.py +++ b/tests/capabilities/test_anti_entropy.py @@ -2277,3 +2277,346 @@ def test_typescript_destructuring_real_cli_exit_code( capture_output=True, ) assert proc.returncode == 1, proc.stdout + proc.stderr + + +@pytest.mark.parametrize( + "caller", + ( + "const api = require('./service.cjs');\nconsole.log(api['old_api\\062']());\n", + "const api = require('./service.cjs');\nconsole.log(api['old_api'+'\\062']());\n", + ), +) +def test_typescript_octal_numeric_property_key_blocks_retirement( + tmp_path: Path, caller: str +) -> None: + """Legacy octal escapes decode to the executable property name they read.""" + diff = """ +--- a/service.cjs ++++ b/service.cjs +@@ -1,2 +1,2 @@ +-function old_api2() { return 1; } +-module.exports = { old_api2 }; ++function new_api2() { return 2; } ++module.exports = { new_api2 }; +""" + basis = RetirementBasis(target_symbols=("old_api2",)) + workspace = _make_workspace( + tmp_path / "ws_octal_property", + { + "service.cjs": "function new_api2() { return 2; }\nmodule.exports = { new_api2 };\n", + "client.cjs": caller, + }, + ) + + 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( + "caller", + ( + "const api = require('./service.cjs');\nconsole.log('old_api\\062');\n", + "const api = require('./service.cjs');\nconsole.log(api.new_api2());\n", + ), +) +def test_typescript_octal_ordinary_string_and_migrated_pass( + tmp_path: Path, caller: str +) -> None: + """A non-executable string and a migrated call must not become false references.""" + diff = """ +--- a/service.cjs ++++ b/service.cjs +@@ -1,2 +1,2 @@ +-function old_api2() { return 1; } +-module.exports = { old_api2 }; ++function new_api2() { return 2; } ++module.exports = { new_api2 }; +""" + basis = RetirementBasis(target_symbols=("old_api2",)) + workspace = _make_workspace( + tmp_path / "ws_octal_control", + { + "service.cjs": "function new_api2() { return 2; }\nmodule.exports = { new_api2 };\n", + "client.cjs": caller, + }, + ) + + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) + assert receipt.verdict == "passed", f"findings: {receipt.findings}" + assert receipt.retired_symbols == ("old_api2",) + + +def test_typescript_template_interpolation_destructuring_blocks_retirement( + tmp_path: Path, +) -> None: + """Executable ${...} template interpolation must be analyzed, not skipped as text.""" + 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",)) + caller = ( + "import * as api from './service.mjs';\n" + "console.log(`${(() => { const { ['old_api']: invoke } = api; " + "return invoke(); })()}`);\n" + ) + workspace = _make_workspace( + tmp_path / "ws_template_interpolation", + { + "service.mjs": "export function new_api() { return 2; }\n", + "client.mjs": caller, + }, + ) + + 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( + "caller", + ( + "import * as api from './service.mjs';\nconsole.log(`plain old_api text`);\n", + "import * as api from './service.mjs';\nconsole.log(`${api.new_api()}`);\n", + ), +) +def test_typescript_template_interpolation_ordinary_and_migrated_pass( + tmp_path: Path, caller: str +) -> None: + """Plain template text and migrated interpolation produce no false 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_template_control", + { + "service.mjs": "export function new_api() { return 2; }\n", + "client.mjs": caller, + }, + ) + + 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( + "caller", + ( + "const api = require('./service.cjs');\nconsole.log(api['old_api\\062']());\n", + "const api = require('./service.cjs');\nconsole.log(api['old_api'+'\\062']());\n", + "import * as api from './service.mjs';\nconsole.log(`${(() => { const { ['old_api']: invoke } = api; return invoke(); })()}`);\n", + ), +) +def test_typescript_octal_and_template_real_cli_exit_code( + tmp_path: Path, caller: str +) -> None: + """Reviewer-shaped real CLI cases for both new P2s reject.""" + import subprocess + import sys + + if "service.cjs" in caller: + ext = ".cjs" + service_new = "function new_api2() { return 2; }\nmodule.exports = { new_api2 };\n" + target = "old_api2" + diff = """ +--- a/service.cjs ++++ b/service.cjs +@@ -1,2 +1,2 @@ +-function old_api2() { return 1; } +-module.exports = { old_api2 }; ++function new_api2() { return 2; } ++module.exports = { new_api2 }; +""" + else: + ext = ".mjs" + service_new = "export function new_api() { return 2; }\n" + target = "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; } +""" + workspace = _make_workspace( + tmp_path / "ws_review3_cli", + { + "service" + ext: service_new, + "client" + ext: caller, + }, + ) + proc = subprocess.run( + [ + sys.executable, + "-m", + "loopx.cli", + "anti-entropy", + "verify", + "--diff-file", + "-", + "--basis-json", + '{"target_symbols": ["' + target + '"]}', + "--workspace", + str(workspace), + "--format", + "json", + ], + input=diff.encode("utf-8"), + capture_output=True, + ) + assert proc.returncode == 1, proc.stdout + proc.stderr + + +@pytest.mark.parametrize( + "caller", + ( + "import * as api from './service.mjs';\nconsole.log(`${`pre${(() => { const { ['old_api']: f } = api; return f(); })()}`}`);\n", + "import * as api from './service.mjs';\nconsole.log(`${`a${`b${(() => { const { ['old_api']: f } = api; return f(); })()}`}`}`);\n", + ), +) +def test_typescript_nested_template_interpolation_blocks_retirement( + tmp_path: Path, caller: str +) -> None: + """Nested executable templates must recurse into their own interpolations.""" + diff = """ +--- a/service.mjs ++++ b/service.mjs +@@ -1,2 +1,2 @@ +-export function old_api() { return 'OLD'; } ++export function new_api() { return 'NEW'; } +""" + basis = RetirementBasis(target_symbols=("old_api",)) + workspace = _make_workspace( + tmp_path / "ws_nested_template", + { + "service.mjs": "export function new_api() { return 'NEW'; }\n", + "client.mjs": caller, + }, + ) + + 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_nested_template_plain_text_passes(tmp_path: Path) -> None: + """Nested plain template text is not executable and passes.""" + diff = """ +--- a/service.mjs ++++ b/service.mjs +@@ -1,2 +1,2 @@ +-export function old_api() { return 'OLD'; } ++export function new_api() { return 'NEW'; } +""" + basis = RetirementBasis(target_symbols=("old_api",)) + workspace = _make_workspace( + tmp_path / "ws_nested_plain", + { + "service.mjs": "export function new_api() { return 'NEW'; }\n", + "client.mjs": ( + "import * as api from './service.mjs';\n" + "console.log(`${`plain old_api text`}`);\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_annex_b_octal_trailing_digit_is_not_malformed( + tmp_path: Path, +) -> None: + """\\400 is Annex B \\40 + '0', not a malformed escape requiring AE004.""" + diff = """ +--- a/service.cjs ++++ b/service.cjs +@@ -1,2 +1,2 @@ +-function old_api() { return 1; } +-module.exports = { old_api }; ++function new_api() { return 2; } ++module.exports = { new_api }; +""" + basis = RetirementBasis(target_symbols=("old_api",)) + workspace = _make_workspace( + tmp_path / "ws_annex_b_octal", + { + "service.cjs": "function new_api() { return 2; }\nmodule.exports = { new_api };\n", + "client.cjs": "console.log('old_api\\400');\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_template_interpolation_regex_brace_blocks_retirement( + tmp_path: Path, +) -> None: + """A regex literal brace must not truncate an executable template expression.""" + diff = """ +--- a/service.mjs ++++ b/service.mjs +@@ -1,2 +1,2 @@ +-export function old_api() { return 'OLD'; } ++export function new_api() { return 'NEW'; } +""" + caller = ( + "import * as api from './service.mjs';\n" + "console.log(`${ (() => { return /}/.test('x'); })() + " + "(() => { const { ['old_api']: f } = api; return f(); })() }`);\n" + ) + basis = RetirementBasis(target_symbols=("old_api",)) + workspace = _make_workspace( + tmp_path / "ws_template_regex", + { + "service.mjs": "export function new_api() { return 'NEW'; }\n", + "client.mjs": caller, + }, + ) + + 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_template_interpolation_division_passes(tmp_path: Path) -> None: + """A division slash in interpolation is not a regex literal or a reference.""" + diff = """ +--- a/service.mjs ++++ b/service.mjs +@@ -1,2 +1,2 @@ +-export function old_api() { return 'OLD'; } ++export function new_api() { return 'NEW'; } +""" + basis = RetirementBasis(target_symbols=("old_api",)) + workspace = _make_workspace( + tmp_path / "ws_template_division", + { + "service.mjs": "export function new_api() { return 'NEW'; }\n", + "client.mjs": ( + "import * as api from './service.mjs';\n" + "console.log(`${ 1 / 2 }`);\n" + ), + }, + ) + + receipt = evaluate_diff_hygiene(diff, basis, workspace=workspace) + assert receipt.verdict == "passed", f"findings: {receipt.findings}" + assert receipt.retired_symbols == ("old_api",)