Orchestrata Ultimate › Plans › rename-engine-briefs › BRIEF-B3

BRIEF-B3 — Rename-plan compiler, atomic executor, residue reporter, CLI, volk tool

Source: plans/rename-engine-briefs/BRIEF-B3.md at 8a2cb42ad · last changed 2026-07-27 · first committed 2026-07-27 · 325 lines.

Prose pending.

Plan text

BRIEF-B3 — Rename-plan compiler, atomic executor, residue reporter, CLI, volk tool

Spec: specs/109-rename-engine/spec.md · Design: plans/rename-engine-design.md Branch: feat/rename-engine · Agent: Sonnet builder Depends on: B1 (models.py, lsp_client.py, workspace.py, symbol_rename.py) and B2 (scanner.py, residue.py, applier.py, string_refs.py, module_move.py). All contracts are frozen in the spec — you may code against them before those briefs land.


1. Context

We are building a deterministic rename engine so that mechanical renames stop half-applying (a gw→slot rename once missed four Redis slot parsers → 54 test failures). B1 produces type-resolved edit plans from a real basedpyright langserver; B2 provides the over-matching candidate oracle, the residue classifier, and the atomic applier.

You own the top layer: the declarative plan, the transaction that makes a multi-entry wave all-or-nothing, the machine-readable residue report that an LLM reads for judgment, the CLI, and the volk tool class.

Two things you must internalise from plans/rename-engine-design.md:


2. Files you own (create these; touch nothing else)

src/orchestrata_ultimate/refactor/rename/plan.py
src/orchestrata_ultimate/refactor/rename/executor.py
src/orchestrata_ultimate/refactor/rename/report.py
src/orchestrata_ultimate/refactor/rename/tool.py
scripts/rename_engine.py
tests/refactor/rename/test_plan.py
tests/refactor/rename/test_executor.py
tests/refactor/rename/test_report.py
tests/refactor/rename/test_tool.py
tests/scripts/test_rename_engine_cli.py

tests/refactor/rename/conftest.py is B1's file — define any extra fixture locally in your own module rather than editing it.


3. Contracts

3.1 plan.py

@dataclass(frozen=True, slots=True)
class SymbolEntry:  module: str; name: str; new_name: str; kind: str = "auto"

@dataclass(frozen=True, slots=True)
class ModuleEntry:  old: str; new: str

@dataclass(frozen=True, slots=True)
class StringEntry:  old: str; new: str; kind: str = "exact"   # path|dotted|exact

@dataclass(frozen=True, slots=True)
class RenamePlan:
    version: int
    repo_root: Path
    symbols: tuple[SymbolEntry, ...] = ()
    modules: tuple[ModuleEntry, ...] = ()
    strings: tuple[StringEntry, ...] = ()
    keep_patterns: tuple[str, ...] = ()
    def validate(self) -> tuple[str, ...]: ...   # () == valid

def load_plan(path: Path) -> RenamePlan: ...     # .toml via tomllib, .json via json

TOML and JSON use identical keys (spec § Rename plan schema). load_plan dispatches on suffix; an unknown suffix raises PlanValidationError.

validate() returns human-readable errors, each naming the offending collection and index, e.g. "symbols[2]: 'module' is required — a bare symbol name is ambiguous":

3.2 executor.py

class PlanExecutor:
    def __init__(self, repo_root: Path, source_root: Path,
                 server_factory: Callable[[], PyrightLangServer],
                 scanner: CandidateScanner,
                 classifier: ResidueClassifier,
                 preflight: ResolutionPreflight,
                 applier: EditApplier,
                 mover_factory: Callable[[], ModuleMover],
                 string_rewriter: StringRefRewriter,
                 tracer: Tracer | None = None) -> None: ...
    def execute(self, plan: RenamePlan, *, apply: bool = False) -> tuple[RenameOutcome, ...]: ...

Every collaborator is constructor-injected — that is what makes the executor testable without mocks.

