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

BRIEF-B2 — Candidate oracle, residue classifier, edit applier, module move + string layer

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

Prose pending.

Plan text

BRIEF-B2 — Candidate oracle, residue classifier, edit applier, module move + string layer

Spec: specs/109-rename-engine/spec.md · Design: plans/rename-engine-design.md Branch: feat/rename-engine · Agent: Sonnet builder Depends on: B1's models.py (contract-frozen in the spec — you may code against it before B1 lands; do not modify it).


1. Context

We are building a deterministic rename engine. B1 owns the LSP client that produces a type-resolved WorkspaceEditPlan from basedpyright. You own the safety net and the mechanical layers.

The engine's central design idea — read plans/rename-engine-design.md §3.1 — is two independent oracles:

Oracle Role Property
basedpyright WorkspaceEdit (B1) authority on what to rewrite precise, but silently under-reports
CandidateScanner (yours) authority on what could possibly be affected over-reports, never under-reports

residue = candidates − plan. Every residue item must be explained with a ResidueReason. A residue item that is a resolvable code reference is classified UNRESOLVED and aborts the whole run (REQ-109-020). That single rule is what converts silent under-rename — the failure that once caused 54 test failures — into a loud, pre-write abort.

So your scanner is deliberately the same kind of over-matching name-based logic that scripts/serialize_codebase_ast.py rename uses and that this spec supersedes for execution. Here it is correct precisely because it over-matches.

Do not import from scripts/serialize_codebase_ast.py. Re-implement the scan. That script is not a library and must not be edited or depended on.


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

src/orchestrata_ultimate/refactor/rename/scanner.py
src/orchestrata_ultimate/refactor/rename/residue.py
src/orchestrata_ultimate/refactor/rename/applier.py
src/orchestrata_ultimate/refactor/rename/string_refs.py
src/orchestrata_ultimate/refactor/rename/module_move.py
tests/refactor/rename/test_scanner.py
tests/refactor/rename/test_residue.py
tests/refactor/rename/test_applier.py
tests/refactor/rename/test_string_refs.py
tests/refactor/rename/test_module_move.py

tests/refactor/rename/conftest.py is B1's file — if you need a shared fixture that is not there, define it locally in your own test module rather than editing B1's conftest.


3. Contracts

Import models from orchestrata_ultimate.refactor.rename.models: ResidueItem, ResidueReason, TextEdit, FileEdit, WorkspaceEditPlan, RenameOutcome, RenameStatus, and the RenameEngineError hierarchy.

3.1 scanner.py — the over-matching candidate oracle

class CandidateScanner:
    def __init__(self, tracer: Tracer | None = None) -> None: ...
    def scan(self, name: str, roots: Sequence[Path]) -> tuple[ResidueItem, ...]: ...

3.2 residue.py — the classifier and the abort rule

class ResidueClassifier:
    def __init__(self, tracer: Tracer | None = None) -> None: ...
    def classify(self, candidates: Sequence[ResidueItem],
                 plan: WorkspaceEditPlan,
                 analyzed_roots: Sequence[Path]) -> tuple[ResidueItem, ...]: ...

Returns only the candidates not covered by plan (a candidate is covered when its (path, line, column) falls inside some TextEdit range), each re-emitted with its final ResidueReason:

Reason When
STRING_LITERAL the position is inside an ast.Constant str/bytes node
DOCSTRING inside a module/class/function docstring
COMMENT at or after a # outside any string (use tokenize, not a regex)
OTHER_SYMBOL the position is a real code identifier, and the file does not import the target module — i.e. it binds a different same-named symbol
OUT_OF_SCOPE the file is not under analyzed_roots
UNPARSEABLE the file failed ast.parse
UNRESOLVED the position is a real code identifier in a parsed, in-scope file that DOES reference the target module — a genuine reference the type checker did not rewrite

Use tokenize to distinguish comments/strings reliably; a regex will get f-strings and nested quotes wrong.

UNRESOLVED is the hard-error class. The classifier only labels it; the executor (B3) aborts on it. Provide a helper:

def has_blocking_residue(residue: Sequence[ResidueItem]) -> bool: ...

3.3 applier.py — atomic edit application

class EditApplier:
    def __init__(self, repo_root: Path, tracer: Tracer | None = None) -> None: ...
    def apply(self, plan: WorkspaceEditPlan) -> None: ...
    def dry_run_diff(self, plan: WorkspaceEditPlan) -> str: ...
    def snapshot(self, paths: Sequence[Path]) -> dict[Path, bytes]: ...
    def restore(self, snap: dict[Path, bytes]) -> None: ...

3.4 string_refs.py — provable string rewrites only

class StringRefRewriter:
    def __init__(self, repo_root: Path, tracer: Tracer | None = None) -> None: ...
    def plan(self, old: str, new: str, kind: str,
             keep_patterns: Sequence[str] = (),
             roots: Sequence[Path] = ()) -> tuple[WorkspaceEditPlan, tuple[ResidueItem, ...]]: ...

kind is "path" | "dotted" | "exact".

Rewrite only when provable (REQ-109-027):

Refuse and emit residue (REQ-109-025 / REQ-109-029) when the literal only partially contains old, is part of an f-string / JoinedStr, is built by concatenation or %/.format(), or matches more than one plan entry.

