diff --git a/benchmarks/data.py b/benchmarks/data.py index 610b0533e..fb87df718 100644 --- a/benchmarks/data.py +++ b/benchmarks/data.py @@ -141,10 +141,10 @@ def apply_task_filters( def path_matches(file_path: str, target_path: str) -> bool: - """Return True if file_path ends with target_path (file-level match, no line span).""" + """Return True if either path is a suffix of the other (handles absolute vs relative paths).""" norm_file = file_path.replace("\\", "/") norm_target = target_path.replace("\\", "/") - return norm_file == norm_target or norm_file.endswith(f"/{norm_target}") + return norm_file == norm_target or norm_file.endswith(f"/{norm_target}") or norm_target.endswith(f"/{norm_file}") def target_matches_location(file_path: str, start_line: int, end_line: int, target: Target) -> bool: diff --git a/src/semble/index/create.py b/src/semble/index/create.py index 960be80ca..1858f3b26 100644 --- a/src/semble/index/create.py +++ b/src/semble/index/create.py @@ -52,6 +52,6 @@ def create_index_from_path( args = BasicArgs() semantic_index = SelectableBasicBackend(embeddings, args) else: - raise ValueError("Unable to create index.") + raise ValueError(f"No supported files found under {path}.") return bm25_index, semantic_index, chunks diff --git a/src/semble/index/index.py b/src/semble/index/index.py index 17f67883e..2be64b784 100644 --- a/src/semble/index/index.py +++ b/src/semble/index/index.py @@ -11,7 +11,6 @@ from semble.index.create import create_index_from_path from semble.index.dense import SelectableBasicBackend, load_model -from semble.index.sparse import selector_to_mask from semble.search import search_bm25, search_hybrid, search_semantic from semble.types import Chunk, Encoder, IndexStats, SearchMode, SearchResult @@ -25,7 +24,6 @@ def __init__( bm25_index: BM25, semantic_index: SelectableBasicBackend, chunks: list[Chunk], - index_root: Path, ) -> None: """Configure the index. @@ -33,13 +31,11 @@ def __init__( :param bm25_index: The bm25 index. :param semantic_index: The semantic index. :param chunks: The found chunks. - :param index_root: The root of the index. """ self.model: Encoder = model self.chunks: list[Chunk] = chunks self._bm25_index: BM25 = bm25_index self._semantic_index: SelectableBasicBackend = semantic_index - self._index_root: Path = index_root self.file_mapping, self.language_mapping = self._populate_mapping() def _populate_mapping(self) -> tuple[dict[str, list[int]], dict[str, list[int]]]: @@ -84,15 +80,23 @@ def from_path( :param extensions: File extensions to include. Defaults to a standard set of code extensions. :param ignore: Directory names to skip. Defaults to common VCS and build dirs. :param include_docs: If True, also index documentation files (.md, .yaml, etc.). - :return: An indexed SembleIndex. + :return: An indexed SembleIndex. Chunk file paths are relative to ``path``. + :raises FileNotFoundError: If `path` does not exist. + :raises NotADirectoryError: If `path` exists but is not a directory. + :raises ValueError: If `path` is a directory but contains no supported files. """ model = model or load_model() path = Path(path) + if not path.exists(): + raise FileNotFoundError(f"Path does not exist: {path}") + if not path.is_dir(): + raise NotADirectoryError(f"Path is not a directory: {path}") + path = path.resolve() bm25, vicinity, chunks = create_index_from_path( - path, model=model, extensions=extensions, ignore=ignore, include_docs=include_docs + path, model=model, extensions=extensions, ignore=ignore, include_docs=include_docs, display_root=path ) - index = SembleIndex(model, bm25, vicinity, chunks, path) + index = SembleIndex(model, bm25, vicinity, chunks) return index @@ -118,7 +122,8 @@ def from_git( :raises RuntimeError: If git is not on PATH or the clone fails. """ with tempfile.TemporaryDirectory() as tmp_dir: - cmd = ["git", "clone", "--depth", "1", *(["--branch", ref] if ref else []), url, tmp_dir] + # `--` prevents `url` from being interpreted as a git option (e.g. `--upload-pack=...`). + cmd = ["git", "clone", "--depth", "1", *(["--branch", ref] if ref else []), "--", url, tmp_dir] try: result = subprocess.run(cmd, capture_output=True, text=True, stdin=subprocess.DEVNULL) except FileNotFoundError: @@ -136,7 +141,7 @@ def from_git( display_root=resolved_path, ) - index = SembleIndex(model, bm25, vicinity, chunks, resolved_path) + index = SembleIndex(model, bm25, vicinity, chunks) return index @@ -144,8 +149,7 @@ def find_related(self, file_path: str, line: int, top_k: int = 5) -> list[Search """Return chunks semantically similar to the chunk at the given file location. :param file_path: Path to the file, in the same format stored by the index. - For indexes built with `from_path` this is an absolute path; for - indexes built with `from_git` this is a repo-relative path + For both `from_path` and `from_git` this is a repo-relative path (e.g. ``src/foo.py``). Use `chunk.file_path` from a prior search result to guarantee the correct format. :param line: Line number (1-indexed) used to identify the source chunk. diff --git a/src/semble/mcp.py b/src/semble/mcp.py index 4d691ebbb..f4bae50d4 100644 --- a/src/semble/mcp.py +++ b/src/semble/mcp.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import re from pathlib import Path from typing import Annotated, Literal @@ -147,9 +148,14 @@ async def get(self, source: str, ref: str | None = None) -> SembleIndex: raise +_GIT_URL_SCHEMES = ("https://", "http://", "ssh://", "git://", "git+ssh://", "file://") +# scp-like syntax: [user@]host:path, where host has no '/' before the ':'. +_SCP_GIT_URL_RE = re.compile(r"^[\w.-]+@[\w.-]+:(?!/)") + + def _is_git_url(path: str) -> bool: """Return True if path looks like a remote git URL rather than a local path.""" - return path.startswith(("https://", "http://", "git@", "ssh://")) + return path.startswith(_GIT_URL_SCHEMES) or _SCP_GIT_URL_RE.match(path) is not None def _format_results(header: str, results: list[SearchResult]) -> str: