---
phase: 22-name-filtering
plan: 01
type: tdd
wave: 1
depends_on: []
files_modified:
  - src/corpus_analyzer/search/engine.py
  - tests/search/test_engine.py
autonomous: true
requirements:
  - NAME-01
  - NAME-03

must_haves:
  truths:
    - "hybrid_search(name='foo') returns only chunks whose chunk_name contains 'foo' (case-insensitive)"
    - "hybrid_search(name='ClassName.method') returns only the matching method chunk"
    - "hybrid_search(query='') with name='foo' returns name-matching chunks without requiring text match"
    - "hybrid_search(name=None) is unchanged from current behaviour"
    - "name filter composes with source, file_type, construct_type, min_score, sort_by"
  artifacts:
    - path: "src/corpus_analyzer/search/engine.py"
      provides: "hybrid_search() with name parameter"
      contains: "def hybrid_search"
      exports: ["CorpusSearch"]
    - path: "tests/search/test_engine.py"
      provides: "TDD tests for name filter and empty-query name-only search"
  key_links:
    - from: "tests/search/test_engine.py"
      to: "src/corpus_analyzer/search/engine.py"
      via: "CorpusSearch.hybrid_search(name=...)"
      pattern: "hybrid_search.*name="
---

<objective>
Add `name: str | None = None` parameter to `CorpusSearch.hybrid_search()` that applies a case-insensitive substring filter on `chunk_name` after retrieval. Also support empty-query (name-only) search: when the query string is empty or whitespace-only, skip the text-term filter so name filtering alone can surface results.

Purpose: Engine contract for NAME-01 (name filter) and NAME-03 (name-only search with no positional query).
Output: Updated `engine.py` with `name` parameter; full TDD test coverage in `test_engine.py`.
</objective>

<execution_context>
@/home/ollie/.claude/get-shit-done/workflows/execute-plan.md
@/home/ollie/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@src/corpus_analyzer/search/engine.py
@tests/search/test_engine.py
</context>

<feature>
  <name>Engine name filter + empty-query support</name>
  <files>
    src/corpus_analyzer/search/engine.py
    tests/search/test_engine.py
  </files>
  <behavior>
    hybrid_search(query, *, name=None, ...) — new optional parameter.

    Name filter semantics:
    - Applied post-retrieval, after the text-term match pass and min_score pass
    - Case-insensitive: chunk_name.lower().find(name.lower()) != -1
    - name=None means no name filtering (default, backward-compatible)
    - name="" is treated as None (no filter) — empty fragment matches nothing useful

    Empty-query semantics (NAME-03):
    - When query.strip() == "" the text-term match block is skipped entirely
    - All rows returned by LanceDB builder pass through to name/min_score filtering
    - The LanceDB hybrid search still runs with query="" — this is fine for LanceDB
    - Note: when query is empty, embed_batch([""])[0] is called — engine does not validate query content

    Cases:
      hybrid_search("tool", name=None)          -> unchanged existing behaviour
      hybrid_search("tool", name="foo")          -> only rows where "foo" in chunk_name (ci)
      hybrid_search("tool", name="Cls.method")   -> only rows where "Cls.method" in chunk_name (ci)
      hybrid_search("", name="foo")              -> skip text-term filter; name filter applied
      hybrid_search("", name=None)               -> skip text-term filter; all rows returned (no limit via name)
      hybrid_search("tool", name="foo", source="src") -> AND: source filter + name filter both apply

    Compose with existing filters:
      source, file_type, construct_type applied as LanceDB WHERE (unchanged)
      min_score applied after name filter
      sort_by applied last (unchanged)
  </behavior>
  <implementation>
    In hybrid_search():
    1. Add `name: str | None = None` to signature after `min_score`.
    2. Replace the current text-term filter block:
       ```python
       query_terms = {t for t in query.lower().split() if t}
       if not query_terms:
           return results
       filtered = [r for r in results if any(term in str(r.get("text", "")).lower() for term in query_terms)]
       ```
       With:
       ```python
       query_terms = {t for t in query.lower().split() if t}
       if query_terms:
           results = [r for r in results if any(term in str(r.get("text", "")).lower() for term in query_terms)]
       # else: empty query — skip text-term filter (name-only search)
       ```
    3. After the text-term filter and before min_score, apply name filter:
       ```python
       if name:
           name_lower = name.lower()
           results = [r for r in results if name_lower in str(r.get("chunk_name", "") or "").lower()]
       ```
    4. Existing min_score and sort_by blocks remain unchanged and run after name filter.
    5. Update docstring to document `name` parameter.
  </implementation>
</feature>

<tasks>

