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:
- §3.3 ordering rule — execute symbols → modules → strings. Symbol renames are computed against the pre-move tree; moving a file first invalidates every cached position.
- §D7 one session per run — cold pyright analysis on this repo is 8–14s;
each rename is 0.1–0.3s. Construct one
PyrightLangServerfor the whole plan and reuse it. A 20-entry plan that restarts the server costs ~4 minutes instead of ~15 seconds.
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":
versionmust be1.- Every
SymbolEntryneeds non-emptymodule,name,new_name; a missingmoduleis the headline error (REQ-109-031) — renaming by bare name is exactly what this engine exists to prevent. new_namemust be a valid Python identifier;modulea valid dotted path.ModuleEntry.old/newmust be valid dotted paths and differ.StringEntry.kind∈{path, dotted, exact}.- No two entries may rename the same target, and no entry's
newmay collide with another entry'sold(that would make execution order-dependent).
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:
plan.validate()— non-empty ⇒ raisePlanValidationErrornaming every error. Nothing else runs (REQ-109-035).- Build the workspace config root and start one langserver session (context-managed, always reaped).
- Plan phase — for every entry, in order symbols → modules → strings:
scanner.scan(...)→ candidatespreflight.check(...)→False⇒ record the reasons, statusABORTED, stop and write nothing (REQ-109-009/010/011)- compute the entry's
WorkspaceEditPlan classifier.classify(...)→ residue- any
UNRESOLVEDresidue ⇒ statusABORTED, stop and write nothing (REQ-109-020 / REQ-109-107)
- If
apply=False⇒ every outcome isDRY_RUN; return. Nothing written (REQ-109-036). - 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.parseevery touched file- any failure ⇒
applier.restore(...), undo any module move, every outcome becomesROLLED_BACK, re-raise asExecutionError(REQ-109-037/038/111)
- Return one
RenameOutcomeper 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_toolis defined but NOT called anywhere in this brief. Wiring it intosrc/orchestrata_ultimate/agents/tool_registry.pyis 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
--repo-rootdefaults to the git toplevel;--source-rootdefaults tosrc.- Dry run is the default;
--applyis the only way to write (REQ-109-041). - The residue report prints on every path, dry-run or applied.
- Exit codes (REQ-109-039):
0clean ·1refused/aborted (blocking residue or a failed preflight) ·2internal/transport error. argparse, module-levelmain() -> int,if __name__ == "__main__": sys.exit(main()).- Read config via
os.environ.get(...)with defaults only.
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
- NEW FILES ONLY. Do not edit
scripts/serialize_codebase_ast.py,src/orchestrata_ultimate/tools/registry.py,src/orchestrata_ultimate/tools/base.py,src/orchestrata_ultimate/agents/tool_registry.py,src/orchestrata_ultimate/refactor/__init__.py,pyrightconfig.json, or B1/B2 files. Registering the tool is a separate later step — shipregister_refactor_rename_tooluncalled. - No mocks, stubs, fakes, monkeypatch (incl.
setenv/delenv), or skips. Inject environment through an explicitenv=dict onsubprocess.run. Inject failure through genuinely invalid input, not a patched function. - No
pytest-xdist, no-n auto. - Absolute imports only.
os.environ.get("X", default)only. - Function-scope fixtures only.
@traces(reqs=[...])on every public class/method, citing theREQ-109-*ids above. Never rawFR-/ERR-/SR-ids.- Injected
Tracer | Noneeverywhere; noprintoutside the CLI's own stdout writes; nologging.basicConfig. - Composition over inheritance — the only permitted base class in this brief
is
BaseToolforRefactorRenameTool, which is the SPEC-072 framework boundary. - Subprocesses are argument vectors, never shell strings.
- If the brief is ambiguous or a genuine design fork appears, STOP and report.