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, ...]: ...
- Walk every
*.pyunderroots, honouring an exclude set (__pycache__,*_pb2*.py). - Find whole-word occurrences of
name(\bboundaries) on every line — textual first, so nothing is missed even in files that fail to parse. - Emit one
ResidueItemper occurrence withpath, 0-basedline,column, the stripped source line astext, andreason=ResidueReason.OUT_OF_SCOPEas a placeholder (the classifier assigns the final reason). - A file that fails
ast.parsestill yields its textual hits, each markedUNPARSEABLEindetail; the classifier promotes that to the final reason. - Deterministic ordering: sort by
(path, line, column).
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: ...
- Every path is resolved and asserted to be under
repo_root; anything else raisesPathEscapeErrorbefore any I/O (SR-109-001). - Within a file, apply edits in reverse
(line, column)order so earlier offsets stay valid. Positions are 0-based,endexclusive (LSP convention). - Preserve the file's original newline style and trailing-newline presence.
- After writing,
ast.parseevery touched.pyfile. On failure,restore()the snapshot and raisePostApplyParseError(REQ-109-038 / REQ-109-111). dry_run_diffreturns a unified diff (difflib.unified_diff) and writes nothing.snapshot/restoreare byte-exact — B3's executor uses them for the cross-entry transaction.
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):
- The occurrence is inside a simple
ast.Constantstring literal, and - the literal's entire value equals
old(kind="exact"), or equalsoldas a POSIX path (kind="path"), or equalsoldas a dotted module path (kind="dotted").
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:
- 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, andfrom a import b(the last only whenbis 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. - Provable string/path-literal rewrites for both the POSIX path (
a/b.py) and the dotted path, delegating toStringRefRewriter(REQ-109-024). - Residue for everything else.
execute() with apply=True:
- Refuse if the destination already exists, or if
git status --porcelainshows the source path is dirty. snapshot()every file the plan touches.git mv <old_path> <new_path>as an argument vector, never a shell string (SR-109-004). If the path is untracked,git mvfails — fall back toPath.renameand recordmove_mode="filesystem"in the outcome (REQ-109-110). Create intermediate__init__.pyonly if the destination package directory already exists; otherwise refuse.applier.apply(plan).- 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
- NEW FILES ONLY. Do not edit
scripts/serialize_codebase_ast.py,src/orchestrata_ultimate/tools/registry.py,src/orchestrata_ultimate/agents/tool_registry.py,src/orchestrata_ultimate/refactor/__init__.py,pyrightconfig.json, B1'smodels.py/conftest.py, or any other existing file. Wiring is a separate step. - Do not import from
scripts/. Re-implement the scan. - No mocks, stubs, fakes, monkeypatch (incl.
setenv/delenv), or skips. Real files, realgit. - 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; noprint, nologging.basicConfig. - Composition over inheritance — no class here inherits from anything.
- Subprocesses are argument vectors, never shell strings.
- If the brief is ambiguous or a genuine design fork appears, STOP and report.