diff --git a/src/sentry/issues/auto_source_code_config/directory_prefixes.py b/src/sentry/issues/auto_source_code_config/directory_prefixes.py new file mode 100644 index 000000000000..7dffc2609a7a --- /dev/null +++ b/src/sentry/issues/auto_source_code_config/directory_prefixes.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from collections import Counter +from collections.abc import Sequence +from typing import NamedTuple + +DEFAULT_PREFIX_CAP = 200 +_SLASH = "/" +_BACKSLASH = "\\" + + +class RankedPrefix(NamedTuple): + path: str + file_count: int + + +def directory_prefixes(path: str) -> list[str]: + scheme, remainder = _peel_scheme(path) + sep = _detect_separator(remainder) + prefixes = _prefix_slices(remainder, sep) + + if scheme: + # Emit the bare scheme first; prepend it to each later prefix so each + # result is a literal slice of the original frame path. + return [scheme] + [scheme + p for p in prefixes] + return prefixes + + +def rank_directory_prefixes( + paths: Sequence[str], *, cap: int = DEFAULT_PREFIX_CAP +) -> list[RankedPrefix]: + counts: Counter[str] = Counter() + seen: set[str] = set() + for path in paths: + # src/foo.py and src\foo.py are different stack roots; treat as distinct. + if path in seen: + continue + seen.add(path) + counts.update(directory_prefixes(path)) + + ranked = sorted(counts.items(), key=_rank_key) + return [RankedPrefix(path, count) for path, count in ranked[:cap]] + + +def _peel_scheme(path: str) -> tuple[str, str]: + # Cuts inside scheme:/// (e.g. app:/, webpack:/) are not valid prefix options. + idx = path.find(":///") + if idx > 0: + scheme = path[: idx + 4] + return scheme, path[idx + 4 :] + return "", path + + +def _detect_separator(path: str) -> str: + # Keep the path's own separator so each prefix is a literal slice of the + # original frame, which is what startswith checks in code_mapping.py. + return _SLASH if _SLASH in path else _BACKSLASH + + +def _prefix_slices(path: str, sep: str) -> list[str]: + prefixes = [] + for i, ch in enumerate(path): + if ch != sep: + continue + prefix = path[: i + 1] + # Skip a prefix made entirely of separators — no directory content to suggest. + if prefix.strip(sep): + prefixes.append(prefix) + return prefixes + + +def _rank_key(item: tuple[str, int]) -> tuple[int, int, str]: + path, count = item + # Count both separators so src\foo\ ranks deeper than src\ when counts tie. + depth = path.count(_SLASH) + path.count(_BACKSLASH) + return (-count, depth, path) diff --git a/tests/sentry/issues/auto_source_code_config/test_directory_prefixes.py b/tests/sentry/issues/auto_source_code_config/test_directory_prefixes.py new file mode 100644 index 000000000000..901d0c774322 --- /dev/null +++ b/tests/sentry/issues/auto_source_code_config/test_directory_prefixes.py @@ -0,0 +1,96 @@ +from sentry.issues.auto_source_code_config.directory_prefixes import ( + DEFAULT_PREFIX_CAP, + directory_prefixes, + rank_directory_prefixes, +) + + +class TestDirectoryPrefixes: + def test_relative_path(self) -> None: + assert directory_prefixes("src/sentry/web/frontend/views.py") == [ + "src/", + "src/sentry/", + "src/sentry/web/", + "src/sentry/web/frontend/", + ] + + def test_absolute_path(self) -> None: + assert directory_prefixes("/usr/src/app/foo.py") == [ + "/usr/", + "/usr/src/", + "/usr/src/app/", + ] + + def test_filename_only_and_empty(self) -> None: + assert directory_prefixes("views.py") == [] + assert directory_prefixes("") == [] + + def test_windows_separators(self) -> None: + result = directory_prefixes("src\\foo\\bar.py") + assert result == ["src\\", "src\\foo\\"] + assert "src\\foo\\bar.py".startswith(result[0]) + assert "src\\foo\\bar.py".startswith(result[1]) + + def test_root_file_has_no_prefix(self) -> None: + assert directory_prefixes("/foo.py") == [] + + def test_app_scheme_preserves_triple_slash(self) -> None: + result = directory_prefixes("app:///src/index.tsx") + assert result == ["app:///", "app:///src/"] + assert "app:/" not in result + + def test_app_scheme_file_at_root(self) -> None: + assert directory_prefixes("app:///index.tsx") == ["app:///"] + + def test_webpack_scheme(self) -> None: + result = directory_prefixes("webpack:///src/index.js") + assert result == ["webpack:///", "webpack:///src/"] + assert all("webpack:///src/index.js".startswith(p) for p in result) + + def test_double_slash_path(self) -> None: + result = directory_prefixes("//usr/src/foo.py") + assert result == ["//usr/", "//usr/src/"] + assert all("//usr/src/foo.py".startswith(p) for p in result) + + def test_unc_path(self) -> None: + result = directory_prefixes("\\\\server\\share\\file.py") + assert result == ["\\\\server\\", "\\\\server\\share\\"] + assert all("\\\\server\\share\\file.py".startswith(p) for p in result) + + +class TestRankDirectoryPrefixes: + def test_ranks_by_file_count(self) -> None: + ranked = rank_directory_prefixes( + [ + "src/sentry/web/views.py", + "src/sentry/utils/foo.py", + "tests/sentry/test_web.py", + ] + ) + by_path = {item.path: item.file_count for item in ranked} + + assert by_path["src/"] == 2 + assert by_path["src/sentry/"] == 2 + assert by_path["tests/"] == 1 + assert by_path["src/sentry/web/"] == 1 + + top = ranked[:2] + assert {item.path for item in top} == {"src/", "src/sentry/"} + assert all(item.file_count == 2 for item in top) + assert all(item.file_count == 1 for item in ranked[2:]) + + def test_duplicate_files_count_once(self) -> None: + # a/b.py deduped to 1 instance; a\b.py is a distinct path, not a duplicate + ranked = rank_directory_prefixes(["a/b.py", "a/b.py", "a\\b.py"]) + by_path = {item.path: item.file_count for item in ranked} + assert by_path["a/"] == 1 + assert by_path["a\\"] == 1 + + def test_cap_keeps_highest_counts(self) -> None: + paths = [f"common/dir{index}/file.py" for index in range(DEFAULT_PREFIX_CAP + 50)] + ranked = rank_directory_prefixes(paths) + + assert len(ranked) == DEFAULT_PREFIX_CAP + assert ranked[0].path == "common/" + assert ranked[0].file_count == DEFAULT_PREFIX_CAP + 50 + assert min(item.file_count for item in ranked) >= 1