From e8b6b08a0725093ff588f804a8d8f99675551117 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 1 Aug 2026 12:16:54 -0500 Subject: [PATCH] fix: ignore constant-return CLI main --- .../smells_ast/_tree_quality_detectors.py | 47 +++++++++ .../python/tests/test_py_smells_ast.py | 98 +++++++++++++++++++ 2 files changed, 145 insertions(+) diff --git a/desloppify/languages/python/detectors/smells_ast/_tree_quality_detectors.py b/desloppify/languages/python/detectors/smells_ast/_tree_quality_detectors.py index 5bece4b29..a4e916e16 100644 --- a/desloppify/languages/python/detectors/smells_ast/_tree_quality_detectors.py +++ b/desloppify/languages/python/detectors/smells_ast/_tree_quality_detectors.py @@ -73,6 +73,50 @@ def _check_block(stmts: list[ast.stmt]): return results +def _is_main_module_guard(node: ast.AST) -> bool: + """Return whether a statement is exactly ``if __name__ == "__main__"``.""" + return ( + isinstance(node, ast.If) + and isinstance(node.test, ast.Compare) + and isinstance(node.test.left, ast.Name) + and node.test.left.id == "__name__" + and len(node.test.ops) == 1 + and isinstance(node.test.ops[0], ast.Eq) + and len(node.test.comparators) == 1 + and isinstance(node.test.comparators[0], ast.Constant) + and node.test.comparators[0].value == "__main__" + ) + + +def _main_guard_invokes_main(guard: ast.If) -> bool: + """Return whether a module-main guard directly executes ``main()``.""" + stack: list[ast.AST] = list(reversed(guard.body)) + while stack: + current = stack.pop() + if isinstance(current, ast.Call) and isinstance(current.func, ast.Name): + if current.func.id == "main": + return True + if isinstance(current, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef | ast.Lambda): + continue + stack.extend(reversed(list(ast.iter_child_nodes(current)))) + return False + + +def _find_top_level_cli_main( + tree: ast.Module, +) -> ast.FunctionDef | ast.AsyncFunctionDef | None: + """Return the top-level ``main`` invoked by a module-main guard, if any.""" + if not any( + _is_main_module_guard(statement) and _main_guard_invokes_main(statement) + for statement in tree.body + ): + return None + for statement in reversed(tree.body): + if isinstance(statement, ast.FunctionDef | ast.AsyncFunctionDef) and statement.name == "main": + return statement + return None + + def _detect_constant_return( filepath: str, tree: ast.Module, @@ -95,8 +139,11 @@ def _iter_function_scope_nodes(node: ast.FunctionDef | ast.AsyncFunctionDef): for child in reversed(list(ast.iter_child_nodes(current))): stack.append(child) + cli_main = _find_top_level_cli_main(tree) results: list[dict] = [] for node in _iter_nodes(tree, all_nodes, (ast.FunctionDef, ast.AsyncFunctionDef)): + if node is cli_main: + continue # Skip tiny functions (stubs/pass-only already caught by dead_function) if not hasattr(node, "end_lineno") or not node.end_lineno: continue diff --git a/desloppify/languages/python/tests/test_py_smells_ast.py b/desloppify/languages/python/tests/test_py_smells_ast.py index 2618431ee..32087b273 100644 --- a/desloppify/languages/python/tests/test_py_smells_ast.py +++ b/desloppify/languages/python/tests/test_py_smells_ast.py @@ -337,6 +337,104 @@ def varying(x): entries, _ = detect_smells(path) assert "constant_return" not in _smell_ids(entries) + def test_cli_main_guarded_by_module_entrypoint_is_not_flagged(self, tmp_path): + path = _write_py( + tmp_path, + '''\ + def main(argv=None): + if argv: + return 0 + return 0 + + + if __name__ == "__main__": + raise SystemExit(main()) + ''', + ) + + entries, _ = detect_smells(path) + + assert "constant_return" not in _smell_ids(entries) + + def test_unguarded_main_with_constant_returns_is_flagged(self, tmp_path): + path = _write_py( + tmp_path, + '''\ + def main(argv=None): + if argv: + return 0 + return 0 + ''', + ) + + entries, _ = detect_smells(path) + constant_return = next( + (entry for entry in entries if entry["id"] == "constant_return"), + None, + ) + + assert constant_return is not None + assert any("main()" in match["content"] for match in constant_return["matches"]) + + def test_module_main_guard_does_not_exempt_other_constant_function(self, tmp_path): + path = _write_py( + tmp_path, + '''\ + def render(argv=None): + if argv: + return 0 + return 0 + + + if __name__ == "__main__": + raise SystemExit(render()) + ''', + ) + + entries, _ = detect_smells(path) + constant_return = next( + (entry for entry in entries if entry["id"] == "constant_return"), + None, + ) + + assert constant_return is not None + assert any("render()" in match["content"] for match in constant_return["matches"]) + + def test_module_main_guard_does_not_exempt_nested_main(self, tmp_path): + path = _write_py( + tmp_path, + '''\ + def main(argv=None): + if argv: + return 0 + return 0 + + + def wrapper(flag): + def main(value): + if value: + return 0 + return 0 + return main(flag) + + + if __name__ == "__main__": + raise SystemExit(main()) + ''', + ) + + entries, _ = detect_smells(path) + constant_return = next( + (entry for entry in entries if entry["id"] == "constant_return"), + None, + ) + + assert constant_return is not None + main_matches = [ + match for match in constant_return["matches"] if "main()" in match["content"] + ] + assert len(main_matches) == 1 + def test_nested_function_returns_are_ignored(self, tmp_path): path = _write_py( tmp_path,