diff --git a/src/aci/cli/__init__.py b/src/aci/cli/__init__.py index efb018d..cd3ded7 100644 --- a/src/aci/cli/__init__.py +++ b/src/aci/cli/__init__.py @@ -22,7 +22,11 @@ from rich.syntax import Syntax from rich.table import Table -from aci.core.path_utils import get_collection_name_for_path, validate_indexable_path +from aci.core.path_utils import ( + get_collection_name_for_path, + resolve_file_filter_pattern, + validate_indexable_path, +) from aci.infrastructure import GrepSearcher from aci.infrastructure.codebase_registry import ( CodebaseRegistryStore, @@ -236,6 +240,7 @@ def search( console.print(f"[bold red]Error:[/bold red] {resolution.error_message}") raise typer.Exit(1) collection_name = resolution.collection_name + normalized_file_filter = resolve_file_filter_pattern(file_filter, resolution.indexed_root) # Use config values if not overridden by CLI actual_limit = limit if limit is not None else cfg.search.default_limit @@ -291,7 +296,7 @@ def search( search_service.search( query=query, limit=actual_limit, - file_filter=file_filter, # User-provided filter only + file_filter=normalized_file_filter, use_rerank=use_rerank and reranker is not None, search_mode=search_mode, # Pass search mode collection_name=collection_name, # Pass explicitly, no state mutation diff --git a/src/aci/core/path_utils.py b/src/aci/core/path_utils.py index a1ad9ac..bcbc8b4 100644 --- a/src/aci/core/path_utils.py +++ b/src/aci/core/path_utils.py @@ -173,6 +173,41 @@ def parse_runtime_path_mappings(raw_value: str | None) -> list[RuntimePathMappin return mappings +def resolve_file_filter_pattern( + file_filter: str | None, indexed_root: str | Path | None +) -> str | None: + """Resolve relative file-filter prefixes against the indexed root path. + + Keeps broad wildcard-only patterns unchanged (e.g. ``*.py`` or ``**/*.py``), + but expands relative directory-prefixed patterns (e.g. ``src/**/*.py``) + to absolute patterns rooted at ``indexed_root``. + """ + if not file_filter: + return file_filter + if indexed_root is None: + return file_filter + + raw_filter = file_filter.strip() + if not raw_filter: + return raw_filter + + # Already absolute (POSIX, Windows drive, or UNC path). + if raw_filter.startswith("/") or _looks_like_windows_path(raw_filter) or raw_filter.startswith("\\\\"): + return raw_filter + + normalized = raw_filter.replace("\\", "/") + has_directory_prefix = "/" in normalized + starts_with_wildcard = normalized.startswith(("*", "?", "[")) + if not has_directory_prefix or starts_with_wildcard: + return raw_filter + + relative_filter = normalized.lstrip("./") + if not relative_filter: + return raw_filter + + return str(Path(indexed_root).resolve() / Path(relative_filter)) + + def _apply_runtime_path_mapping( path_str: str, path_mappings: Sequence[RuntimePathMapping], diff --git a/src/aci/http_server.py b/src/aci/http_server.py index e17cb9e..51bead8 100644 --- a/src/aci/http_server.py +++ b/src/aci/http_server.py @@ -11,7 +11,11 @@ from fastapi import FastAPI, HTTPException, Query from pydantic import BaseModel -from aci.core.path_utils import get_collection_name_for_path, is_system_directory +from aci.core.path_utils import ( + get_collection_name_for_path, + is_system_directory, + resolve_file_filter_pattern, +) from aci.core.watch_config import WatchConfig from aci.infrastructure.codebase_registry import best_effort_update_registry from aci.infrastructure.file_watcher import FileWatcher @@ -330,6 +334,7 @@ async def search( raise HTTPException(status_code=400, detail=resolution.error_message) collection_name = resolution.collection_name + normalized_file_filter = resolve_file_filter_pattern(file_filter, resolution.indexed_root) apply_rerank = cfg.search.use_rerank if use_rerank is None else use_rerank @@ -363,7 +368,7 @@ async def search( results = await search_service.search( query=q, limit=limit, - file_filter=file_filter, + file_filter=normalized_file_filter, use_rerank=apply_rerank, search_mode=search_mode, collection_name=collection_name, diff --git a/src/aci/mcp/handlers.py b/src/aci/mcp/handlers.py index 3f3981d..324fed8 100644 --- a/src/aci/mcp/handlers.py +++ b/src/aci/mcp/handlers.py @@ -11,6 +11,7 @@ from aci.core.path_utils import ( RuntimePathResolutionResult, get_collection_name_for_path, + resolve_file_filter_pattern, resolve_runtime_path, validate_indexable_path, ) @@ -243,6 +244,8 @@ async def _handle_search_code(arguments: dict, ctx: MCPContext) -> list[TextCont f"Valid types: {', '.join(sorted(valid_artifact_types))}" )] + normalized_file_filter = resolve_file_filter_pattern(file_filter, indexed_root) + # Request more results if filtering by subdirectory (to ensure enough after filtering) fetch_limit = limit * 3 if path_prefix_filter else limit @@ -250,7 +253,7 @@ async def _handle_search_code(arguments: dict, ctx: MCPContext) -> list[TextCont results = await search_service.search( query=query, limit=fetch_limit, - file_filter=file_filter, + file_filter=normalized_file_filter, use_rerank=use_rerank, search_mode=search_mode, collection_name=collection_name, diff --git a/tests/unit/test_runtime_path_resolution.py b/tests/unit/test_runtime_path_resolution.py index 2749f0b..60a1f13 100644 --- a/tests/unit/test_runtime_path_resolution.py +++ b/tests/unit/test_runtime_path_resolution.py @@ -107,3 +107,28 @@ def test_mcp_index_codebase_uses_resolved_runtime_path(tmp_path: Path): payload = json.loads(result[0].text) assert payload["requested_path"] == r"D:\workspace" assert payload["indexed_path"] == str(mounted_repo.resolve()) + + +def test_resolve_file_filter_pattern_keeps_wildcard_only_pattern(tmp_path: Path): + from aci.core.path_utils import resolve_file_filter_pattern + + resolved = resolve_file_filter_pattern("**/*.tsx", tmp_path) + + assert resolved == "**/*.tsx" + + +def test_resolve_file_filter_pattern_expands_relative_prefixed_pattern(tmp_path: Path): + from aci.core.path_utils import resolve_file_filter_pattern + + resolved = resolve_file_filter_pattern("apps/web/**/*.tsx", tmp_path) + + assert resolved == str(tmp_path.resolve() / "apps/web/**/*.tsx") + + +def test_resolve_file_filter_pattern_keeps_absolute_pattern(tmp_path: Path): + from aci.core.path_utils import resolve_file_filter_pattern + + absolute_pattern = str(tmp_path / "apps/**/*.tsx") + resolved = resolve_file_filter_pattern(absolute_pattern, "/another/root") + + assert resolved == absolute_pattern