Orchestrata Ultimate / Specs / 109-rename-engine

109-rename-engine: Deterministic LSP-Grade Rename Engine

A deterministic rename engine: it renames Python symbols by type resolution (not by name matching), moves modules atomically, executes declarative rename plans, and emits a machine-readable residue report of every occurrence it matched but deliberately did not rewrite.

At a glance

Item Value
Spec directory specs/109-rename-engine/
Status line in the spec Draft
Requirement IDs defined 62
With a @traces citation in src/ 0 (0.0%)
With a @traces citation in tests/ 0 (0.0%)
Tracked files 2
RTM page 109-rename-engine

Reading this spec

This spec defines a deterministic rename engine that renames Python symbols by type resolution rather than by name matching. The motivating incident is stated in the text: LLM agents half-apply mechanical renames, and a gw to slot label rename that missed four Redis slot parsers produced 54 test failures. The existing tooling cannot prevent this, because the in-repo AST rename matches name strings and cannot tell two classes with the same name apart, and the LSP tool of spec 065 has no JSON-RPC layer. The one piece with the right semantics is the fail-closed type-scoped method rename, and this spec generalizes that contract to every rename kind.

The engine has three capabilities. Symbol rename uses a real basedpyright-langserver textDocument/rename round trip and disambiguates same-named symbols by defining module. Module move runs git mv, rewrites every absolute import and rewrites string and path literals it can prove, refusing on ambiguity. Rename plan executes a declarative TOML or JSON plan atomically. The design treats the language server's WorkspaceEdit as the authority on what to rewrite and an independent name-based scan as the candidate oracle; the difference between the two is a machine-readable residue report of every occurrence matched but deliberately not rewritten. A residue item that is a resolvable code reference is a hard error that aborts the rename. Non-goals are other languages, semantic renaming inside Prolog rule bases and Cypher strings (reported as residue), and replacing the existing AST tool.

Five user stories cover renaming one of two same-named classes, the engine refusing rather than half-applying, moving a module atomically, executing a plan, and a volk renaming through the tool registry. The 62 requirement IDs (REQ-109-NNN) include: the engine opens the defining document before prepareRename; it aborts when cross-file candidates exist but the edit touches only the defining file; modules move with git mv; a non-provable occurrence is residue and never rewritten; execution snapshots, applies, verifies and restores on failure; execute never raises and returns a failed ToolResult; an unknown target refuses and lists near-miss candidates; and a plan validation failure refuses before execution, naming the entry index.

No code module cites this spec, and its status line reads Draft. The requirements traceability matrix lists every ID. It tracks 2 files. No src/ symbol cites any of the 62 requirements, so measured trace coverage is 0.0%, and in tests/, 0 requirements are cited by a test-side @traces. All 62 requirements have no citing symbol; untraced means no @traces names the ID, and the behaviour may still exist in code that was never annotated.

Requirements

Checkbox is the state recorded in the requirements checklist; Traced by is the count of src/ symbols whose @traces names the ID.