execute() follows plans/rename-engine-design.md §3.3 exactly:

  1. plan.validate() — non-empty ⇒ raise PlanValidationError naming every error. Nothing else runs (REQ-109-035).
  2. Build the workspace config root and start one langserver session (context-managed, always reaped).
  3. Plan phase — for every entry, in order symbols → modules → strings:
    • scanner.scan(...) → candidates
    • preflight.check(...) → False ⇒ record the reasons, status ABORTED, stop and write nothing (REQ-109-009/010/011)
    • compute the entry's WorkspaceEditPlan
    • classifier.classify(...) → residue
    • any UNRESOLVED residue ⇒ status ABORTED, stop and write nothing (REQ-109-020 / REQ-109-107)
  4. If apply=False ⇒ every outcome is DRY_RUN; return. Nothing written (REQ-109-036).
  5. Apply phase (transactional across the whole plan, not per entry):
    • applier.snapshot() the union of every file any plan touches
    • apply each plan in order; module moves go through ModuleMover.execute
    • ast.parse every touched file
    • any failure ⇒ applier.restore(...), undo any module move, every outcome becomes ROLLED_BACK, re-raise as ExecutionError (REQ-109-037/038/111)
  6. Return one RenameOutcome per entry.

Guarantee to state and test: either every entry lands, or the tree is byte-identical to how it started.

3.3 report.py

class ResidueReporter:
    def __init__(self, tracer: Tracer | None = None) -> None: ...
    def render(self, outcomes: Sequence[RenameOutcome], fmt: str = "json") -> str: ...

fmt ∈ {"json", "text"}.

json is the machine-readable contract an LLM consumes (REQ-109-021):

{
  "schema": "rename-residue/1",
  "status": "dry_run",
  "summary": {"entries": 3, "files_changed": 25, "edits": 70,
              "residue": 18, "blocking": 0},
  "outcomes": [
    {"target": "orchestrata_ultimate.knowledge_graph.config:Neo4jConfig",
     "status": "dry_run",
     "files_changed": 25, "edits": 70,
     "residue": [
       {"path": "src/.../config.py", "line": 37, "column": 12,
        "text": "# No explicit database=: Neo4jConfig resolves KG_DATABASE",
        "reason": "comment", "detail": ""}
     ],
     "errors": []}
  ]
}

Paths are repo-relative POSIX, lines are 1-based in the report (0-based internally — convert at the boundary and say so in the docstring). Sort residue by (path, line, column) so the report is diffable. text is a JSON-safe copy of the source line.

text format is a compact human summary: one line per residue item as path:line:col [reason] text, grouped by reason, with a blocking-count banner.

3.4 tool.py — the volk tool (built, tested, NOT registered)

class RefactorRenameTool(BaseTool):
    @property
    def metadata(self) -> ToolMetadata: ...
    async def execute(self, arguments: dict[str, Any]) -> ToolResult: ...
    @classmethod
    def create(cls, *, repo_root: Path,
               trace_config: TraceConfig | None = None) -> RefactorRenameTool: ...

def register_refactor_rename_tool(
    registry: ToolRegistry, *, repo_root: Path,
    trace_config: TraceConfig | None = None) -> RefactorRenameTool: ...

Follow the exact shape of src/orchestrata_ultimate/tools/search.py (ToolSearchTool.create + register_search_tool) — that is the house pattern.

Metadata: name="refactor_rename", category=ToolCategory.FILE, requires_approval=True (REQ-109-043), priority=ToolPriority.P1.

Arguments: {"plan_path": str} or inline {"symbols": [...], "modules": [...], "strings": [...], "keep_patterns": [...]}, plus "apply": bool = False.

ToolResult.output is ResidueReporter.render(..., fmt="json"). execute never raises (REQ-109-044) — every failure becomes ToolResult(success=False, error=...). Reuse the LSPError.to_result bridge pattern from tools/lsp_tool.py:114 where it fits.

register_refactor_rename_tool is defined but NOT called anywhere in this brief. Wiring it into src/orchestrata_ultimate/agents/tool_registry.py is a separate, later step — that file is off-limits here (see §6). Ship the function so the wiring step is a one-liner.

3.5 scripts/rename_engine.py — the production CLI seam

rename_engine.py symbol --module M --name N --new NEW [--apply] [--format json|text]
rename_engine.py module --old A.B --new C.D          [--apply] [--format ...]
rename_engine.py plan   PLAN.toml                     [--apply] [--format ...]
rename_engine.py report PLAN.toml                     # always dry-run

4. Test plan (TDD — write first, watch fail, then implement)

