---
phase: 20-python-method-sub-chunking
plan: "02"
type: tdd
wave: 2
depends_on: ["20-01"]
files_modified:
  - src/corpus_analyzer/ingest/chunker.py
  - tests/ingest/test_chunker.py
autonomous: true
requirements: [SUB-02]

must_haves:
  truths:
    - "chunk_python() produces one ClassName.method_name chunk per method in every class"
    - "All methods (including __init__, __str__, dunder methods, @property, @classmethod, @staticmethod, async def) produce ClassName.method_name chunks"
    - "The monolithic class chunk no longer appears — it is replaced by header + per-method chunks"
    - "Re-indexing the same Python file produces the same chunks deterministically"
    - "Multi-class files: every class gets sub-chunked"
    - "Nested classes inside a class body are treated as opaque content (not recursively sub-chunked)"
  artifacts:
    - path: "src/corpus_analyzer/ingest/chunker.py"
      provides: "_chunk_class() now returns header + per-method chunks"
      contains: "ClassName.method_name"
    - path: "tests/ingest/test_chunker.py"
      provides: "TestChunkPythonMethodChunks test class with SUB-02 cases"
      contains: "TestChunkPythonMethodChunks"
  key_links:
    - from: "_chunk_class()"
      to: "method chunks"
      via: "iterating FunctionDef/AsyncFunctionDef nodes in ClassDef.body"
      pattern: "isinstance.*FunctionDef.*node\\.body"
    - from: "chunk_name"
      to: "ClassName.method_name"
      via: "f'{node.name}.{method.name}'"
      pattern: "f\"{node\\.name}\\."
---

<objective>
Implement per-method chunks (SUB-02): extend _chunk_class() to produce one chunk per method in the class, named ClassName.method_name. The monolithic class chunk is fully replaced by the header chunk (Plan 01) plus one method chunk per method.

Purpose: Each method is independently searchable — LLM callers can find a specific method without reading the entire class.
Output: Extended _chunk_class() in chunker.py with full method chunk extraction, plus TDD tests covering all SUB-02 edge cases.
</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
@.planning/phases/20-python-method-sub-chunking/20-CONTEXT.md

@src/corpus_analyzer/ingest/chunker.py
@tests/ingest/test_chunker.py
@.planning/phases/20-python-method-sub-chunking/20-01-SUMMARY.md
</context>

<tasks>

<task type="tdd">
  <name>Task 1 RED: Failing tests for SUB-02 per-method chunk behaviour</name>
  <files>tests/ingest/test_chunker.py</files>
  <action>
Add a new test class `TestChunkPythonMethodChunks` to tests/ingest/test_chunker.py AFTER the `TestChunkPythonSubChunking` class. Do NOT modify existing tests.

Write the following failing tests (they MUST fail against the current _chunk_class() which only produces a header chunk):

```
class TestChunkPythonMethodChunks:
```

Test cases to implement:

1. `test_class_methods_produce_dot_name_chunks` — a class with two methods produces two method chunks named `MyClass.method_a` and `MyClass.method_b`
   - Input:
     ```python
     class MyClass:
         def method_a(self): pass
         def method_b(self): pass
     ```
   - Assert: any(c["chunk_name"] == "MyClass.method_a" for c in chunks)
   - Assert: any(c["chunk_name"] == "MyClass.method_b" for c in chunks)

2. `test_init_produces_dot_init_chunk` — `__init__` produces a `MyClass.__init__` chunk
   - Input:
     ```python
     class MyClass:
         def __init__(self, x: int) -> None:
             self.x = x
     ```
   - Assert: any(c["chunk_name"] == "MyClass.__init__" for c in chunks)

3. `test_dunder_methods_follow_dot_notation` — `__str__` and `__repr__` produce correctly named chunks
   - Input:
     ```python
     class MyClass:
         def __str__(self) -> str: return "x"
         def __repr__(self) -> str: return "MyClass()"
     ```
   - Assert: any(c["chunk_name"] == "MyClass.__str__" for c in chunks)
   - Assert: any(c["chunk_name"] == "MyClass.__repr__" for c in chunks)

4. `test_property_method_chunked` — `@property` decorated method produces a chunk
   - Input:
     ```python
     class MyClass:
         @property
         def value(self) -> int:
             return self._value
     ```
   - Assert: any(c["chunk_name"] == "MyClass.value" for c in chunks)

5. `test_classmethod_chunked` — `@classmethod` decorated method produces a chunk
   - Input:
     ```python
     class MyClass:
         @classmethod
         def create(cls) -> "MyClass":
             return cls()
     ```
   - Assert: any(c["chunk_name"] == "MyClass.create" for c in chunks)