ID Done Requirement Traced by
REQ-109-012 no The engine SHALL textDocument/didOpen the defining document before prepareRename none
REQ-109-013 no A null prepareRename result SHALL refuse the rename rather than proceed none
REQ-109-014 no The engine SHALL normalize both changes and documentChanges WorkspaceEdit shapes none
REQ-109-015 no A rename target SHALL be resolved by defining module plus symbol name none
REQ-109-016 no A same-named symbol defined in a different module SHALL never be edited none
REQ-109-009 no The engine SHALL verify the langserver resolves the target module to the same on-disk file it opened none
REQ-109-010 no The engine SHALL abort with UNRESOLVED_IMPORTS when an analyzed file's import of the target module fails to resolve none
REQ-109-011 no The engine SHALL abort when cross-file candidates exist but the WorkspaceEdit contains only defining-file edits none
REQ-109-017 no An independent occurrence scan SHALL produce the candidate set none
REQ-109-018 no Every candidate absent from the plan SHALL be assigned exactly one residue reason none
REQ-109-019 no Classification SHALL distinguish string literals, docstrings, comments, other-symbol bindings, out-of-scope files, and unparseable files none
REQ-109-020 no A resolvable code reference absent from the plan SHALL be classified UNRESOLVED and abort the run none
REQ-109-021 no The residue report SHALL be machine-readable with path, line, column, text, reason, and detail none
REQ-109-036 no Dry run SHALL be the default; writing SHALL require explicit opt-in none
REQ-109-022 no The engine SHALL move the file with git mv, recording any filesystem-move fallback none
REQ-109-023 no The engine SHALL rewrite every absolute import of the old dotted path none
REQ-109-024 no The engine SHALL rewrite string literals that provably denote the moved module none
REQ-109-025 no The engine SHALL refuse partial, concatenated, f-string, or multiply-matching literals none
REQ-109-026 no A module move SHALL be atomic — all edits land or none do none
REQ-109-027 no A string rename SHALL rewrite only whole-token literal matches under the declared kind none
REQ-109-028 no keep_patterns globs SHALL exclude files from all string rewriting none
REQ-109-029 no A non-provable occurrence SHALL be emitted as residue, never rewritten none
REQ-109-030 no The engine SHALL load and validate a plan from TOML or JSON with identical keys none
REQ-109-031 no Each symbols entry SHALL require module, name, and new_name none
REQ-109-032 no Each modules entry SHALL require old and new dotted paths none
REQ-109-033 no Each strings entry SHALL require old, new, and kind none
REQ-109-034 no keep_patterns SHALL be honoured by every layer none
REQ-109-035 no The whole plan SHALL validate before any entry executes, naming the offending entry index none
REQ-109-037 no Execution SHALL snapshot, apply, verify, and restore on failure none
REQ-109-038 no Every touched Python file SHALL parse after applying, else roll back none
REQ-109-039 no The engine SHALL use exit code 0 for clean, 1 for refused/aborted, 2 for internal error none
REQ-109-040 no scripts/rename_engine.py SHALL expose symbol, module, plan, and report subcommands none
REQ-109-041 no Every mutating subcommand SHALL be dry-run unless --apply is passed, and print residue regardless none
REQ-109-042 no RefactorRenameTool SHALL conform to the SPEC-072 BaseTool contract with a JSON RenameOutcome output none
REQ-109-043 no The tool metadata SHALL set requires_approval=True and category FILE none
REQ-109-044 no execute SHALL never raise; failures become ToolResult(success=False, error=...) none
REQ-109-001 no The transport SHALL encode and decode LSP Content-Length framing over stdio none
REQ-109-002 no The transport SHALL correlate requests to responses by integer id with a per-request timeout none
REQ-109-003 no The transport SHALL answer server-to-client requests including workspace/configuration none
REQ-109-004 no The client SHALL run the full initialize/initialized/shutdown/exit lifecycle and reap the server process none
REQ-109-005 no The engine SHALL generate a temporary pyright config root and never mutate the repo's own config none
REQ-109-006 no The generated config SHALL use absolute include, exclude, and extraPaths into the real repo none
REQ-109-007 no The generated config SHALL set typeCheckingMode: "off" and enable no basedpyright based-extras none
REQ-109-008 no The temporary config root SHALL be removed on shutdown, including on error none
REQ-109-045 no Every component SHALL compose an injected Tracer bound to a TraceConfig rather than printing none
REQ-109-101 no A missing basedpyright-langserver SHALL refuse with an install hint and exit 2 none
REQ-109-102 no A langserver that dies mid-session SHALL be reaped, its stderr tail surfaced, with no partial writes none
REQ-109-103 no An LSP request timeout SHALL abort the run and never fall back to name matching none
REQ-109-104 no A null prepareRename SHALL refuse the target and report position and reason none
REQ-109-105 no A target module or symbol that is not found SHALL refuse and list near-miss candidates none
REQ-109-106 no An import-resolution mismatch SHALL abort before writing and name both resolved paths none
REQ-109-107 no Any UNRESOLVED residue SHALL abort the run none
REQ-109-108 no A new name already bound in a target scope SHALL refuse the collision naming file and line none
REQ-109-109 no An ambiguous string literal match SHALL emit residue and not rewrite none
REQ-109-110 no A failed git mv on an untracked path SHALL fall back to a filesystem move and record the mode none
REQ-109-111 no A post-apply parse failure SHALL roll back every file and exit 1 none
REQ-109-112 no A plan validation failure SHALL refuse before execution naming the entry index none
REQ-109-201 no Every path SHALL be resolved and confined to the declared repo_root before any I/O none
REQ-109-202 no The engine SHALL write only inside repo_root and its own temporary config root none
REQ-109-203 no The engine SHALL not enable basedpyright based-extras nor invoke the pyright console script none
REQ-109-204 no git mv and the langserver SHALL be spawned as argument vectors, never through a shell none
REQ-109-205 no Environment SHALL be read via os.environ.get with defaults, never bare os.environ[...] none

