fix(mutation report): warn when multiple functions in a file share a name

Addresses the Greptile review concern: ast.walk's first-match-wins
behavior could embed the wrong function body when a file defines the
same name in multiple places (e.g., a module-level helper and a class
method). mutmut's mutant identifier does not carry class context, so
we can't always determine which definition was mutated.

find_function_in_file now returns the start line of every matching
definition; render() surfaces a "Note: N functions named X" warning
in the report when there is more than one match. The first match is
still embedded as the body — the warning tells the reader to verify
manually instead of silently using the wrong context.

Smoke-tested against the existing artifact: single-match files render
unchanged.
This commit is contained in:
Ryan Crabbe 2026-05-09 20:39:36 -07:00 committed by Cursor Agent
parent 0ccdc91dc5
commit c4d964f460
No known key found for this signature in database

View file

@ -77,21 +77,35 @@ def module_to_file(module_path: str) -> Path | None:
def find_function_in_file(
file_path: Path, function_name: str
) -> tuple[int, int, str] | None:
) -> tuple[int, int, str, list[int]] | None:
"""Find a top-level or nested function by name; returns the first match.
Returns ``(start_line, end_line, source, all_match_lines)`` or ``None``.
``all_match_lines`` is the start line of every function (any nesting
level) in the file with this name. When ``len(all_match_lines) > 1`` the
file defines the same name in multiple places (e.g., a module-level
helper and a class method) mutmut's mutant identifier does not carry
class context, so we can't determine which definition was mutated.
Callers surface a disambiguation note in that case.
"""
src = file_path.read_text()
tree = ast.parse(src)
for node in ast.walk(tree):
if (
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and node.name == function_name
):
lines = src.splitlines()
return (
node.lineno,
node.end_lineno,
"\n".join(lines[node.lineno - 1 : node.end_lineno]),
)
return None
matches = [
node
for node in ast.walk(tree)
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and node.name == function_name
]
if not matches:
return None
first = matches[0]
lines = src.splitlines()
return (
first.lineno,
first.end_lineno,
"\n".join(lines[first.lineno - 1 : first.end_lineno]),
[m.lineno for m in matches],
)
def collect_test_files(tests_dir: list[str]) -> list[Path]:
@ -274,9 +288,21 @@ def render(config: dict, survivors: list[str], stats: dict | None) -> str:
out.append("")
found = find_function_in_file(file_path, function_name)
if found:
start, end, fn_src = found
start, end, fn_src, all_lines = found
out.append(f"### Original function (lines {start}-{end})")
out.append("")
if len(all_lines) > 1:
line_list = ", ".join(str(line) for line in all_lines)
out.append(
f"> **Note:** {len(all_lines)} functions named "
f"`{function_name}` are defined in this file at lines "
f"{line_list}. Showing the first match. mutmut's "
f"mutant identifier does not carry class context, so "
f"the body below may not correspond to the function "
f"that was actually mutated — verify manually before "
f"writing the killing test."
)
out.append("")
out.append("```python")
out.append(fn_src)
out.append("```")