6. `test_staticmethod_chunked` — `@staticmethod` decorated method produces a chunk
   - Input:
     ```python
     class MyClass:
         @staticmethod
         def helper() -> int:
             return 42
     ```
   - Assert: any(c["chunk_name"] == "MyClass.helper" for c in chunks)

7. `test_async_method_chunked` — `async def` method produces a chunk with dot-notation name
   - Input:
     ```python
     class MyClass:
         async def fetch(self) -> None:
             await something()
     ```
   - Assert: any(c["chunk_name"] == "MyClass.fetch" for c in chunks)

8. `test_method_chunk_text_contains_def_line` — method chunk text starts with the `def` line (or decorator if decorated)
   - Input:
     ```python
     class MyClass:
         def my_method(self) -> None:
             pass
     ```
   - Find chunk with name "MyClass.my_method", assert "def my_method" in chunk["text"]

9. `test_method_chunk_line_range` — method chunk start_line is the decorator/def line, end_line is the last line of the method body
   - Input (class starts on line 1, `def method` on line 2, body on line 3):
     ```python
     class MyClass:
         def method(self) -> None:
             pass
     ```
   - Find method chunk, assert start_line == 2 and end_line == 3

10. `test_no_methods_no_method_chunks` — class with no methods produces no ClassName.method chunks
    - Input: `class Empty:\n    pass\n`
    - Assert: no chunk with chunk_name containing "Empty." exists

11. `test_multi_class_file_all_classes_sub_chunked` — two classes in one file both get sub-chunked
    - Input:
      ```python
      class Alpha:
          def run(self): pass

      class Beta:
          def go(self): pass
      ```
    - Assert: any(c["chunk_name"] == "Alpha.run" for c in chunks)
    - Assert: any(c["chunk_name"] == "Beta.go" for c in chunks)

12. `test_nested_class_treated_as_opaque` — nested class inside a method is NOT recursively sub-chunked
    - Input:
      ```python
      class Outer:
          def method(self):
              class Inner:
                  def inner_method(self): pass
      ```
    - Assert: NOT any(c["chunk_name"] == "Outer.Inner.inner_method" for c in chunks)
    - Assert: any(c["chunk_name"] == "Outer.method" for c in chunks)

13. `test_monolithic_class_chunk_absent` — no chunk named exactly `MyClass` exists when class has methods (header exists, but monolithic-without-methods-removed)
    - Wait: the header chunk IS named `MyClass` (from Plan 01). The monolithic class chunk is gone and replaced by header + method chunks. So this test checks that we get MORE than one chunk for a class with methods.
    - Input: class with 2 methods → assert len([c for c in chunks if c["chunk_name"].startswith("MyClass")]) >= 3 (header + 2 methods)

14. `test_deterministic_output` — calling chunk_python() twice on the same file produces identical results
    - Input: any class with 2 methods written to tmp_path
    - Assert: chunk_python(py_file) == chunk_python(py_file)

Run `uv run pytest tests/ingest/test_chunker.py::TestChunkPythonMethodChunks -v` — ALL tests MUST FAIL (RED).

Commit: `test(20-02): add failing SUB-02 per-method chunk tests`
  </action>
  <verify>
    <automated>uv run pytest tests/ingest/test_chunker.py::TestChunkPythonMethodChunks -v 2>&1 | tail -20</automated>
    <manual>Confirm output shows all 14 new tests FAILED (not errored with ImportError)</manual>
  </verify>
  <done>All 14 new tests in TestChunkPythonMethodChunks fail with AssertionError (RED phase confirmed)</done>
</task>

<task type="tdd">
  <name>Task 2 GREEN: Extend _chunk_class() to produce per-method chunks</name>
  <files>src/corpus_analyzer/ingest/chunker.py</files>
  <action>
Extend `_chunk_class()` to append one chunk per method after the header chunk.

**Method chunk extraction logic (append to result after header chunk):**

```python
for method in node.body:
    if not isinstance(method, (ast.FunctionDef, ast.AsyncFunctionDef)):
        continue

    # Determine start_line: include decorator if present
    if method.decorator_list:
        method_start = method.decorator_list[0].lineno
    else:
        method_start = method.lineno

    method_end = method.end_lineno

    method_text = "\n".join(lines[method_start - 1 : method_end]).rstrip()

    result.append({
        "text": method_text,
        "start_line": method_start,
        "end_line": method_end,
        "chunk_name": f"{node.name}.{method.name}",
        "chunk_text": method_text,
    })
```

**Important notes on nested classes:**
- Only iterate `node.body` (the direct children of the ClassDef), NOT `ast.walk()`. This naturally excludes nested classes because nested ClassDef nodes are not FunctionDef/AsyncFunctionDef instances.