Code that traces this spec

No src/ symbol cites a requirement of this spec.

Checklists

checklists/requirements.md

The requirement list above comes from this file; 0 of 62 checkbox items are ticked.

Full spec text

Verbatim from specs/109-rename-engine/spec.md; headings are pushed down two levels.

SPEC-109: Deterministic LSP-Grade Rename Engine

Metadata

File layout note. The originating brief named this file specs/SPEC-109-rename-engine.md. It is placed at specs/109-rename-engine/spec.md instead, matching every other spec in the repo, because scripts/serialize_codebase_ast.py check-traces scans specs/*/checklists/requirements.md. A top-level spec file would make every REQ-109-* trace resolve as dangling.


MOVED 2026-10-20 (MOVE-109): This spec has moved to codetools. The rename engine now lives in codetools.refactor.rename.*. The engine files were copied to src/codetools/refactor/rename/ with decoupling edits (see reports/move-109-report-20261020.md). @traces decorators were stripped because codetools.rtm.traces cites the CODETOOLS spec registry; REQ-109 ids remain historically valid in this spec but declarations leave orchestrata code. A renumbering was not done to avoid updating all REQ-109 references across the codebase.


Overview

What

A deterministic rename engine: it renames Python symbols by type resolution (not by name matching), moves modules atomically, executes declarative rename plans, and emits a machine-readable residue report of every occurrence it matched but deliberately did not rewrite.

Three capabilities:

  1. Symbol rename — type-resolved, via a real basedpyright-langserver textDocument/rename round-trip; disambiguates same-named symbols by defining module.
  2. Module move — git mv plus rewrite of every absolute import, plus rewrite of string/path literals it can prove; refuses on ambiguity.
  3. Rename plan — a declarative TOML/JSON plan executed atomically, producing a residue report for everything left for human/LLM judgment.
Why

LLM agents half-apply mechanical renames. The motivating incident: a gw → slot label rename missed four Redis slot parsers and produced 54 test failures. The existing in-repo tooling cannot prevent this:

Exactly one piece of in-repo code already has the right semantics: _type_scoped_method_rename (serialize_codebase_ast.py), which is fail-closed — if any receiver type is undeterminable it sets blocked=True and refuses --apply. SPEC-109 generalizes that contract to every rename kind and backs it with a real type checker.

How

Drive basedpyright-langserver --stdio (MIT, PyPI-distributed, self-contained — open source, unlike Pylance) over stdio JSON-RPC. Treat its WorkspaceEdit as the authority on what to rewrite, and an independent name-based occurrence scan as the candidate oracle. The difference between the two sets is the residue report. A residue item that is a resolvable code reference is a hard error that aborts the rename — never a silent skip.

Non-goals

Spike Findings (load-bearing — the design depends on these)

A live de-risk spike ran basedpyright 1.39.9 (based on pyright 1.1.411) against both a synthetic fixture and the real 122K-LOC tree. Full transcripts: plans/rename-engine-design.md § Spike. Four findings are normative:

# Finding Consequence
S1 textDocument/rename requires the defining document to be didOpened first. Without it, prepareRename returns null and rename yields same-file-only edits. REQ-109-012
S2 Cross-file references are silently missed when pyright resolves the package through a site-packages editable .pth that points at a different source tree. Measured: 1 file / 3 edits (wrong) vs 5 files / 15 edits (correct). No error is raised — the WorkspaceEdit just comes back short. REQ-109-009/010/011 — the resolution preflight exists solely because of this
S3 A pyrightconfig.json at the workspace root overrides LSP workspace/configuration settings. Supplying python.analysis.extraPaths over the wire did not fix S2. The repo's config pins include: ["src"], so tests/ is invisible and every test-file reference is silently skipped. REQ-109-005/006 — the engine must own a generated config root
S4 With an engine-owned temp config root (absolute include/extraPaths into the real repo), the real-repo rename produced 25 files / 70 edits, covering src/ and tests/, with the same-named twin and all four twin-consuming test modules correctly untouched. Validates the whole architecture

Verdict: PASS. Type-resolved rename with same-name disambiguation works, and the two silent-under-rename failure modes are both detectable and preventable.


User Scenarios

US-109-001: Rename one of two same-named classes

As a maintainer I want to rename Neo4jConfig in knowledge_graph/config.py without touching the unrelated Neo4jConfig in orchestrator/neo4j_audit.py So that a mechanical rename cannot corrupt an unrelated subsystem Acceptance Criteria:

US-109-002: The engine refuses rather than half-applies

As an operator I want the engine to abort when it cannot see the whole picture So that I never get the 54-failing-test outcome Acceptance Criteria:

US-109-003: Move a module atomically

As a developer I want to move a/b.py to c/d.py and have every import follow So that the tree stays import-clean in one step Acceptance Criteria:

US-109-004: Execute a declarative rename plan

As a maintainer running a rename wave I want to declare all symbol/module/string renames in one file and execute it So that the wave is reviewable, repeatable, and atomic Acceptance Criteria:

US-109-005: A volk renames through the tool registry

As a volk I want a refactor_rename tool So that an agent can request a rename and receive the residue for judgment Acceptance Criteria:


Data Models

All models live in refactor/rename/models.py, are frozen dataclasses, and are contract-frozen by this spec so parallel builders can code against them.

class ResidueReason(StrEnum):
    STRING_LITERAL   = "string_literal"    # inside a str/bytes literal
    DOCSTRING        = "docstring"
    COMMENT          = "comment"
    OTHER_SYMBOL     = "other_symbol"      # same name, different definition
    OUT_OF_SCOPE     = "out_of_scope"      # file outside the analyzed set
    UNPARSEABLE      = "unparseable"       # file failed to parse
    UNRESOLVED       = "unresolved"        # HARD ERROR: resolvable ref not rewritten

class RenameStatus(StrEnum):
    DRY_RUN = "dry_run"; APPLIED = "applied"
    REFUSED = "refused"; ABORTED = "aborted"; ROLLED_BACK = "rolled_back"

@dataclass(frozen=True, slots=True)
class SymbolTarget:
    module: str          # dotted defining module, e.g. "orchestrata_ultimate.knowledge_graph.config"
    name: str            # symbol name, e.g. "Neo4jConfig"
    new_name: str
    kind: str = "auto"   # auto|class|function|variable|method

@dataclass(frozen=True, slots=True)
class TextEdit:
    line: int; start_col: int; end_col: int; new_text: str   # 0-based, LSP-native

@dataclass(frozen=True, slots=True)
class FileEdit:
    path: Path; edits: tuple[TextEdit, ...]

@dataclass(frozen=True, slots=True)
class WorkspaceEditPlan:
    files: tuple[FileEdit, ...]
    @property
    def edit_count(self) -> int: ...
    @property
    def file_count(self) -> int: ...

@dataclass(frozen=True, slots=True)
class ResidueItem:
    path: Path; line: int; column: int
    text: str            # the source line, stripped
    reason: ResidueReason
    detail: str = ""

@dataclass(frozen=True, slots=True)
class RenameOutcome:
    target: str
    status: RenameStatus
    plan: WorkspaceEditPlan
    residue: tuple[ResidueItem, ...]
    errors: tuple[str, ...] = ()
    def to_dict(self) -> dict[str, Any]: ...   # machine-readable report
Rename plan schema (TOML; JSON accepted with identical keys)
version = 1
repo_root = "."

[[symbols]]
module   = "orchestrata_ultimate.knowledge_graph.config"
name     = "Neo4jConfig"
new_name = "KnowledgeGraphNeo4jConfig"

[[modules]]
old = "orchestrata_ultimate.orchestrator.redis_state"
new = "orchestrata_ultimate.orchestrator.state.redis_state"

[[strings]]
old = "scripts/old_name.py"
new = "scripts/new_name.py"
kind = "path"            # path | dotted | exact

keep_patterns = ["docs/**", "CHANGELOG.md", "**/*.pl"]

