Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/aci/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions src/aci/core/path_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
9 changes: 7 additions & 2 deletions src/aci/http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion src/aci/mcp/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -243,14 +244,16 @@ 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

# Pass collection_name explicitly to avoid shared state mutation
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,
Expand Down
25 changes: 25 additions & 0 deletions tests/unit/test_runtime_path_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading