gabriel / muse public
test_rename_supercharge.py python
844 lines 34.8 KB
Raw
sha256:f6cd81bc71702f5c1c6890bd39aaba994fe58c75f019d7c03934724fa2739bb4 fix: carry dev changes harmony dropped in merge — detached … Sonnet 4.6 minor ⚠ breaking 16 days ago
1 """TDD supercharge tests for ``muse code rename``.
2
3 Gaps being closed
4 -----------------
5 - ``-j`` alias for ``--json``
6 - ``exit_code`` and ``duration_ms`` in ``_RenameResult`` JSON
7 - Unit tests for all 7 private helpers
8 - ``--scope callsites`` coverage
9 - ``--scope all`` round-trip (finds all 3 kinds)
10 - ``async def`` rename
11 - Attribute reference sites (``obj.old_name``)
12 - Multiple tokens on the same line
13 - Max-files warning present in JSON warnings list
14 - Zero-edit-site case (no references found)
15 - ``-n`` / ``-y`` alias verification
16 - Security: null byte / ANSI in new_name and address
17 - Docstring completeness for ``register()`` and ``run()``
18
19 Test classes
20 ------------
21 TestJsonAlias -j alias identical to --json
22 TestJsonEnvelope exit_code, duration_ms in every JSON response
23 TestUnitValidateIdentifier _validate_identifier edge cases
24 TestUnitParseAddress _parse_address edge cases
25 TestUnitLine _line helper
26 TestUnitDedup _dedup helper
27 TestUnitApplyEdits _apply_edits multi-edit, right-to-left, empty
28 TestUnitFindDefinitionSite _find_definition_site: async def, class, missing
29 TestUnitFindReferenceSites _find_reference_sites: imports, callsites, attr
30 TestCLIScopeCallsites --scope callsites
31 TestCLIScopeAll --scope all finds all 3 kinds
32 TestCLIAliases -n and -y aliases
33 TestCLIAttributeRename obj.old_name attribute-reference rename
34 TestCLIMultipleOccurrences multiple tokens same line
35 TestCLIWarnings max-files warning in JSON
36 TestCLINoSites zero edit sites does not crash
37 TestCLISecurity null byte / ANSI in new_name / address
38 TestDocstrings run(), register() doc completeness
39 """
40
41 from __future__ import annotations
42 from collections.abc import Mapping
43
44 import json
45 import pathlib
46 import textwrap
47 import typing
48
49 import pytest
50
51 from tests.cli_test_helper import CliRunner, InvokeResult
52
53 cli = None
54 runner = CliRunner()
55
56
57 # ---------------------------------------------------------------------------
58 # Helpers
59 # ---------------------------------------------------------------------------
60
61
62 def _run(root: pathlib.Path, *args: str) -> InvokeResult:
63 return runner.invoke(cli, list(args), env={"MUSE_REPO_ROOT": str(root)})
64
65
66 def _commit(root: pathlib.Path, msg: str = "commit") -> None:
67 r = _run(root, "code", "add", ".")
68 assert r.exit_code == 0, r.output
69 r2 = _run(root, "commit", "-m", msg)
70 assert r2.exit_code == 0, r2.output
71
72
73 # ---------------------------------------------------------------------------
74 # Fixture — repo with a range of symbol types
75 # ---------------------------------------------------------------------------
76
77
78 @pytest.fixture
79 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
80 """Code-domain repo with functions, imports, async def, attribute refs."""
81 monkeypatch.chdir(tmp_path)
82 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
83 r = _run(tmp_path, "init", "--domain", "code")
84 assert r.exit_code == 0, r.output
85
86 # Primary module: sync + async function + class with method
87 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
88 def compute_total(items: list[int]) -> int:
89 return sum(items)
90
91 async def fetch_invoice() -> Mapping[str, object]:
92 return {}
93
94 class Invoice:
95 def compute_total(self, items):
96 return sum(items) * 2
97 """))
98
99 # Caller: imports and uses compute_total
100 (tmp_path / "order.py").write_text(textwrap.dedent("""\
101 from billing import compute_total
102
103 def process(items):
104 return compute_total(items)
105 """))
106
107 # Caller with attribute reference
108 (tmp_path / "service.py").write_text(textwrap.dedent("""\
109 class Service:
110 def run(self, inv):
111 return inv.compute_total([1, 2, 3])
112 """))
113
114 # Caller with multiple occurrences on same line
115 (tmp_path / "multi.py").write_text(textwrap.dedent("""\
116 from billing import compute_total
117 result = compute_total(compute_total([1, 2]))
118 """))
119
120 _commit(tmp_path, "initial")
121 return tmp_path
122
123
124 # ---------------------------------------------------------------------------
125 # 1. -j alias
126 # ---------------------------------------------------------------------------
127
128
129 class TestJsonAlias:
130 def test_j_alias_exits_zero(self, repo: pathlib.Path) -> None:
131 r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "-j")
132 assert r.exit_code == 0, r.output
133
134 def test_j_alias_emits_valid_json(self, repo: pathlib.Path) -> None:
135 r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "-j")
136 data = json.loads(r.output.strip())
137 assert isinstance(data, dict)
138
139 def test_j_alias_has_from_address(self, repo: pathlib.Path) -> None:
140 r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "-j")
141 data = json.loads(r.output)
142 assert "from_address" in data
143
144 def test_j_alias_same_keys_as_json_flag(self, repo: pathlib.Path) -> None:
145 r1 = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--json", "--dry-run")
146 r2 = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "-j", "--dry-run")
147 d1 = json.loads(r1.output)
148 d2 = json.loads(r2.output)
149 d1.pop("duration_ms", None)
150 d2.pop("duration_ms", None)
151 assert set(d1.keys()) == set(d2.keys())
152
153 def test_j_alias_edit_sites_match(self, repo: pathlib.Path) -> None:
154 r1 = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--json", "--dry-run")
155 r2 = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "-j", "--dry-run")
156 assert json.loads(r1.output)["total_edit_sites"] == json.loads(r2.output)["total_edit_sites"]
157
158
159 # ---------------------------------------------------------------------------
160 # 2. JSON envelope: exit_code + duration_ms
161 # ---------------------------------------------------------------------------
162
163
164 class TestJsonEnvelope:
165 def test_has_exit_code(self, repo: pathlib.Path) -> None:
166 r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--json")
167 data = json.loads(r.output)
168 assert "exit_code" in data
169
170 def test_exit_code_is_zero(self, repo: pathlib.Path) -> None:
171 r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--json")
172 data = json.loads(r.output)
173 assert data["exit_code"] == 0
174
175 def test_has_duration_ms(self, repo: pathlib.Path) -> None:
176 r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--json")
177 data = json.loads(r.output)
178 assert "duration_ms" in data
179
180 def test_duration_ms_is_float(self, repo: pathlib.Path) -> None:
181 r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--json")
182 data = json.loads(r.output)
183 assert isinstance(data["duration_ms"], float)
184
185 def test_duration_ms_positive(self, repo: pathlib.Path) -> None:
186 r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--json")
187 data = json.loads(r.output)
188 assert data["duration_ms"] > 0
189
190 def test_typed_dict_has_exit_code_field(self) -> None:
191 from muse.cli.commands.rename import _RenameResult
192 hints = typing.get_type_hints(_RenameResult)
193 assert "exit_code" in hints
194
195 def test_typed_dict_has_duration_ms_field(self) -> None:
196 from muse.cli.commands.rename import _RenameResult
197 hints = typing.get_type_hints(_RenameResult)
198 assert "duration_ms" in hints
199
200 def test_json_applied_also_has_exit_code(self, repo: pathlib.Path) -> None:
201 r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum",
202 "--json", "--yes")
203 data = json.loads(r.output)
204 assert data["exit_code"] == 0
205
206 def test_json_applied_has_duration_ms(self, repo: pathlib.Path) -> None:
207 r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum",
208 "--json", "--yes")
209 data = json.loads(r.output)
210 assert "duration_ms" in data
211
212
213 # ---------------------------------------------------------------------------
214 # 3. Unit — _validate_identifier
215 # ---------------------------------------------------------------------------
216
217
218 class TestUnitValidateIdentifier:
219 def _v(self, name: str, force: bool = False) -> str | None:
220 from muse.cli.commands.rename import _validate_identifier
221 return _validate_identifier(name, force)
222
223 def test_valid_name_returns_none(self) -> None:
224 assert self._v("new_name") is None
225
226 def test_empty_name_returns_error(self) -> None:
227 assert self._v("") is not None
228
229 def test_name_too_long_returns_error(self) -> None:
230 assert self._v("a" * 201) is not None
231
232 def test_invalid_identifier_returns_error(self) -> None:
233 assert self._v("123bad") is not None
234
235 def test_hyphen_is_invalid(self) -> None:
236 assert self._v("my-func") is not None
237
238 def test_dunder_without_force_returns_error(self) -> None:
239 assert self._v("__init__") is not None
240
241 def test_dunder_with_force_returns_none(self) -> None:
242 assert self._v("__init__", force=True) is None
243
244 def test_leading_underscore_is_valid(self) -> None:
245 assert self._v("_private") is None
246
247 def test_all_caps_is_valid(self) -> None:
248 assert self._v("CONSTANT") is None
249
250 def test_unicode_letter_start_is_invalid(self) -> None:
251 # _IDENT_RE only accepts ASCII identifiers
252 result = self._v("café")
253 # Either None (if unicode allowed) or error — just verify no exception
254 # The regex is ASCII-only so this should be an error
255 assert result is not None
256
257
258 # ---------------------------------------------------------------------------
259 # 4. Unit — _parse_address
260 # ---------------------------------------------------------------------------
261
262
263 class TestUnitParseAddress:
264 def _p(self, address: str) -> tuple[str, list[str]] | None:
265 from muse.cli.commands.rename import _parse_address
266 return _parse_address(address)
267
268 def test_simple_address(self) -> None:
269 result = self._p("billing.py::compute_total")
270 assert result == ("billing.py", ["compute_total"])
271
272 def test_method_address(self) -> None:
273 result = self._p("billing.py::Invoice.compute_total")
274 assert result == ("billing.py", ["Invoice", "compute_total"])
275
276 def test_no_double_colon_returns_none(self) -> None:
277 assert self._p("billing.py:compute_total") is None
278
279 def test_empty_file_returns_none(self) -> None:
280 assert self._p("::compute_total") is None
281
282 def test_empty_symbol_returns_none(self) -> None:
283 assert self._p("billing.py::") is None
284
285 def test_empty_part_in_dotted_returns_none(self) -> None:
286 assert self._p("billing.py::Invoice..method") is None
287
288 def test_nested_path(self) -> None:
289 result = self._p("src/billing/core.py::compute_total")
290 assert result == ("src/billing/core.py", ["compute_total"])
291
292
293 # ---------------------------------------------------------------------------
294 # 5. Unit — _line
295 # ---------------------------------------------------------------------------
296
297
298 class TestUnitLine:
299 def _line(self, lines: list[str], lineno: int) -> str:
300 from muse.cli.commands.rename import _line
301 return _line(lines, lineno)
302
303 def test_first_line(self) -> None:
304 assert self._line(["a", "b", "c"], 1) == "a"
305
306 def test_last_line(self) -> None:
307 assert self._line(["a", "b", "c"], 3) == "c"
308
309 def test_out_of_range_high(self) -> None:
310 assert self._line(["a", "b"], 5) == ""
311
312 def test_out_of_range_zero(self) -> None:
313 assert self._line(["a", "b"], 0) == ""
314
315 def test_out_of_range_negative(self) -> None:
316 assert self._line(["a", "b"], -1) == ""
317
318 def test_empty_list(self) -> None:
319 assert self._line([], 1) == ""
320
321
322 # ---------------------------------------------------------------------------
323 # 6. Unit — _dedup
324 # ---------------------------------------------------------------------------
325
326
327 class TestUnitDedup:
328 def _site(self, line: int, col_start: int, kind: str = "reference") -> Mapping[str, object]:
329 return {
330 "file": "f.py", "line": line, "col_start": col_start,
331 "col_end": col_start + 3, "kind": kind, "context": "",
332 }
333
334 def test_no_duplicates_unchanged(self) -> None:
335 from muse.cli.commands.rename import _dedup
336 sites = [self._site(1, 0), self._site(2, 0)]
337 assert len(_dedup(sites)) == 2 # type: ignore[arg-type]
338
339 def test_exact_duplicate_removed(self) -> None:
340 from muse.cli.commands.rename import _dedup
341 sites = [self._site(1, 0), self._site(1, 0)]
342 assert len(_dedup(sites)) == 1 # type: ignore[arg-type]
343
344 def test_same_line_different_col_kept(self) -> None:
345 from muse.cli.commands.rename import _dedup
346 sites = [self._site(1, 0), self._site(1, 10)]
347 assert len(_dedup(sites)) == 2 # type: ignore[arg-type]
348
349 def test_preserves_order(self) -> None:
350 from muse.cli.commands.rename import _dedup
351 s1, s2, s3 = self._site(1, 0), self._site(2, 0), self._site(3, 0)
352 result = _dedup([s1, s2, s3]) # type: ignore[arg-type]
353 assert result[0]["line"] == 1
354 assert result[2]["line"] == 3
355
356 def test_empty_list(self) -> None:
357 from muse.cli.commands.rename import _dedup
358 assert _dedup([]) == []
359
360
361 # ---------------------------------------------------------------------------
362 # 7. Unit — _apply_edits
363 # ---------------------------------------------------------------------------
364
365
366 class TestUnitApplyEdits:
367 def _site(self, line: int, col_start: int, col_end: int, kind: str = "reference") -> Mapping[str, object]:
368 return {
369 "file": "f.py", "line": line, "col_start": col_start,
370 "col_end": col_end, "kind": kind, "context": "",
371 }
372
373 def test_single_edit(self) -> None:
374 from muse.cli.commands.rename import _apply_edits
375 source = "def foo():\n pass\n"
376 site = self._site(1, 4, 7, "definition")
377 result = _apply_edits(source, [site], "bar") # type: ignore[arg-type]
378 assert "def bar():" in result
379
380 def test_empty_sites_unchanged(self) -> None:
381 from muse.cli.commands.rename import _apply_edits
382 source = "def foo():\n pass\n"
383 assert _apply_edits(source, [], "bar") == source
384
385 def test_two_edits_same_line_right_to_left(self) -> None:
386 from muse.cli.commands.rename import _apply_edits
387 # "foo(foo())" — two occurrences of "foo" on line 1
388 source = "foo(foo())\n"
389 s1 = self._site(1, 0, 3) # first "foo"
390 s2 = self._site(1, 4, 7) # second "foo"
391 result = _apply_edits(source, [s1, s2], "bar") # type: ignore[arg-type]
392 assert result == "bar(bar())\n"
393
394 def test_edit_preserves_trailing_newline(self) -> None:
395 from muse.cli.commands.rename import _apply_edits
396 source = "foo = 1\n"
397 site = self._site(1, 0, 3)
398 result = _apply_edits(source, [site], "baz") # type: ignore[arg-type]
399 assert result.endswith("\n")
400
401 def test_longer_replacement(self) -> None:
402 from muse.cli.commands.rename import _apply_edits
403 source = "foo()\n"
404 site = self._site(1, 0, 3)
405 result = _apply_edits(source, [site], "compute_total_invoice") # type: ignore[arg-type]
406 assert "compute_total_invoice()" in result
407
408 def test_shorter_replacement(self) -> None:
409 from muse.cli.commands.rename import _apply_edits
410 source = "compute_total_invoice()\n"
411 # col_end = 20 (len of "compute_total_invoic") — just rename first bit
412 site = self._site(1, 0, 21)
413 result = _apply_edits(source, [site], "f") # type: ignore[arg-type]
414 assert result.startswith("f()")
415
416
417 # ---------------------------------------------------------------------------
418 # 8. Unit — _find_definition_site
419 # ---------------------------------------------------------------------------
420
421
422 class TestUnitFindDefinitionSite:
423 def test_finds_function(self) -> None:
424 from muse.cli.commands.rename import _find_definition_site
425 source = "def compute_total(items):\n return sum(items)\n"
426 site = _find_definition_site(source, "billing.py", ["compute_total"])
427 assert site is not None
428 assert site["kind"] == "definition"
429 assert site["line"] == 1
430
431 def test_finds_async_function(self) -> None:
432 from muse.cli.commands.rename import _find_definition_site
433 source = "async def fetch_invoice():\n return {}\n"
434 site = _find_definition_site(source, "billing.py", ["fetch_invoice"])
435 assert site is not None
436 assert site["kind"] == "definition"
437 assert site["line"] == 1
438
439 def test_finds_class(self) -> None:
440 from muse.cli.commands.rename import _find_definition_site
441 source = "class Invoice:\n pass\n"
442 site = _find_definition_site(source, "billing.py", ["Invoice"])
443 assert site is not None
444 assert site["kind"] == "definition"
445
446 def test_finds_method_scoped_to_class(self) -> None:
447 from muse.cli.commands.rename import _find_definition_site
448 source = textwrap.dedent("""\
449 class Invoice:
450 def compute_total(self, items):
451 return sum(items)
452 """)
453 site = _find_definition_site(source, "billing.py", ["Invoice", "compute_total"])
454 assert site is not None
455 assert site["line"] == 2
456
457 def test_returns_none_when_not_found(self) -> None:
458 from muse.cli.commands.rename import _find_definition_site
459 source = "def other():\n pass\n"
460 assert _find_definition_site(source, "billing.py", ["compute_total"]) is None
461
462 def test_returns_none_for_syntax_error(self) -> None:
463 from muse.cli.commands.rename import _find_definition_site
464 source = "def (\n"
465 assert _find_definition_site(source, "billing.py", ["compute_total"]) is None
466
467 def test_col_start_points_at_name(self) -> None:
468 from muse.cli.commands.rename import _find_definition_site
469 source = "def compute_total():\n pass\n"
470 site = _find_definition_site(source, "billing.py", ["compute_total"])
471 assert site is not None
472 # "def " = 4 chars, so col_start should be 4
473 assert site["col_start"] == 4
474 assert source.splitlines()[0][site["col_start"]:site["col_end"]] == "compute_total"
475
476 def test_async_col_start_points_at_name(self) -> None:
477 from muse.cli.commands.rename import _find_definition_site
478 source = "async def fetch_invoice():\n return {}\n"
479 site = _find_definition_site(source, "billing.py", ["fetch_invoice"])
480 assert site is not None
481 # "async def " = 10 chars
482 assert site["col_start"] == 10
483 assert source.splitlines()[0][site["col_start"]:site["col_end"]] == "fetch_invoice"
484
485
486 # ---------------------------------------------------------------------------
487 # 9. Unit — _find_reference_sites
488 # ---------------------------------------------------------------------------
489
490
491 class TestUnitFindReferenceSites:
492 def test_finds_import_site(self) -> None:
493 from muse.cli.commands.rename import _find_reference_sites
494 source = "from billing import compute_total\n"
495 sites = _find_reference_sites(source, "order.py", "compute_total",
496 include_imports=True, include_callsites=False)
497 assert any(s["kind"] == "import" for s in sites)
498
499 def test_import_disabled(self) -> None:
500 from muse.cli.commands.rename import _find_reference_sites
501 source = "from billing import compute_total\n"
502 sites = _find_reference_sites(source, "order.py", "compute_total",
503 include_imports=False, include_callsites=False)
504 assert sites == []
505
506 def test_finds_call_site(self) -> None:
507 from muse.cli.commands.rename import _find_reference_sites
508 source = "result = compute_total([1, 2, 3])\n"
509 sites = _find_reference_sites(source, "order.py", "compute_total",
510 include_imports=False, include_callsites=True)
511 assert any(s["kind"] == "reference" for s in sites)
512
513 def test_finds_attribute_access(self) -> None:
514 from muse.cli.commands.rename import _find_reference_sites
515 source = "x = obj.compute_total([1, 2])\n"
516 sites = _find_reference_sites(source, "service.py", "compute_total",
517 include_imports=False, include_callsites=True)
518 assert any(s["kind"] == "reference" for s in sites)
519
520 def test_callsites_disabled(self) -> None:
521 from muse.cli.commands.rename import _find_reference_sites
522 source = "result = compute_total([1, 2, 3])\n"
523 sites = _find_reference_sites(source, "order.py", "compute_total",
524 include_imports=False, include_callsites=False)
525 assert sites == []
526
527 def test_returns_empty_on_syntax_error(self) -> None:
528 from muse.cli.commands.rename import _find_reference_sites
529 source = "def (\n"
530 sites = _find_reference_sites(source, "bad.py", "compute_total",
531 include_imports=True, include_callsites=True)
532 assert sites == []
533
534 def test_does_not_match_partial_name(self) -> None:
535 from muse.cli.commands.rename import _find_reference_sites
536 source = "result = total_compute_total([1])\n"
537 # "compute_total" appears as a suffix — word boundary regex should not match
538 # as a call site in ast.Name (AST won't have node.id == "compute_total")
539 sites = _find_reference_sites(source, "order.py", "compute_total",
540 include_imports=False, include_callsites=True)
541 # No ast.Name node with id == "compute_total" — only "total_compute_total"
542 assert all(s["kind"] != "reference" or "total_compute_total" not in s["context"]
543 for s in sites)
544
545
546 # ---------------------------------------------------------------------------
547 # 10. CLI — --scope callsites
548 # ---------------------------------------------------------------------------
549
550
551 class TestCLIScopeCallsites:
552 def test_callsites_exits_zero(self, repo: pathlib.Path) -> None:
553 r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum",
554 "--scope", "callsites", "--json")
555 assert r.exit_code == 0, r.output
556
557 def test_callsites_only_reference_kind(self, repo: pathlib.Path) -> None:
558 r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum",
559 "--scope", "callsites", "--json")
560 data = json.loads(r.output)
561 for site in data["edit_sites"]:
562 assert site["kind"] == "reference"
563
564 def test_callsites_no_definition_kind(self, repo: pathlib.Path) -> None:
565 r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum",
566 "--scope", "callsites", "--json")
567 data = json.loads(r.output)
568 assert not any(s["kind"] == "definition" for s in data["edit_sites"])
569
570 def test_callsites_no_import_kind(self, repo: pathlib.Path) -> None:
571 r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum",
572 "--scope", "callsites", "--json")
573 data = json.loads(r.output)
574 assert not any(s["kind"] == "import" for s in data["edit_sites"])
575
576 def test_callsites_scope_reflected_in_json(self, repo: pathlib.Path) -> None:
577 r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum",
578 "--scope", "callsites", "--json")
579 data = json.loads(r.output)
580 assert data["scope"] == "callsites"
581
582
583 # ---------------------------------------------------------------------------
584 # 11. CLI — --scope all round-trip
585 # ---------------------------------------------------------------------------
586
587
588 class TestCLIScopeAll:
589 def test_scope_all_finds_definition(self, repo: pathlib.Path) -> None:
590 r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum",
591 "--scope", "all", "--json")
592 data = json.loads(r.output)
593 assert any(s["kind"] == "definition" for s in data["edit_sites"])
594
595 def test_scope_all_finds_import(self, repo: pathlib.Path) -> None:
596 r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum",
597 "--scope", "all", "--json")
598 data = json.loads(r.output)
599 assert any(s["kind"] == "import" for s in data["edit_sites"])
600
601 def test_scope_all_finds_reference(self, repo: pathlib.Path) -> None:
602 r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum",
603 "--scope", "all", "--json")
604 data = json.loads(r.output)
605 assert any(s["kind"] == "reference" for s in data["edit_sites"])
606
607
608 # ---------------------------------------------------------------------------
609 # 12. CLI — -n and -y aliases
610 # ---------------------------------------------------------------------------
611
612
613 class TestCLIAliases:
614 def test_n_alias_is_dry_run(self, repo: pathlib.Path) -> None:
615 r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum",
616 "--json", "-n")
617 assert r.exit_code == 0, r.output
618 data = json.loads(r.output)
619 assert data["dry_run"] is True
620
621 def test_n_alias_does_not_write(self, repo: pathlib.Path) -> None:
622 original = (repo / "billing.py").read_text()
623 _run(repo, "code", "rename", "billing.py::compute_total", "total_sum",
624 "--json", "-n")
625 assert (repo / "billing.py").read_text() == original
626
627 def test_y_alias_applies_changes(self, repo: pathlib.Path) -> None:
628 r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum",
629 "--json", "-y")
630 assert r.exit_code == 0, r.output
631 content = (repo / "billing.py").read_text()
632 assert "total_sum" in content
633
634 def test_y_alias_dry_run_is_false(self, repo: pathlib.Path) -> None:
635 r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum",
636 "--json", "-y")
637 data = json.loads(r.output)
638 assert data["dry_run"] is False
639
640
641 # ---------------------------------------------------------------------------
642 # 13. CLI — async def rename
643 # ---------------------------------------------------------------------------
644
645
646 class TestCLIAsyncDef:
647 def test_async_rename_exits_zero(self, repo: pathlib.Path) -> None:
648 r = _run(repo, "code", "rename", "billing.py::fetch_invoice", "get_invoice",
649 "--json")
650 assert r.exit_code == 0, r.output
651
652 def test_async_rename_finds_definition(self, repo: pathlib.Path) -> None:
653 r = _run(repo, "code", "rename", "billing.py::fetch_invoice", "get_invoice",
654 "--json")
655 data = json.loads(r.output)
656 assert any(s["kind"] == "definition" for s in data["edit_sites"])
657
658 def test_async_rename_applies_correctly(self, repo: pathlib.Path) -> None:
659 _run(repo, "code", "rename", "billing.py::fetch_invoice", "get_invoice",
660 "--json", "--yes")
661 content = (repo / "billing.py").read_text()
662 assert "async def get_invoice" in content
663 assert "async def fetch_invoice" not in content
664
665
666 # ---------------------------------------------------------------------------
667 # 14. CLI — attribute reference sites (obj.old_name)
668 # ---------------------------------------------------------------------------
669
670
671 class TestCLIAttributeRename:
672 def test_finds_attribute_reference(self, repo: pathlib.Path) -> None:
673 r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum",
674 "--scope", "callsites", "--json")
675 data = json.loads(r.output)
676 # service.py has inv.compute_total([1, 2, 3])
677 service_sites = [s for s in data["edit_sites"] if "service" in s["file"]]
678 assert service_sites, "Expected reference sites in service.py"
679
680 def test_attribute_site_kind_is_reference(self, repo: pathlib.Path) -> None:
681 r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum",
682 "--scope", "callsites", "--json")
683 data = json.loads(r.output)
684 service_sites = [s for s in data["edit_sites"] if "service" in s["file"]]
685 assert all(s["kind"] == "reference" for s in service_sites)
686
687 def test_attribute_applied_correctly(self, repo: pathlib.Path) -> None:
688 _run(repo, "code", "rename", "billing.py::compute_total", "total_sum",
689 "--scope", "callsites", "--json", "--yes")
690 content = (repo / "service.py").read_text()
691 assert "inv.total_sum([1, 2, 3])" in content
692
693
694 # ---------------------------------------------------------------------------
695 # 15. CLI — multiple occurrences on same line
696 # ---------------------------------------------------------------------------
697
698
699 class TestCLIMultipleOccurrences:
700 def test_multiple_sites_found_on_same_line(self, repo: pathlib.Path) -> None:
701 r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum",
702 "--scope", "callsites", "--json")
703 data = json.loads(r.output)
704 multi_sites = [s for s in data["edit_sites"] if "multi" in s["file"]]
705 # multi.py line 2: compute_total(compute_total([1, 2]))
706 line2_sites = [s for s in multi_sites if s["line"] == 2]
707 assert len(line2_sites) >= 2
708
709 def test_multiple_applied_correctly(self, repo: pathlib.Path) -> None:
710 _run(repo, "code", "rename", "billing.py::compute_total", "total_sum",
711 "--scope", "all", "--json", "--yes")
712 content = (repo / "multi.py").read_text()
713 # Both occurrences should be renamed
714 assert "total_sum(total_sum(" in content
715 assert "compute_total" not in content
716
717
718 # ---------------------------------------------------------------------------
719 # 16. CLI — max-files warning in JSON
720 # ---------------------------------------------------------------------------
721
722
723 class TestCLIWarnings:
724 def test_max_files_warning_in_json(self, repo: pathlib.Path) -> None:
725 # Set max-files to 1 so the warning fires
726 r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum",
727 "--json", "--max-files", "1")
728 assert r.exit_code == 0, r.output
729 data = json.loads(r.output)
730 assert "warnings" in data
731 assert len(data["warnings"]) > 0
732
733 def test_no_warning_when_files_within_limit(self, repo: pathlib.Path) -> None:
734 r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum",
735 "--json", "--max-files", "1000")
736 data = json.loads(r.output)
737 assert data["warnings"] == []
738
739
740 # ---------------------------------------------------------------------------
741 # 17. CLI — zero edit sites
742 # ---------------------------------------------------------------------------
743
744
745 class TestCLINoSites:
746 def test_no_sites_exits_zero(self, repo: pathlib.Path) -> None:
747 # Rename the definition only — no imports/callsites expected for fetch_invoice
748 r = _run(repo, "code", "rename", "billing.py::fetch_invoice", "get_invoice",
749 "--scope", "imports", "--json")
750 assert r.exit_code == 0, r.output
751
752 def test_no_sites_total_edit_sites_zero_or_more(self, repo: pathlib.Path) -> None:
753 r = _run(repo, "code", "rename", "billing.py::fetch_invoice", "get_invoice",
754 "--scope", "imports", "--json")
755 data = json.loads(r.output)
756 assert isinstance(data["total_edit_sites"], int)
757 assert data["total_edit_sites"] >= 0
758
759
760 # ---------------------------------------------------------------------------
761 # 18. Security
762 # ---------------------------------------------------------------------------
763
764
765 class TestCLISecurity:
766 def test_null_byte_in_new_name_rejected(self, repo: pathlib.Path) -> None:
767 r = _run(repo, "code", "rename", "billing.py::compute_total", "new\x00name")
768 assert r.exit_code != 0
769
770 def test_null_byte_not_in_stdout(self, repo: pathlib.Path) -> None:
771 r = _run(repo, "code", "rename", "billing.py::compute_total", "new\x00name")
772 assert "\x00" not in r.output
773
774 def test_ansi_not_in_json_output(self, repo: pathlib.Path) -> None:
775 r = _run(repo, "code", "rename", "billing.py::compute_total", "total_sum", "--json")
776 assert "\x1b" not in r.output
777
778 def test_path_traversal_rejected(self, repo: pathlib.Path) -> None:
779 r = _run(repo, "code", "rename", "../etc/passwd::compute_total", "total_sum")
780 assert r.exit_code != 0
781
782 def test_space_in_new_name_rejected(self, repo: pathlib.Path) -> None:
783 r = _run(repo, "code", "rename", "billing.py::compute_total", "total sum")
784 assert r.exit_code != 0
785
786
787 # ---------------------------------------------------------------------------
788 # 19. Docstrings
789 # ---------------------------------------------------------------------------
790
791
792 class TestDocstrings:
793 def test_run_docstring_exists(self) -> None:
794 from muse.cli.commands.rename import run
795 assert run.__doc__ is not None
796 assert len(run.__doc__) > 80
797
798 def test_run_docstring_mentions_json(self) -> None:
799 from muse.cli.commands.rename import run
800 assert "json" in (run.__doc__ or "").lower()
801
802
803
804 def test_register_docstring_exists(self) -> None:
805 from muse.cli.commands.rename import register
806 assert register.__doc__ is not None
807 assert len(register.__doc__) > 80
808
809 def test_register_docstring_mentions_scope(self) -> None:
810 from muse.cli.commands.rename import register
811 assert "scope" in (register.__doc__ or "").lower()
812
813 def test_register_docstring_mentions_yes(self) -> None:
814 from muse.cli.commands.rename import register
815 assert "--yes" in (register.__doc__ or "") or "yes" in (register.__doc__ or "").lower()
816
817
818 class TestRegisterFlags:
819 def test_default_json_out_is_false(self) -> None:
820 import argparse
821 from muse.cli.commands.rename import register
822 p = argparse.ArgumentParser()
823 subs = p.add_subparsers()
824 register(subs)
825 args = p.parse_args(["rename", "billing.py::compute_total", "compute_invoice_total"])
826 assert args.json_out is False
827
828 def test_json_flag_sets_json_out(self) -> None:
829 import argparse
830 from muse.cli.commands.rename import register
831 p = argparse.ArgumentParser()
832 subs = p.add_subparsers()
833 register(subs)
834 args = p.parse_args(["rename", "billing.py::compute_total", "compute_invoice_total", "--json"])
835 assert args.json_out is True
836
837 def test_j_shorthand_sets_json_out(self) -> None:
838 import argparse
839 from muse.cli.commands.rename import register
840 p = argparse.ArgumentParser()
841 subs = p.add_subparsers()
842 register(subs)
843 args = p.parse_args(["rename", "billing.py::compute_total", "compute_invoice_total", "-j"])
844 assert args.json_out is True
File History 2 commits
sha256:f6cd81bc71702f5c1c6890bd39aaba994fe58c75f019d7c03934724fa2739bb4 fix: carry dev changes harmony dropped in merge — detached … Sonnet 4.6 minor 16 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 21 days ago