Functional Requirements

FR-001 — LSP transport
FR-002 — Engine-owned workspace configuration
FR-003 — Resolution preflight (anti-silent-under-rename guard)
FR-004 — Type-resolved symbol rename
FR-005 — Candidate oracle and residue classification
FR-006 — Atomic module move
FR-007 — String-reference layer
FR-008 — Rename plan
FR-009 — Atomic execution and rollback
FR-010 — CLI
FR-011 — Volk tool
FR-012 — Observability

Error Conditions

ID REQ Trigger Severity Handling
ERR-109-001 REQ-109-101 basedpyright-langserver not on PATH ERROR Refuse with an install hint; exit 2
ERR-109-002 REQ-109-102 Langserver dies mid-session ERROR Reap, surface stderr tail, exit 2; no partial writes
ERR-109-003 REQ-109-103 LSP request exceeds timeout ERROR Abort the run; never fall back to name matching
ERR-109-004 REQ-109-104 prepareRename returns null WARNING Refuse this target, report position and reason
ERR-109-005 REQ-109-105 Target module/symbol not found ERROR Refuse, list near-miss candidates
ERR-109-006 REQ-109-106 Import resolution mismatch (spike S2) ERROR Abort before writing; name the two resolved paths
ERR-109-007 REQ-109-107 UNRESOLVED residue present ERROR Abort; the residue report is the diagnostic
ERR-109-008 REQ-109-108 New name already bound in a target scope ERROR Refuse the collision, name the file:line
ERR-109-009 REQ-109-109 Ambiguous string literal match WARNING Do not rewrite; emit residue
ERR-109-010 REQ-109-110 git mv fails / path untracked WARNING Fall back to filesystem move; record the mode
ERR-109-011 REQ-109-111 Post-apply ast.parse failure ERROR Roll back every file; exit 1
ERR-109-012 REQ-109-112 Plan validation failure ERROR Refuse before execution, name the entry index

