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