Real tests only. No mocks, no monkeypatch, no skips, function-scope fixtures, no xdist. Executor tests build a real fixture package on tmp_path and drive a real langserver; CLI tests invoke the script via subprocess.run([sys.executable, ...]) against a tmp_path repo.

To test rollback without mocking, inject failure through real means: a plan entry whose edit produces genuinely invalid Python (so the post-apply ast.parse gate fires). That is a real failure, not a fake.

Test ID Behaviour
test_load_plan_toml TST-109-011 a TOML plan round-trips to the expected RenamePlan
test_load_plan_json_identical TST-109-011 the equivalent JSON yields an equal RenamePlan
test_validate_rejects_bare_symbol TST-109-011 a symbols entry without module → error naming symbols[i]
test_validate_rejects_bad_identifier TST-109-011 new_name="2bad" rejected
test_validate_rejects_chained_entries TST-109-011 entry A's new == entry B's old → rejected as order-dependent
test_validate_runs_before_execution TST-109-011 an invalid plan with apply=True writes nothing
test_executor_dry_run_writes_nothing TST-109-013 tree byte-identical after apply=False; outcomes are DRY_RUN
test_executor_applies_multi_entry_plan TST-109-011 2 symbols + 1 string all land; outcomes APPLIED
test_executor_orders_symbols_before_modules TST-109-011 a plan renaming a symbol in a module that is also moved lands correctly
test_executor_aborts_on_blocking_residue TST-109-007 an UNRESOLVED residue ⇒ status ABORTED, tree byte-identical
test_executor_aborts_on_failed_preflight TST-109-005 preflight failure ⇒ ABORTED, tree byte-identical
test_executor_rollback_is_byte_identical TST-109-012 an entry producing invalid Python ⇒ every file restored byte-for-byte, status ROLLED_BACK
test_executor_reuses_one_server_session TST-109-011 a 3-entry plan constructs exactly one server (assert via an injected counting factory — a real callable, not a mock)
test_report_json_schema TST-109-011 JSON has schema, summary, outcomes; residue sorted; lines 1-based; paths repo-relative POSIX
test_report_text_groups_by_reason TST-109-011 text format lists each reason with a blocking-count banner
test_tool_conforms_to_protocol TST-109-014 isinstance(tool, Tool); metadata requires_approval is True; category FILE
test_tool_never_raises_on_bad_args TST-109-014 {}, {"plan_path": "/nope"}, wrong types → ToolResult(success=False), no exception
test_tool_output_is_json_residue_report TST-109-014 json.loads(result.output)["schema"] == "rename-residue/1"
test_tool_defaults_to_dry_run TST-109-014 without apply, the tmp repo is byte-identical
test_cli_dry_run_default TST-109-013 plan P.toml without --apply writes nothing, exits 0, prints the report
test_cli_apply_writes TST-109-013 with --apply the files change and exit is 0
test_cli_exit_1_on_blocking_residue TST-109-013 blocking residue ⇒ exit 1, nothing written
test_cli_exit_2_on_missing_langserver TST-109-013 with a PATH that has no langserver (passed via env= on subprocess.run, not monkeypatch) ⇒ exit 2 with an install hint
test_cli_path_traversal_rejected TST-109-016 a plan whose target escapes repo_root ⇒ refused, nothing written

5. Verification

cd /home/mohamed/workspace/wt-ENGINE
export PYTHONPATH=$(pwd)/src

.venv/bin/python -m pytest tests/refactor/rename/ tests/scripts/test_rename_engine_cli.py \
    -p orchestrata_ultimate.core.threading.pytest_live_observer -v

# the CLI must actually run — it is the production seam
.venv/bin/python scripts/rename_engine.py --help
.venv/bin/python scripts/rename_engine.py report plans/rename-engine-briefs/example-plan.toml || true

uv run black src/orchestrata_ultimate/refactor/rename/ tests/refactor/rename/ scripts/rename_engine.py
uv run ruff check src/orchestrata_ultimate/refactor/rename/ tests/refactor/rename/ scripts/rename_engine.py
.venv/bin/python scripts/serialize_codebase_ast.py check-deps --include-tests
.venv/bin/python scripts/serialize_codebase_ast.py standards
.venv/bin/python scripts/serialize_codebase_ast.py check-traces
.venv/bin/python scripts/test-review.py tests/refactor/rename/
.venv/bin/python scripts/pyright_ratchet.py --max 283

6. Hard rules