Security Requirements


API Contracts

# refactor/rename/lsp_client.py
class PyrightLangServer:
    def __init__(self, config: LspServerConfig, tracer: Tracer | None = None) -> None: ...
    def __enter__(self) -> PyrightLangServer: ...
    def __exit__(self, *exc: object) -> None: ...
    def open_document(self, path: Path) -> None: ...
    def prepare_rename(self, path: Path, line: int, column: int) -> dict | None: ...
    def rename(self, path: Path, line: int, column: int, new_name: str) -> WorkspaceEditPlan: ...
    def definition(self, path: Path, line: int, column: int) -> list[dict]: ...

# refactor/rename/workspace.py
class WorkspaceConfigBuilder:
    def build(self, repo_root: Path, include: Sequence[str], exclude: Sequence[str]) -> Path: ...
    def cleanup(self) -> None: ...

class ResolutionPreflight:
    def check(self, server: PyrightLangServer, target: SymbolTarget,
              candidates: Sequence[ResidueItem]) -> tuple[bool, tuple[str, ...]]: ...

# refactor/rename/symbol_rename.py
class SymbolRenamer:
    def __init__(self, server, preflight, tracer=None) -> None: ...
    def plan(self, target: SymbolTarget) -> WorkspaceEditPlan: ...

# refactor/rename/scanner.py
class CandidateScanner:
    def scan(self, name: str, roots: Sequence[Path]) -> tuple[ResidueItem, ...]: ...

