From 523bbf53a8bc90f82e47d9495a79362ca0b8ba6e Mon Sep 17 00:00:00 2001 From: Abdullah Khan Date: Thu, 1 Oct 2026 14:02:24 -0400 Subject: [PATCH 1/3] feat(code-mappings): Add directory prefix ranking helper Split file paths into directory prefixes and rank them by how many unique files they cover, for the upcoming prefix autocomplete endpoints. Refs VDY-245 Co-authored-by: Cursor --- .../directory_prefixes.py | 64 ++++++++++++++++++ .../test_directory_prefixes.py | 67 +++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 src/sentry/issues/auto_source_code_config/directory_prefixes.py create mode 100644 tests/sentry/issues/auto_source_code_config/test_directory_prefixes.py 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..ee00cf27512e --- /dev/null +++ b/src/sentry/issues/auto_source_code_config/directory_prefixes.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from collections import Counter +from collections.abc import Sequence +from typing import NamedTuple + +DEFAULT_PREFIX_CAP = 200 +_SLASH = "/" + + +class RankedPrefix(NamedTuple): + path: str + file_count: int + + +def directory_prefixes(path: str) -> list[str]: + directories = _directory_segments(path) + if not directories: + return [] + + prefixes: list[str] = [] + built: list[str] = [] + for segment in directories: + built.append(segment) + prefixes.append(_SLASH.join(built) + _SLASH) + 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: + normalized = _normalize_separators(path) + if normalized in seen: + continue + seen.add(normalized) + counts.update(directory_prefixes(normalized)) + + ranked = sorted(counts.items(), key=_rank_key) + return [RankedPrefix(path, count) for path, count in ranked[:cap]] + + +def _normalize_separators(path: str) -> str: + return path.replace("\\", _SLASH) + + +def _directory_segments(path: str) -> list[str]: + normalized = _normalize_separators(path) + absolute = normalized.startswith(_SLASH) + segments = [segment for segment in normalized.split(_SLASH) if segment] + if len(segments) <= 1: + return [] + + directories = segments[:-1] + if absolute: + directories[0] = f"{_SLASH}{directories[0]}" + return directories + + +def _rank_key(item: tuple[str, int]) -> tuple[int, int, str]: + path, count = item + return (-count, path.count(_SLASH), 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..8798e571001f --- /dev/null +++ b/tests/sentry/issues/auto_source_code_config/test_directory_prefixes.py @@ -0,0 +1,67 @@ +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: + assert directory_prefixes("src\\foo\\bar.py") == ["src/", "src/foo/"] + + def test_root_file_has_no_prefix(self) -> None: + assert directory_prefixes("/foo.py") == [] + + +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: + ranked = rank_directory_prefixes(["a/b.py", "a/b.py", "a\\b.py"]) + assert ranked == [("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 From 6ce7be0b97adbcd86c9128cc81cdb05394fad501 Mon Sep 17 00:00:00 2001 From: Abdullah Khan Date: Thu, 1 Oct 2026 14:23:52 -0400 Subject: [PATCH 2/3] fix(auto-source-code-config): Preserve app:/// scheme in directory_prefixes Splitting on '/' dropped the empty segments between the triple slashes, collapsing app:///src/index.tsx into prefixes starting with app:/ instead of app:///. Peel the scheme before splitting and re-attach it to each prefix. Refs VDY-245 Co-authored-by: Cursor --- .../directory_prefixes.py | 16 +++++++++++++--- .../test_directory_prefixes.py | 8 ++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/sentry/issues/auto_source_code_config/directory_prefixes.py b/src/sentry/issues/auto_source_code_config/directory_prefixes.py index ee00cf27512e..bb1b837c50f7 100644 --- a/src/sentry/issues/auto_source_code_config/directory_prefixes.py +++ b/src/sentry/issues/auto_source_code_config/directory_prefixes.py @@ -6,6 +6,7 @@ DEFAULT_PREFIX_CAP = 200 _SLASH = "/" +_APP_SCHEME = "app:///" class RankedPrefix(NamedTuple): @@ -14,15 +15,18 @@ class RankedPrefix(NamedTuple): def directory_prefixes(path: str) -> list[str]: - directories = _directory_segments(path) - if not directories: - return [] + normalized = _normalize_separators(path) + scheme, remainder = _peel_scheme(normalized) + directories = _directory_segments(remainder) prefixes: list[str] = [] built: list[str] = [] for segment in directories: built.append(segment) prefixes.append(_SLASH.join(built) + _SLASH) + + if scheme: + return [scheme] + [scheme + p for p in prefixes] return prefixes @@ -46,6 +50,12 @@ def _normalize_separators(path: str) -> str: return path.replace("\\", _SLASH) +def _peel_scheme(path: str) -> tuple[str, str]: + if path.startswith(_APP_SCHEME): + return _APP_SCHEME, path[len(_APP_SCHEME) :] + return "", path + + def _directory_segments(path: str) -> list[str]: normalized = _normalize_separators(path) absolute = normalized.startswith(_SLASH) 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 index 8798e571001f..c3e3691b0ce3 100644 --- a/tests/sentry/issues/auto_source_code_config/test_directory_prefixes.py +++ b/tests/sentry/issues/auto_source_code_config/test_directory_prefixes.py @@ -31,6 +31,14 @@ def test_windows_separators(self) -> None: 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:///"] + class TestRankDirectoryPrefixes: def test_ranks_by_file_count(self) -> None: From a7c0e0f503237082a88dd969763adc57a2d535ce Mon Sep 17 00:00:00 2001 From: Abdullah Khan Date: Thu, 1 Oct 2026 22:57:26 -0400 Subject: [PATCH 3/3] fix(auto-source-code-config): Keep directory prefixes as slices of the raw path Stack roots are matched with startswith before any slash rewrite. Rebuilding a path by dropping empty segments turned UNC, scheme:///, and backslash frames into prefixes the frame does not start with. Emit each prefix as a slice of the original path and keep that path's separator. Refs VDY-245 Co-authored-by: Cursor --- .../directory_prefixes.py | 64 ++++++++++--------- .../test_directory_prefixes.py | 25 +++++++- 2 files changed, 56 insertions(+), 33 deletions(-) diff --git a/src/sentry/issues/auto_source_code_config/directory_prefixes.py b/src/sentry/issues/auto_source_code_config/directory_prefixes.py index bb1b837c50f7..7dffc2609a7a 100644 --- a/src/sentry/issues/auto_source_code_config/directory_prefixes.py +++ b/src/sentry/issues/auto_source_code_config/directory_prefixes.py @@ -6,7 +6,7 @@ DEFAULT_PREFIX_CAP = 200 _SLASH = "/" -_APP_SCHEME = "app:///" +_BACKSLASH = "\\" class RankedPrefix(NamedTuple): @@ -15,17 +15,13 @@ class RankedPrefix(NamedTuple): def directory_prefixes(path: str) -> list[str]: - normalized = _normalize_separators(path) - scheme, remainder = _peel_scheme(normalized) - directories = _directory_segments(remainder) - - prefixes: list[str] = [] - built: list[str] = [] - for segment in directories: - built.append(segment) - prefixes.append(_SLASH.join(built) + _SLASH) + 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 @@ -36,39 +32,45 @@ def rank_directory_prefixes( counts: Counter[str] = Counter() seen: set[str] = set() for path in paths: - normalized = _normalize_separators(path) - if normalized in seen: + # src/foo.py and src\foo.py are different stack roots; treat as distinct. + if path in seen: continue - seen.add(normalized) - counts.update(directory_prefixes(normalized)) + 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 _normalize_separators(path: str) -> str: - return path.replace("\\", _SLASH) - - def _peel_scheme(path: str) -> tuple[str, str]: - if path.startswith(_APP_SCHEME): - return _APP_SCHEME, path[len(_APP_SCHEME) :] + # 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 _directory_segments(path: str) -> list[str]: - normalized = _normalize_separators(path) - absolute = normalized.startswith(_SLASH) - segments = [segment for segment in normalized.split(_SLASH) if segment] - if len(segments) <= 1: - return [] +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 - directories = segments[:-1] - if absolute: - directories[0] = f"{_SLASH}{directories[0]}" - return directories + +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 - return (-count, path.count(_SLASH), path) + # 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 index c3e3691b0ce3..901d0c774322 100644 --- a/tests/sentry/issues/auto_source_code_config/test_directory_prefixes.py +++ b/tests/sentry/issues/auto_source_code_config/test_directory_prefixes.py @@ -26,7 +26,10 @@ def test_filename_only_and_empty(self) -> None: assert directory_prefixes("") == [] def test_windows_separators(self) -> None: - assert directory_prefixes("src\\foo\\bar.py") == ["src/", "src/foo/"] + 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") == [] @@ -39,6 +42,21 @@ def test_app_scheme_preserves_triple_slash(self) -> None: 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: @@ -62,8 +80,11 @@ def test_ranks_by_file_count(self) -> None: 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"]) - assert ranked == [("a/", 1)] + 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)]