<task type="tdd">
  <name>Task 1: RED — write failing tests for name filter and empty-query support</name>
  <files>tests/search/test_engine.py</files>
  <action>
    Add a new test class `TestHybridSearchNameFilter` to `tests/search/test_engine.py`. Use the existing `seeded_table` fixture (which already has rows with `chunk_name=""`).

    First, add a dedicated name-filter fixture with rows that have non-empty chunk_name values:

    ```python
    @pytest.fixture
    def named_table(tmp_path: Path):
        """Table seeded with rows that have distinct chunk_names for name-filter tests."""
        db = lancedb.connect(str(tmp_path / "named_idx"))
        table = db.create_table("chunks", schema=ChunkRecord, mode="overwrite")
        ensure_schema_v2(table)
        now = datetime.now(UTC).isoformat()
        rows: list[dict[str, object]] = [
            {
                "chunk_id": "n1",
                "file_path": "/repo/src/search.py",
                "source_name": "src",
                "text": "def search foo bar baz",
                "vector": [0.1] * 768,
                "start_line": 1, "end_line": 5,
                "file_type": ".py",
                "content_hash": "h1",
                "embedding_model": "nomic-embed-text",
                "indexed_at": now,
                "construct_type": "function",
                "summary": None,
                "chunk_name": "SearchEngine.search",
                "chunk_text": "def search(self): ...",
            },
            {
                "chunk_id": "n2",
                "file_path": "/repo/src/index.py",
                "source_name": "src",
                "text": "def index foo bar baz",
                "vector": [0.2] * 768,
                "start_line": 10, "end_line": 20,
                "file_type": ".py",
                "content_hash": "h2",
                "embedding_model": "nomic-embed-text",
                "indexed_at": now,
                "construct_type": "function",
                "summary": None,
                "chunk_name": "SearchEngine.index",
                "chunk_text": "def index(self): ...",
            },
            {
                "chunk_id": "n3",
                "file_path": "/repo/src/util.py",
                "source_name": "src",
                "text": "def helper foo bar baz",
                "vector": [0.3] * 768,
                "start_line": 1, "end_line": 5,
                "file_type": ".py",
                "content_hash": "h3",
                "embedding_model": "nomic-embed-text",
                "indexed_at": now,
                "construct_type": "function",
                "summary": None,
                "chunk_name": "HelperClass.run",
                "chunk_text": "def run(self): ...",
            },
        ]
        table.add(rows)
        table.create_fts_index("text", replace=True)
        return table
    ```

    Then add `TestHybridSearchNameFilter`:

    ```python
    class TestHybridSearchNameFilter:
        """NAME-01/NAME-03: Tests for the name= filter and empty-query name-only search."""

        def test_name_filter_returns_matching_chunks(self, named_table) -> None:
            """NAME-01: name='search' returns only chunks whose chunk_name contains 'search' (case-insensitive)."""
            search = CorpusSearch(named_table, MockEmbedder())
            results = search.hybrid_search("foo", name="search")
            assert results
            assert all("search" in str(r.get("chunk_name", "")).lower() for r in results)

        def test_name_filter_case_insensitive(self, named_table) -> None:
            """NAME-01: name filter is case-insensitive ('SEARCH' matches 'SearchEngine.search')."""
            search = CorpusSearch(named_table, MockEmbedder())
            results = search.hybrid_search("foo", name="SEARCH")
            assert results
            assert all("search" in str(r.get("chunk_name", "")).lower() for r in results)

        def test_name_filter_dot_notation(self, named_table) -> None:
            """NAME-01: name='SearchEngine.search' narrows to that specific method chunk."""
            search = CorpusSearch(named_table, MockEmbedder())
            results = search.hybrid_search("foo", name="SearchEngine.search")
            assert len(results) == 1
            assert results[0]["chunk_name"] == "SearchEngine.search"

        def test_name_filter_no_match_returns_empty(self, named_table) -> None:
            """NAME-01: name filter with no matching chunk_name returns empty list."""
            search = CorpusSearch(named_table, MockEmbedder())
            results = search.hybrid_search("foo", name="NonExistentClass.method")
            assert results == []

        def test_name_none_is_noop(self, named_table) -> None:
            """NAME-01: name=None behaves identically to omitting the parameter."""
            search = CorpusSearch(named_table, MockEmbedder())
            without_name = search.hybrid_search("foo")
            with_none = search.hybrid_search("foo", name=None)
            assert with_none == without_name

        def test_empty_query_with_name_skips_text_filter(self, named_table) -> None:
            """NAME-03: empty query + name filter returns name-matching chunks without requiring text match."""
            search = CorpusSearch(named_table, MockEmbedder())
            results = search.hybrid_search("", name="helper")
            assert results
            assert all("helper" in str(r.get("chunk_name", "")).lower() for r in results)

        def test_name_composes_with_construct_filter(self, named_table) -> None:
            """NAME-01: name filter composes with construct_type filter as AND predicate."""
            search = CorpusSearch(named_table, MockEmbedder())
            results = search.hybrid_search("foo", name="search", construct_type="function")
            assert results
            assert all("search" in str(r.get("chunk_name", "")).lower() for r in results)
            assert all(r.get("construct_type") == "function" for r in results)
    ```

    Run `uv run pytest tests/search/test_engine.py::TestHybridSearchNameFilter -x` — all tests must FAIL (RED).
  </action>
  <verify>
    <automated>uv run pytest tests/search/test_engine.py::TestHybridSearchNameFilter -x 2>&1 | tail -20</automated>
    <manual>Confirm tests fail with AttributeError or TypeError (name= parameter doesn't exist yet)</manual>
    <sampling_rate>run after this task, before Task 2</sampling_rate>
  </verify>
  <done>All TestHybridSearchNameFilter tests fail RED — name= parameter not yet in hybrid_search signature</done>
</task>

<task type="tdd">
  <name>Task 2: GREEN — implement name filter and empty-query support in engine.py</name>
  <files>src/corpus_analyzer/search/engine.py</files>
  <action>
    Modify `hybrid_search()` in `src/corpus_analyzer/search/engine.py`:

    1. Add `name: str | None = None` to signature (after `min_score: float = 0.0`):
       ```python
       def hybrid_search(
           self,
           query: str,
           *,
           source: str | None = None,
           file_type: str | None = None,
           construct_type: str | None = None,
           limit: int = 10,
           sort_by: str = "relevance",
           min_score: float = 0.0,
           name: str | None = None,
       ) -> list[dict[str, Any]]:
       ```

    2. Update docstring to document the new parameter:
       ```
       name: Optional case-insensitive substring filter on chunk_name. None means no filter.
           When query is empty/whitespace-only, the text-term match check is skipped so
           name filtering alone can surface results (name-only search, NAME-03).
       ```

    3. Replace the text-term filter block (lines starting `query_terms = ...`):
       BEFORE:
       ```python
       query_terms = {t for t in query.lower().split() if t}
       if not query_terms:
           return results

       filtered = [
           r
           for r in results
           if any(term in str(r.get("text", "")).lower() for term in query_terms)
       ]
       ```
       AFTER:
       ```python
       query_terms = {t for t in query.lower().split() if t}
       if query_terms:
           results = [
               r
               for r in results
               if any(term in str(r.get("text", "")).lower() for term in query_terms)
           ]
       # else: empty query — skip text-term filter (name-only search, NAME-03)
       ```
       Note: change variable name from `filtered` to reassign `results` for consistency with the rest of the method.

    4. Add name filter block immediately after the text-term filter block (before `if min_score > 0.0:`):
       ```python
       if name:
           name_lower = name.lower()
           results = [
               r for r in results
               if name_lower in str(r.get("chunk_name", "") or "").lower()
           ]
       ```

    5. Update all subsequent references from `filtered` to `results` (if the variable was renamed).

    After implementation, run full test suite to ensure no regressions:
    `uv run pytest tests/search/test_engine.py -x`
    Then run ruff and mypy:
    `uv run ruff check src/corpus_analyzer/search/engine.py`
    `uv run mypy src/corpus_analyzer/search/engine.py`
  </action>
  <verify>
    <automated>uv run pytest tests/search/test_engine.py -x && uv run ruff check src/corpus_analyzer/search/engine.py && uv run mypy src/corpus_analyzer/search/engine.py</automated>
    <sampling_rate>run after this task commits, before next plan begins</sampling_rate>
  </verify>
  <done>
    All TestHybridSearchNameFilter tests pass GREEN.
    All existing engine tests continue to pass (no regression).
    ruff and mypy report zero errors on engine.py.
  </done>
</task>

</tasks>

<verification>
uv run pytest tests/search/test_engine.py -v
uv run ruff check src/corpus_analyzer/search/engine.py
uv run mypy src/corpus_analyzer/search/engine.py
</verification>

<success_criteria>
- hybrid_search() accepts name: str | None = None parameter
- name filter is case-insensitive substring match on chunk_name
- Empty query skips text-term filter, enabling name-only search
- name=None is backward-compatible (no behaviour change for existing callers)
- All TestHybridSearchNameFilter tests pass
- All existing engine tests pass (no regression)
- ruff and mypy clean
</success_criteria>

<output>
After completion, create `.planning/phases/22-name-filtering/22-name-filtering-01-SUMMARY.md`
</output>