# refactor/rename/residue.py
class ResidueClassifier:
    def classify(self, candidates, plan: WorkspaceEditPlan,
                 analyzed_roots: Sequence[Path]) -> tuple[ResidueItem, ...]: ...

# refactor/rename/applier.py
class EditApplier:
    def apply(self, plan: WorkspaceEditPlan) -> None: ...      # raises on verify failure
    def dry_run_diff(self, plan: WorkspaceEditPlan) -> str: ...

# refactor/rename/module_move.py
class ModuleMover:
    def plan(self, old: str, new: str) -> tuple[WorkspaceEditPlan, tuple[ResidueItem, ...]]: ...
    def execute(self, old: str, new: str, *, apply: bool = False) -> RenameOutcome: ...

# refactor/rename/plan.py / executor.py
def load_plan(path: Path) -> RenamePlan: ...
class PlanExecutor:
    def execute(self, plan: RenamePlan, *, apply: bool = False) -> tuple[RenameOutcome, ...]: ...

Dependencies

Component Spec / source Purpose
basedpyright (MIT) external, [dependency-groups] dev basedpyright-langserver --stdio — the type-resolution authority
core.tracing.tracer SPEC-100 Injected Tracer / TraceConfig
tools.base / tools.registry SPEC-062 / SPEC-072 BaseTool, ToolResult, registry wiring
refactor/ SPEC-103 Parent package; orthogonal LLM tool-calling agent
tools/lsp_tool.py SPEC-065 Wire dataclasses + URI helpers reused; PythonSymbolIndex rejected
git external git mv for history-preserving module moves

Rejected alternatives (recorded so they are not re-litigated):


Testing Requirements

Real tests only: every LSP test spawns a real basedpyright-langserver against a real fixture package on tmp_path. No mocks, no monkeypatch, no skips, function-scope fixtures, no xdist.

ID REQs Test
TST-109-001 001–004 Transport: framing round-trip, id correlation, timeout raises, workspace/configuration answered, process reaped
TST-109-002 005–008 Generated config root has absolute include/extraPaths; repo pyrightconfig.json byte-identical after a run; temp root removed
TST-109-003 012–016 The motivating case: fixture with two same-named classes + a consumer each; rename one — defining module and its consumer rewritten, twin and twin-consumer untouched
TST-109-004 013, 104 prepareRename null on a non-symbol position → REFUSED, nothing written
TST-109-005 009–011, 106 Resolution guard: fixture whose package resolves to a different tree → aborts with UNRESOLVED_IMPORTS, writes nothing (regression for spike S2)
TST-109-006 017–021 Candidate oracle + classifier: docstring, comment, string-literal and twin occurrences each get the right ResidueReason
TST-109-007 020, 107 A resolvable code reference absent from the plan → UNRESOLVED → run aborts
TST-109-008 022–026 Module move: git mv in a real temp git repo, imports rewritten, atomic
TST-109-009 024–025, 109 Provable path literal rewritten; concatenated/f-string literal refused as residue
TST-109-010 027–029 String layer: whole-token match only; keep_patterns honoured
TST-109-011 030–035, 112 Plan load/validate from TOML and JSON; bare symbol without module rejected with entry index
TST-109-012 036–039, 111 Rollback: injected post-apply parse failure restores every file byte-identically
TST-109-013 040–041 CLI: dry-run default, --apply writes, exit codes 0/1/2
TST-109-014 042–044 Tool: conforms to Tool protocol, requires_approval true, never raises on bad args
TST-109-015 045 Tracer injection: spans emitted through an injected TraceConfig, no stdout writes
TST-109-016 201–205 Security: path traversal rejected; repo config untouched; no shell interpolation

Definition of Done


Revision History

Version Date Author Changes
0.1.0 2026-07-27 Mohamed Initial spec — rename engine, written after a passing live basedpyright spike