**Important notes on __init__ in method chunks:**
- `__init__` is a FunctionDef and will be iterated like any other method → chunk_name = `ClassName.__init__`
- The header chunk already captures the self-assignment subset of `__init__` — the `__init__` method chunk contains the FULL `__init__` body (including non-assignment lines)
- Both can coexist: header has the "contract view", method chunk has the full implementation

**Header chunk end_line adjustment:**
- After Plan 01 the header chunk ends before the first method (or covers all of `__init__` assignments)
- With methods now getting their own chunks, verify the header chunk `end_line` from Plan 01 is still correct — it should NOT include the method bodies (those are in method chunks)
- If `__init__` is the first method and header ends at last self-assignment, the `__init__` method chunk starts from the decorator/def line — overlap is fine (both chunks cover the `__init__` signature line, that is intentional)

Run `uv run pytest tests/ingest/test_chunker.py::TestChunkPythonMethodChunks -v` — ALL 14 MUST PASS (GREEN).
Also run `uv run pytest tests/ingest/test_chunker.py -v` — all tests including Plan 01 tests MUST still pass.

Commit: `feat(20-02): extend _chunk_class() with per-method chunks for SUB-02`
  </action>
  <verify>
    <automated>uv run pytest tests/ingest/test_chunker.py -v 2>&1 | tail -15</automated>
    <manual>All TestChunkPythonMethodChunks tests pass AND all TestChunkPythonSubChunking tests still pass.</manual>
  </verify>
  <done>All 14 new method-chunk tests pass GREEN. All existing tests in test_chunker.py still pass. _chunk_class() returns header + per-method chunks.</done>
</task>

<task type="auto">
  <name>Task 3: Full regression check — ruff, mypy, full test suite</name>
  <files>src/corpus_analyzer/ingest/chunker.py, tests/ingest/test_chunker.py</files>
  <action>
1. Run `uv run ruff check src/corpus_analyzer/ingest/chunker.py tests/ingest/test_chunker.py` — fix any issues found (line length, unused imports, etc.)

2. Run `uv run mypy src/corpus_analyzer/ingest/chunker.py` — fix any type annotation issues. Ensure `_chunk_class()` has full type annotations: `def _chunk_class(node: ast.ClassDef, lines: list[str]) -> list[dict[str, Any]]:` with a PEP 257 docstring.

3. Run `uv run pytest -v` — full suite. Must be >= 345 tests (the pre-phase count). No regressions outside chunker tests.

4. Run `uv run pytest tests/ingest/ -v` — all ingest tests pass.

5. Run `uv run ruff format src/corpus_analyzer/ingest/chunker.py tests/ingest/test_chunker.py` — apply formatting.

If any test outside `test_chunker.py` fails due to sub-chunking changes (e.g. test_round_trip.py or test_indexer.py making assumptions about class chunk count), update those tests with the same pattern: check chunk_name membership rather than exact count.

Commit: `refactor(20-02): ruff + mypy clean after SUB-02 implementation`
  </action>
  <verify>
    <automated>uv run pytest -v 2>&1 | tail -5 && uv run ruff check src/corpus_analyzer/ingest/chunker.py && uv run mypy src/corpus_analyzer/ingest/chunker.py</automated>
    <manual>Full suite green, ruff clean, mypy clean.</manual>
  </verify>
  <done>Full suite green (>= 345 passing tests), ruff clean, mypy clean. Phase 20 complete: SUB-01 and SUB-02 implemented and tested.</done>
</task>

</tasks>

<verification>
1. `uv run pytest tests/ingest/test_chunker.py -v` — all tests pass including TestChunkPythonMethodChunks
2. `uv run pytest -v` — no regressions, full suite green
3. `uv run ruff check src/corpus_analyzer/ingest/chunker.py` — clean
4. `uv run mypy src/corpus_analyzer/ingest/chunker.py` — clean
5. Verify chunk_name patterns: `grep "chunk_name.*\." tests/ingest/test_chunker.py` shows ClassName.method_name assertions
6. A class with 2 methods produces 3 chunks total: 1 header (ClassName) + 2 method chunks (ClassName.method_a, ClassName.method_b)
7. Monolithic class chunk is gone — no chunk with chunk_name=="ClassName" that spans the full class body including methods
</verification>

<success_criteria>
- _chunk_class() returns header chunk + one method chunk per FunctionDef/AsyncFunctionDef in the class body
- Method chunks named ClassName.method_name for all method types (dunder, property, classmethod, staticmethod, async)
- Nested class inside method is NOT recursively sub-chunked (opaque content in method chunk)
- Multi-class files: all classes sub-chunked
- Empty/enum-style classes (no methods): header only, no method chunks
- 14 new SUB-02 tests pass
- All Plan 01 tests still pass
- ruff + mypy clean, full test suite green
</success_criteria>

<output>
After completion, create `.planning/phases/20-python-method-sub-chunking/20-02-SUMMARY.md`
</output>
