From 4cb9a6fad4bbf760bdd3dd8d84131157432e5c69 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 1 Aug 2026 14:50:14 -0500 Subject: [PATCH] fix: ignore duplicate constants in versioned migrations --- .../detectors/smells_ast/_source_detectors.py | 20 +++++++++++++ .../python/tests/test_py_smells_crossfile.py | 28 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/desloppify/languages/python/detectors/smells_ast/_source_detectors.py b/desloppify/languages/python/detectors/smells_ast/_source_detectors.py index 675057779..f7c1308eb 100644 --- a/desloppify/languages/python/detectors/smells_ast/_source_detectors.py +++ b/desloppify/languages/python/detectors/smells_ast/_source_detectors.py @@ -11,6 +11,12 @@ ConstantLocations = dict[tuple[str, str], list[tuple[str, int]]] SmellCounts = dict[str, list[dict[str, object]]] +_VERSIONED_MIGRATION_DIRECTORY_PAIRS = frozenset( + { + ("alembic", "versions"), + ("migrations", "versions"), + } +) def _is_within(root: Path, candidate: Path) -> bool: @@ -22,6 +28,17 @@ def _is_within(root: Path, candidate: Path) -> bool: return False +def _is_versioned_migration_path(filepath: str) -> bool: + """Return whether a file is an immutable versioned schema migration.""" + path_parts = tuple( + part.casefold() for part in re.split(r"[\\/]+", filepath) if part + ) + return any( + path_parts[index : index + 2] in _VERSIONED_MIGRATION_DIRECTORY_PAIRS + for index in range(len(path_parts) - 1) + ) + + def collect_module_constants( filepath: str, content: str, @@ -32,6 +49,9 @@ def collect_module_constants( Only collects UPPER_CASE or _UPPER_CASE names assigned to simple literals (dicts, lists, sets, tuples, numbers, strings). """ + if _is_versioned_migration_path(filepath): + return + try: tree = ast.parse(content, filename=filepath) except SyntaxError as exc: diff --git a/desloppify/languages/python/tests/test_py_smells_crossfile.py b/desloppify/languages/python/tests/test_py_smells_crossfile.py index 5138615f2..75bbf4827 100644 --- a/desloppify/languages/python/tests/test_py_smells_crossfile.py +++ b/desloppify/languages/python/tests/test_py_smells_crossfile.py @@ -5,6 +5,9 @@ from desloppify.languages.python.detectors import smells as smells_mod from desloppify.languages.python.detectors.smells import detect_smells +from desloppify.languages.python.detectors.smells_ast._source_detectors import ( + collect_module_constants, +) # ── Helpers ──────────────────────────────────────────────── @@ -105,6 +108,31 @@ def test_same_constant_in_two_files(self, tmp_path): entries, _ = detect_smells(tmp_path) assert "duplicate_constant" in _smell_ids(entries) + def test_versioned_migration_constant_is_ignored(self, tmp_path): + migration_dir = tmp_path / "migrations" / "versions" + migration_dir.mkdir(parents=True) + (migration_dir / "a1_add_constraint.py").write_text("SHA256_CHECK = 'sql'\n") + (tmp_path / "model.py").write_text("SHA256_CHECK = 'sql'\n") + + entries, _ = detect_smells(tmp_path) + + assert "duplicate_constant" not in _smell_ids(entries) + + def test_versioned_migration_paths_are_separator_independent(self): + for filepath in ( + "migrations/versions/a1.py", + r"migrations\versions\a1.py", + "alembic/versions/a1.py", + r"alembic\versions\a1.py", + ): + constants_by_key = {} + + collect_module_constants( + filepath, "SHA256_CHECK = 'sql'\n", constants_by_key + ) + + assert constants_by_key == {} + def test_different_constants_ok(self, tmp_path): (tmp_path / "a.py").write_text("MAX_RETRIES = 3\n") (tmp_path / "b.py").write_text("MAX_RETRIES = 5\n")