"""TDD supercharge tests for ``muse code rename``. Gaps being closed ----------------- - ``-j`` alias for ``--json`` - ``exit_code`` and ``duration_ms`` in ``_RenameResult`` JSON - Unit tests for all 7 private helpers - ``--scope callsites`` coverage - ``--scope all`` round-trip (finds all 3 kinds) - ``async def`` rename - Attribute reference sites (``obj.old_name``) - Multiple tokens on the same line - Max-files warning present in JSON warnings list - Zero-edit-site case (no references found) - ``-n`` / ``-y`` alias verification - Security: null byte / ANSI in new_name and address - Docstring completeness for ``register()`` and ``run()`` Test classes ------------ TestJsonAlias -j alias identical to --json TestJsonEnvelope exit_code, duration_ms in every JSON response TestUnitValidateIdentifier _validate_identifier edge cases TestUnitParseAddress _parse_address edge cases TestUnitLine _line helper TestUnitDedup _dedup helper TestUnitApplyEdits _apply_edits multi-edit, right-to-left, empty TestUnitFindDefinitionSite _find_definition_site: async def, class, missing TestUnitFindReferenceSites _find_reference_sites: imports, callsites, attr TestCLIScopeCallsites --scope callsites TestCLIScopeAll --scope all finds all 3 kinds TestCLIAliases -n and -y aliases TestCLIAttributeRename obj.old_name attribute-reference rename TestCLIMultipleOccurrences multiple tokens same line TestCLIWarnings max-files warning in JSON TestCLINoSites zero edit sites does not crash TestCLISecurity null byte / ANSI in new_name / address TestDocstrings run(), register() doc completeness """ from __future__ import annotations from collections.abc import Mapping import json import pathlib import textwrap import typing import pytest from tests.cli_test_helper import CliRunner, InvokeResult cli = None runner = CliRunner() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _run(root: pathlib.Path, *args: str) -> InvokeResult: return runner.invoke(cli, list(args), env={"MUSE_REPO_ROOT": str(root)}) def _commit(root: pathlib.Path, msg: str = "commit") -> None: r = _run(root, "code", "add", ".") assert r.exit_code == 0, r.output r2 = _run(root, "commit", "-m", msg) assert r2.exit_code == 0, r2.output # --------------------------------------------------------------------------- # Fixture — repo with a range of symbol types # --------------------------------------------------------------------------- @pytest.fixture def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: """Code-domain repo with functions, imports, async def, attribute refs.""" monkeypatch.chdir(tmp_path) monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path)) r = _run(tmp_path, "init", "--domain", "code") assert r.exit_code == 0, r.output # Primary module: sync + async function + class with method (tmp_path / "billing.py").write_text(textwrap.dedent("""\ def compute_total(items: list[int]) -> int: return sum(items) async def fetch_invoice() -> Mapping[str, object]: return {} class Invoice: def compute_total(self, items): return sum(items) * 2 """)) # Caller: imports and uses compute_total (tmp_path / "order.py").write_text(textwrap.dedent("""\ from billing import compute_total def process(items): return compute_total(items) """)) # Caller with attribute reference (tmp_path / "service.py").write_text(textwrap.dedent("""\ class Service: def run(self, inv): return inv.compute_total([1, 2, 3]) """)) # Caller with multiple occurrences on same line (tmp_path / "multi.py").write_text(textwrap.dedent("""\ from billing import compute_total result = compute_total(compute_total([1, 2])) """)) _commit(tmp_path, "initial") return tmp_path # --------------------------------------------------------------------------- # 1. -j alias # --------------------------------------------------------------------------- class TestJsonAlias: def test_j_alias_exits_zero(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "-j") assert r.exit_code == 0, r.output def test_j_alias_emits_valid_json(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "-j") data = json.loads(r.output.strip()) assert isinstance(data, dict) def test_j_alias_has_from_address(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "-j") data = json.loads(r.output) assert "from_address" in data def test_j_alias_same_keys_as_json_flag(self, repo: pathlib.Path) -> None: r1 = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--json", "--dry-run") r2 = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "-j", "--dry-run") d1 = json.loads(r1.output) d2 = json.loads(r2.output) d1.pop("duration_ms", None) d2.pop("duration_ms", None) assert set(d1.keys()) == set(d2.keys()) def test_j_alias_edit_sites_match(self, repo: pathlib.Path) -> None: r1 = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--json", "--dry-run") r2 = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "-j", "--dry-run") assert json.loads(r1.output)["total_edit_sites"] == json.loads(r2.output)["total_edit_sites"] # --------------------------------------------------------------------------- # 2. JSON envelope: exit_code + duration_ms # --------------------------------------------------------------------------- class TestJsonEnvelope: def test_has_exit_code(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--json") data = json.loads(r.output) assert "exit_code" in data def test_exit_code_is_zero(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--json") data = json.loads(r.output) assert data["exit_code"] == 0 def test_has_duration_ms(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--json") data = json.loads(r.output) assert "duration_ms" in data def test_duration_ms_is_float(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--json") data = json.loads(r.output) assert isinstance(data["duration_ms"], float) def test_duration_ms_positive(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--json") data = json.loads(r.output) assert data["duration_ms"] > 0 def test_typed_dict_has_exit_code_field(self) -> None: from muse.cli.commands.rename import _RenameResult hints = typing.get_type_hints(_RenameResult) assert "exit_code" in hints def test_typed_dict_has_duration_ms_field(self) -> None: from muse.cli.commands.rename import _RenameResult hints = typing.get_type_hints(_RenameResult) assert "duration_ms" in hints def test_json_applied_also_has_exit_code(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--json", "--yes") data = json.loads(r.output) assert data["exit_code"] == 0 def test_json_applied_has_duration_ms(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--json", "--yes") data = json.loads(r.output) assert "duration_ms" in data # --------------------------------------------------------------------------- # 3. Unit — _validate_identifier # --------------------------------------------------------------------------- class TestUnitValidateIdentifier: def _v(self, name: str, force: bool = False) -> str | None: from muse.cli.commands.rename import _validate_identifier return _validate_identifier(name, force) def test_valid_name_returns_none(self) -> None: assert self._v("new_name") is None def test_empty_name_returns_error(self) -> None: assert self._v("") is not None def test_name_too_long_returns_error(self) -> None: assert self._v("a" * 201) is not None def test_invalid_identifier_returns_error(self) -> None: assert self._v("123bad") is not None def test_hyphen_is_invalid(self) -> None: assert self._v("my-func") is not None def test_dunder_without_force_returns_error(self) -> None: assert self._v("__init__") is not None def test_dunder_with_force_returns_none(self) -> None: assert self._v("__init__", force=True) is None def test_leading_underscore_is_valid(self) -> None: assert self._v("_private") is None def test_all_caps_is_valid(self) -> None: assert self._v("CONSTANT") is None def test_unicode_letter_start_is_invalid(self) -> None: # _IDENT_RE only accepts ASCII identifiers result = self._v("café") # Either None (if unicode allowed) or error — just verify no exception # The regex is ASCII-only so this should be an error assert result is not None # --------------------------------------------------------------------------- # 4. Unit — _parse_address # --------------------------------------------------------------------------- class TestUnitParseAddress: def _p(self, address: str) -> tuple[str, list[str]] | None: from muse.cli.commands.rename import _parse_address return _parse_address(address) def test_simple_address(self) -> None: result = self._p("billing.py::compute_total") assert result == ("billing.py", ["compute_total"]) def test_method_address(self) -> None: result = self._p("billing.py::Invoice.compute_total") assert result == ("billing.py", ["Invoice", "compute_total"]) def test_no_double_colon_returns_none(self) -> None: assert self._p("billing.py:compute_total") is None def test_empty_file_returns_none(self) -> None: assert self._p("::compute_total") is None def test_empty_symbol_returns_none(self) -> None: assert self._p("billing.py::") is None def test_empty_part_in_dotted_returns_none(self) -> None: assert self._p("billing.py::Invoice..method") is None def test_nested_path(self) -> None: result = self._p("src/billing/core.py::compute_total") assert result == ("src/billing/core.py", ["compute_total"]) # --------------------------------------------------------------------------- # 5. Unit — _line # --------------------------------------------------------------------------- class TestUnitLine: def _line(self, lines: list[str], lineno: int) -> str: from muse.cli.commands.rename import _line return _line(lines, lineno) def test_first_line(self) -> None: assert self._line(["a", "b", "c"], 1) == "a" def test_last_line(self) -> None: assert self._line(["a", "b", "c"], 3) == "c" def test_out_of_range_high(self) -> None: assert self._line(["a", "b"], 5) == "" def test_out_of_range_zero(self) -> None: assert self._line(["a", "b"], 0) == "" def test_out_of_range_negative(self) -> None: assert self._line(["a", "b"], -1) == "" def test_empty_list(self) -> None: assert self._line([], 1) == "" # --------------------------------------------------------------------------- # 6. Unit — _dedup # --------------------------------------------------------------------------- class TestUnitDedup: def _site(self, line: int, col_start: int, kind: str = "reference") -> Mapping[str, object]: return { "file": "f.py", "line": line, "col_start": col_start, "col_end": col_start + 3, "kind": kind, "context": "", } def test_no_duplicates_unchanged(self) -> None: from muse.cli.commands.rename import _dedup sites = [self._site(1, 0), self._site(2, 0)] assert len(_dedup(sites)) == 2 # type: ignore[arg-type] def test_exact_duplicate_removed(self) -> None: from muse.cli.commands.rename import _dedup sites = [self._site(1, 0), self._site(1, 0)] assert len(_dedup(sites)) == 1 # type: ignore[arg-type] def test_same_line_different_col_kept(self) -> None: from muse.cli.commands.rename import _dedup sites = [self._site(1, 0), self._site(1, 10)] assert len(_dedup(sites)) == 2 # type: ignore[arg-type] def test_preserves_order(self) -> None: from muse.cli.commands.rename import _dedup s1, s2, s3 = self._site(1, 0), self._site(2, 0), self._site(3, 0) result = _dedup([s1, s2, s3]) # type: ignore[arg-type] assert result[0]["line"] == 1 assert result[2]["line"] == 3 def test_empty_list(self) -> None: from muse.cli.commands.rename import _dedup assert _dedup([]) == [] # --------------------------------------------------------------------------- # 7. Unit — _apply_edits # --------------------------------------------------------------------------- class TestUnitApplyEdits: def _site(self, line: int, col_start: int, col_end: int, kind: str = "reference") -> Mapping[str, object]: return { "file": "f.py", "line": line, "col_start": col_start, "col_end": col_end, "kind": kind, "context": "", } def test_single_edit(self) -> None: from muse.cli.commands.rename import _apply_edits source = "def foo():\n pass\n" site = self._site(1, 4, 7, "definition") result = _apply_edits(source, [site], "bar") # type: ignore[arg-type] assert "def bar():" in result def test_empty_sites_unchanged(self) -> None: from muse.cli.commands.rename import _apply_edits source = "def foo():\n pass\n" assert _apply_edits(source, [], "bar") == source def test_two_edits_same_line_right_to_left(self) -> None: from muse.cli.commands.rename import _apply_edits # "foo(foo())" — two occurrences of "foo" on line 1 source = "foo(foo())\n" s1 = self._site(1, 0, 3) # first "foo" s2 = self._site(1, 4, 7) # second "foo" result = _apply_edits(source, [s1, s2], "bar") # type: ignore[arg-type] assert result == "bar(bar())\n" def test_edit_preserves_trailing_newline(self) -> None: from muse.cli.commands.rename import _apply_edits source = "foo = 1\n" site = self._site(1, 0, 3) result = _apply_edits(source, [site], "baz") # type: ignore[arg-type] assert result.endswith("\n") def test_longer_replacement(self) -> None: from muse.cli.commands.rename import _apply_edits source = "foo()\n" site = self._site(1, 0, 3) result = _apply_edits(source, [site], "compute_total_invoice") # type: ignore[arg-type] assert "compute_total_invoice()" in result def test_shorter_replacement(self) -> None: from muse.cli.commands.rename import _apply_edits source = "compute_total_invoice()\n" # col_end = 20 (len of "compute_total_invoic") — just rename first bit site = self._site(1, 0, 21) result = _apply_edits(source, [site], "f") # type: ignore[arg-type] assert result.startswith("f()") # --------------------------------------------------------------------------- # 8. Unit — _find_definition_site # --------------------------------------------------------------------------- class TestUnitFindDefinitionSite: def test_finds_function(self) -> None: from muse.cli.commands.rename import _find_definition_site source = "def compute_total(items):\n return sum(items)\n" site = _find_definition_site(source, "billing.py", ["compute_total"]) assert site is not None assert site["kind"] == "definition" assert site["line"] == 1 def test_finds_async_function(self) -> None: from muse.cli.commands.rename import _find_definition_site source = "async def fetch_invoice():\n return {}\n" site = _find_definition_site(source, "billing.py", ["fetch_invoice"]) assert site is not None assert site["kind"] == "definition" assert site["line"] == 1 def test_finds_class(self) -> None: from muse.cli.commands.rename import _find_definition_site source = "class Invoice:\n pass\n" site = _find_definition_site(source, "billing.py", ["Invoice"]) assert site is not None assert site["kind"] == "definition" def test_finds_method_scoped_to_class(self) -> None: from muse.cli.commands.rename import _find_definition_site source = textwrap.dedent("""\ class Invoice: def compute_total(self, items): return sum(items) """) site = _find_definition_site(source, "billing.py", ["Invoice", "compute_total"]) assert site is not None assert site["line"] == 2 def test_returns_none_when_not_found(self) -> None: from muse.cli.commands.rename import _find_definition_site source = "def other():\n pass\n" assert _find_definition_site(source, "billing.py", ["compute_total"]) is None def test_returns_none_for_syntax_error(self) -> None: from muse.cli.commands.rename import _find_definition_site source = "def (\n" assert _find_definition_site(source, "billing.py", ["compute_total"]) is None def test_col_start_points_at_name(self) -> None: from muse.cli.commands.rename import _find_definition_site source = "def compute_total():\n pass\n" site = _find_definition_site(source, "billing.py", ["compute_total"]) assert site is not None # "def " = 4 chars, so col_start should be 4 assert site["col_start"] == 4 assert source.splitlines()[0][site["col_start"]:site["col_end"]] == "compute_total" def test_async_col_start_points_at_name(self) -> None: from muse.cli.commands.rename import _find_definition_site source = "async def fetch_invoice():\n return {}\n" site = _find_definition_site(source, "billing.py", ["fetch_invoice"]) assert site is not None # "async def " = 10 chars assert site["col_start"] == 10 assert source.splitlines()[0][site["col_start"]:site["col_end"]] == "fetch_invoice" # --------------------------------------------------------------------------- # 9. Unit — _find_reference_sites # --------------------------------------------------------------------------- class TestUnitFindReferenceSites: def test_finds_import_site(self) -> None: from muse.cli.commands.rename import _find_reference_sites source = "from billing import compute_total\n" sites = _find_reference_sites(source, "order.py", "compute_total", include_imports=True, include_callsites=False) assert any(s["kind"] == "import" for s in sites) def test_import_disabled(self) -> None: from muse.cli.commands.rename import _find_reference_sites source = "from billing import compute_total\n" sites = _find_reference_sites(source, "order.py", "compute_total", include_imports=False, include_callsites=False) assert sites == [] def test_finds_call_site(self) -> None: from muse.cli.commands.rename import _find_reference_sites source = "result = compute_total([1, 2, 3])\n" sites = _find_reference_sites(source, "order.py", "compute_total", include_imports=False, include_callsites=True) assert any(s["kind"] == "reference" for s in sites) def test_finds_attribute_access(self) -> None: from muse.cli.commands.rename import _find_reference_sites source = "x = obj.compute_total([1, 2])\n" sites = _find_reference_sites(source, "service.py", "compute_total", include_imports=False, include_callsites=True) assert any(s["kind"] == "reference" for s in sites) def test_callsites_disabled(self) -> None: from muse.cli.commands.rename import _find_reference_sites source = "result = compute_total([1, 2, 3])\n" sites = _find_reference_sites(source, "order.py", "compute_total", include_imports=False, include_callsites=False) assert sites == [] def test_returns_empty_on_syntax_error(self) -> None: from muse.cli.commands.rename import _find_reference_sites source = "def (\n" sites = _find_reference_sites(source, "bad.py", "compute_total", include_imports=True, include_callsites=True) assert sites == [] def test_does_not_match_partial_name(self) -> None: from muse.cli.commands.rename import _find_reference_sites source = "result = total_compute_total([1])\n" # "compute_total" appears as a suffix — word boundary regex should not match # as a call site in ast.Name (AST won't have node.id == "compute_total") sites = _find_reference_sites(source, "order.py", "compute_total", include_imports=False, include_callsites=True) # No ast.Name node with id == "compute_total" — only "total_compute_total" assert all(s["kind"] != "reference" or "total_compute_total" not in s["context"] for s in sites) # --------------------------------------------------------------------------- # 10. CLI — --scope callsites # --------------------------------------------------------------------------- class TestCLIScopeCallsites: def test_callsites_exits_zero(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--scope", "callsites", "--json") assert r.exit_code == 0, r.output def test_callsites_only_reference_kind(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--scope", "callsites", "--json") data = json.loads(r.output) for site in data["edit_sites"]: assert site["kind"] == "reference" def test_callsites_no_definition_kind(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--scope", "callsites", "--json") data = json.loads(r.output) assert not any(s["kind"] == "definition" for s in data["edit_sites"]) def test_callsites_no_import_kind(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--scope", "callsites", "--json") data = json.loads(r.output) assert not any(s["kind"] == "import" for s in data["edit_sites"]) def test_callsites_scope_reflected_in_json(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--scope", "callsites", "--json") data = json.loads(r.output) assert data["scope"] == "callsites" # --------------------------------------------------------------------------- # 11. CLI — --scope all round-trip # --------------------------------------------------------------------------- class TestCLIScopeAll: def test_scope_all_finds_definition(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--scope", "all", "--json") data = json.loads(r.output) assert any(s["kind"] == "definition" for s in data["edit_sites"]) def test_scope_all_finds_import(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--scope", "all", "--json") data = json.loads(r.output) assert any(s["kind"] == "import" for s in data["edit_sites"]) def test_scope_all_finds_reference(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--scope", "all", "--json") data = json.loads(r.output) assert any(s["kind"] == "reference" for s in data["edit_sites"]) # --------------------------------------------------------------------------- # 12. CLI — -n and -y aliases # --------------------------------------------------------------------------- class TestCLIAliases: def test_n_alias_is_dry_run(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--json", "-n") assert r.exit_code == 0, r.output data = json.loads(r.output) assert data["dry_run"] is True def test_n_alias_does_not_write(self, repo: pathlib.Path) -> None: original = (repo / "billing.py").read_text() _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--json", "-n") assert (repo / "billing.py").read_text() == original def test_y_alias_applies_changes(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--json", "-y") assert r.exit_code == 0, r.output content = (repo / "billing.py").read_text() assert "total_sum" in content def test_y_alias_dry_run_is_false(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--json", "-y") data = json.loads(r.output) assert data["dry_run"] is False # --------------------------------------------------------------------------- # 13. CLI — async def rename # --------------------------------------------------------------------------- class TestCLIAsyncDef: def test_async_rename_exits_zero(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "billing.py::fetch_invoice", "get_invoice", "--json") assert r.exit_code == 0, r.output def test_async_rename_finds_definition(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "billing.py::fetch_invoice", "get_invoice", "--json") data = json.loads(r.output) assert any(s["kind"] == "definition" for s in data["edit_sites"]) def test_async_rename_applies_correctly(self, repo: pathlib.Path) -> None: _run(repo, "code", "rename", "billing.py::fetch_invoice", "get_invoice", "--json", "--yes") content = (repo / "billing.py").read_text() assert "async def get_invoice" in content assert "async def fetch_invoice" not in content # --------------------------------------------------------------------------- # 14. CLI — attribute reference sites (obj.old_name) # --------------------------------------------------------------------------- class TestCLIAttributeRename: def test_finds_attribute_reference(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--scope", "callsites", "--json") data = json.loads(r.output) # service.py has inv.compute_total([1, 2, 3]) service_sites = [s for s in data["edit_sites"] if "service" in s["file"]] assert service_sites, "Expected reference sites in service.py" def test_attribute_site_kind_is_reference(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--scope", "callsites", "--json") data = json.loads(r.output) service_sites = [s for s in data["edit_sites"] if "service" in s["file"]] assert all(s["kind"] == "reference" for s in service_sites) def test_attribute_applied_correctly(self, repo: pathlib.Path) -> None: _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--scope", "callsites", "--json", "--yes") content = (repo / "service.py").read_text() assert "inv.total_sum([1, 2, 3])" in content # --------------------------------------------------------------------------- # 15. CLI — multiple occurrences on same line # --------------------------------------------------------------------------- class TestCLIMultipleOccurrences: def test_multiple_sites_found_on_same_line(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--scope", "callsites", "--json") data = json.loads(r.output) multi_sites = [s for s in data["edit_sites"] if "multi" in s["file"]] # multi.py line 2: compute_total(compute_total([1, 2])) line2_sites = [s for s in multi_sites if s["line"] == 2] assert len(line2_sites) >= 2 def test_multiple_applied_correctly(self, repo: pathlib.Path) -> None: _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--scope", "all", "--json", "--yes") content = (repo / "multi.py").read_text() # Both occurrences should be renamed assert "total_sum(total_sum(" in content assert "compute_total" not in content # --------------------------------------------------------------------------- # 16. CLI — max-files warning in JSON # --------------------------------------------------------------------------- class TestCLIWarnings: def test_max_files_warning_in_json(self, repo: pathlib.Path) -> None: # Set max-files to 1 so the warning fires r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--json", "--max-files", "1") assert r.exit_code == 0, r.output data = json.loads(r.output) assert "warnings" in data assert len(data["warnings"]) > 0 def test_no_warning_when_files_within_limit(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--json", "--max-files", "1000") data = json.loads(r.output) assert data["warnings"] == [] # --------------------------------------------------------------------------- # 17. CLI — zero edit sites # --------------------------------------------------------------------------- class TestCLINoSites: def test_no_sites_exits_zero(self, repo: pathlib.Path) -> None: # Rename the definition only — no imports/callsites expected for fetch_invoice r = _run(repo, "code", "rename", "billing.py::fetch_invoice", "get_invoice", "--scope", "imports", "--json") assert r.exit_code == 0, r.output def test_no_sites_total_edit_sites_zero_or_more(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "billing.py::fetch_invoice", "get_invoice", "--scope", "imports", "--json") data = json.loads(r.output) assert isinstance(data["total_edit_sites"], int) assert data["total_edit_sites"] >= 0 # --------------------------------------------------------------------------- # 18. Security # --------------------------------------------------------------------------- class TestCLISecurity: def test_null_byte_in_new_name_rejected(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "billing.py::compute_total", "new\x00name") assert r.exit_code != 0 def test_null_byte_not_in_stdout(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "billing.py::compute_total", "new\x00name") assert "\x00" not in r.output def test_ansi_not_in_json_output(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--json") assert "\x1b" not in r.output def test_path_traversal_rejected(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "../etc/passwd::compute_total", "total_sum") assert r.exit_code != 0 def test_space_in_new_name_rejected(self, repo: pathlib.Path) -> None: r = _run(repo, "code", "rename", "billing.py::compute_total", "total sum") assert r.exit_code != 0 # --------------------------------------------------------------------------- # 19. Docstrings # --------------------------------------------------------------------------- class TestDocstrings: def test_run_docstring_exists(self) -> None: from muse.cli.commands.rename import run assert run.__doc__ is not None assert len(run.__doc__) > 80 def test_run_docstring_mentions_json(self) -> None: from muse.cli.commands.rename import run assert "json" in (run.__doc__ or "").lower() def test_register_docstring_exists(self) -> None: from muse.cli.commands.rename import register assert register.__doc__ is not None assert len(register.__doc__) > 80 def test_register_docstring_mentions_scope(self) -> None: from muse.cli.commands.rename import register assert "scope" in (register.__doc__ or "").lower() def test_register_docstring_mentions_yes(self) -> None: from muse.cli.commands.rename import register assert "--yes" in (register.__doc__ or "") or "yes" in (register.__doc__ or "").lower() class TestRegisterFlags: def test_default_json_out_is_false(self) -> None: import argparse from muse.cli.commands.rename import register p = argparse.ArgumentParser() subs = p.add_subparsers() register(subs) args = p.parse_args(["rename", "billing.py::compute_total", "compute_invoice_total"]) assert args.json_out is False def test_json_flag_sets_json_out(self) -> None: import argparse from muse.cli.commands.rename import register p = argparse.ArgumentParser() subs = p.add_subparsers() register(subs) args = p.parse_args(["rename", "billing.py::compute_total", "compute_invoice_total", "--json"]) assert args.json_out is True def test_j_shorthand_sets_json_out(self) -> None: import argparse from muse.cli.commands.rename import register p = argparse.ArgumentParser() subs = p.add_subparsers() register(subs) args = p.parse_args(["rename", "billing.py::compute_total", "compute_invoice_total", "-j"]) assert args.json_out is True