keep_patterns are globs matched against the repo-relative path; matching files are excluded from all string rewriting (REQ-109-028) and their occurrences are reported as residue with detail="keep_pattern".

3.5 module_move.py — atomic module move

class ModuleMover:
    def __init__(self, repo_root: Path, source_root: Path,
                 applier: EditApplier, string_rewriter: StringRefRewriter,
                 scanner: CandidateScanner, tracer: Tracer | None = None) -> None: ...
    def plan(self, old: str, new: str) -> tuple[WorkspaceEditPlan, tuple[ResidueItem, ...]]: ...
    def execute(self, old: str, new: str, *, apply: bool = False) -> RenameOutcome: ...

old/new are dotted module paths (orchestrata_ultimate.a.b).

plan() computes, without writing:

  1. Import rewrites across the analyzed set (REQ-109-023). Handle all three forms: import a.b, import a.b as x, from a.b import x, and from a import b (the last only when b is the moved module, resolved via the AST, not a regex). This repo is absolute-imports-only, so relative-import handling is out of scope — if you encounter one, emit residue rather than guessing.
  2. Provable string/path-literal rewrites for both the POSIX path (a/b.py) and the dotted path, delegating to StringRefRewriter (REQ-109-024).
  3. Residue for everything else.

execute() with apply=True:

  1. Refuse if the destination already exists, or if git status --porcelain shows the source path is dirty.
  2. snapshot() every file the plan touches.
  3. git mv <old_path> <new_path> as an argument vector, never a shell string (SR-109-004). If the path is untracked, git mv fails — fall back to Path.rename and record move_mode="filesystem" in the outcome (REQ-109-110). Create intermediate __init__.py only if the destination package directory already exists; otherwise refuse.
  4. applier.apply(plan).
  5. Any failure ⇒ restore the snapshot, move the file back, return RenameStatus.ROLLED_BACK (REQ-109-026).

With apply=False return RenameStatus.DRY_RUN and touch nothing.


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

Real tests only. No mocks, no monkeypatch, no skips, function-scope fixtures, no xdist. Module-move tests use a real temp git repo (git init under tmp_path, git -c user.email=... -c user.name=... commit) — not a fake.

Test ID Behaviour
test_scanner_finds_all_textual_occurrences TST-109-006 code, docstring, comment and string occurrences are ALL returned (over-matching is the point)
test_scanner_whole_word_only TST-109-006 Neo4jConfigExtra is not a candidate for Neo4jConfig
test_scanner_survives_unparseable_file TST-109-006 a file with a syntax error still yields its textual hits
test_scanner_deterministic_order TST-109-006 two runs return identical tuples
test_classifier_assigns_string_literal TST-109-006 occurrence inside x = "Neo4jConfig" → STRING_LITERAL
test_classifier_assigns_docstring TST-109-006 occurrence in a docstring → DOCSTRING
test_classifier_assigns_comment TST-109-006 occurrence after # → COMMENT; an # inside a string is NOT a comment
test_classifier_assigns_other_symbol TST-109-006 file defining its own same-named class, not importing the target → OTHER_SYMBOL
test_classifier_flags_unresolved_reference TST-109-007 file that imports the target module and uses the name, absent from the plan → UNRESOLVED; has_blocking_residue is True
test_classifier_covered_positions_excluded TST-109-006 positions inside plan edits are not residue
test_applier_reverse_order_multi_edit TST-109-012 3 edits on one line all land correctly
test_applier_preserves_newline_style TST-109-012 a CRLF file stays CRLF; no trailing newline added
test_applier_rejects_path_escape TST-109-016 a plan targeting /etc/passwd raises PathEscapeError, nothing written
test_applier_rollback_on_parse_failure TST-109-012 an edit that produces invalid Python restores every file byte-identically and raises
test_dry_run_writes_nothing TST-109-012 file mtimes and bytes unchanged after dry_run_diff
test_string_rewrite_whole_literal_only TST-109-010 "a/b.py" rewritten; "prefix-a/b.py-suffix" refused as residue
test_string_rewrite_refuses_fstring TST-109-009 f"{x}/a/b.py" → residue, not rewritten
test_string_rewrite_honours_keep_patterns TST-109-010 a file matching keep_patterns is untouched and reported
test_module_move_git_mv_preserves_history TST-109-008 real temp git repo: after move, git log --follow on the new path shows the original commit
test_module_move_rewrites_all_import_forms TST-109-008 import a.b, import a.b as x, from a.b import y, from a import b all rewritten
test_module_move_untracked_falls_back TST-109-008 untracked file moves via filesystem; outcome records move_mode="filesystem"
test_module_move_rollback_on_failure TST-109-008 injected apply failure leaves the tree byte-identical and the file at its ORIGINAL path
test_module_move_refuses_existing_destination TST-109-008 destination exists → REFUSED, nothing written
test_module_move_dry_run_writes_nothing TST-109-008 apply=False leaves the tree byte-identical

5. Verification

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

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

uv run black src/orchestrata_ultimate/refactor/rename/ tests/refactor/rename/
uv run ruff check src/orchestrata_ultimate/refactor/rename/ tests/refactor/rename/
.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