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

must_haves:
  truths:
    - "chunk_python() produces a ClassName header chunk for every class in a .py file"
    - "The header chunk contains class decorators, class signature, class-level attributes, docstring, and __init__ self.x = value assignments up to the first non-assignment statement"
    - "The header chunk is named exactly ClassName (not ClassName.__init__)"
    - "A class with no methods and no __init__ still produces a header-only chunk"
    - "A class with __init__ that has no self-assignments still produces a header chunk covering the full __init__ body"
    - "Top-level functions alongside classes are unaffected — still one chunk per function"
  artifacts:
    - path: "src/corpus_analyzer/ingest/chunker.py"
      provides: "_chunk_class() helper that extracts header chunk from a ClassDef AST node"
      contains: "_chunk_class"
    - path: "tests/ingest/test_chunker.py"
      provides: "TestChunkPythonSubChunking class with RED tests for SUB-01 header behaviour"
      contains: "TestChunkPythonSubChunking"
  key_links:
    - from: "chunk_python()"
      to: "_chunk_class()"
      via: "called for each ClassDef node in tree.body"
      pattern: "_chunk_class\\("
    - from: "_chunk_class()"
      to: "ast.ClassDef"
      via: "AST node inspection for attributes and __init__ body"
      pattern: "isinstance.*ClassDef|node\\.body"
---

<objective>
Implement the class header chunk (SUB-01): when chunk_python() encounters a ClassDef, extract a header chunk containing everything that describes the class shape — decorators, signature, class-level attributes, docstring, and __init__ self-assignments — stopping at the first non-assignment statement in __init__.

Purpose: LLM callers searching for a class get a focused "class contract" chunk, not the entire class body.
Output: _chunk_class() helper in chunker.py, full TDD test coverage for header extraction 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
</context>

<tasks>

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

Write the following failing tests (they MUST fail against the current chunk_python() implementation):

```
class TestChunkPythonSubChunking:
```

Test cases to implement:

1. `test_class_with_methods_produces_header_chunk` — a class with two methods produces a chunk named exactly `MyClass` (the header), not a monolithic class chunk
   - Input: `class MyClass:\n    def method_a(self): pass\n    def method_b(self): pass\n`
   - Assert: any(c["chunk_name"] == "MyClass" for c in chunks)

2. `test_class_header_contains_class_signature` — header chunk text starts with `class MyClass:`
   - Input: `class MyClass:\n    def method_a(self): pass\n`
   - Assert: header chunk text startswith "class MyClass:"

3. `test_class_header_includes_init_self_assignments` — header chunk includes `self.x = value` lines from `__init__` when there are only assignments
   - Input:
     ```python
     class MyClass:
         def __init__(self, x: int) -> None:
             self.x = x
             self.y = 0
     ```
   - Assert: "self.x = x" in header_chunk["text"] and "self.y = 0" in header_chunk["text"]

4. `test_class_header_stops_at_first_non_assignment_in_init` — header chunk does NOT include lines after the first non-assignment statement in `__init__`
   - Input:
     ```python
     class MyClass:
         def __init__(self, x: int) -> None:
             self.x = x
             self._setup()  # non-assignment call
             self.y = 1
     ```
   - Assert: "self.x = x" in header_chunk["text"], "self._setup()" NOT in header_chunk["text"], "self.y = 1" NOT in header_chunk["text"]

5. `test_class_without_init_produces_header_chunk` — class with no `__init__` still produces a header chunk
   - Input: `class Empty:\n    pass\n`
   - Assert: any(c["chunk_name"] == "Empty" for c in chunks)

6. `test_class_with_no_methods_header_only` — class with no methods (enum-style) produces exactly one chunk named ClassName
   - Input:
     ```python
     class Status:
         ACTIVE = "active"
         INACTIVE = "inactive"
     ```
   - Assert: exactly 1 chunk, chunk_name == "Status"

7. `test_class_decorators_in_header` — class decorator appears in the header chunk text
   - Input:
     ```python
     @dataclass
     class Point:
         x: float
         y: float
     ```
   - Assert: "@dataclass" in header_chunk["text"]

8. `test_class_level_attributes_in_header` — class-level attribute defined outside `__init__` appears in header chunk
   - Input:
     ```python
     class Cfg:
         KEY = "value"
         def method(self): pass
     ```
   - Assert: 'KEY = "value"' in header_chunk["text"]

9. `test_top_level_function_unchanged_alongside_class` — top-level function alongside class is still a single chunk with its own name
   - Input:
     ```python
     def top_func():
         pass

     class MyClass:
         def method(self): pass
     ```
   - Assert: any(c["chunk_name"] == "top_func" for c in chunks)

10. `test_header_chunk_line_range` — header chunk start_line is the class decorator or class keyword line
    - Input: simple class starting on line 1 — assert header_chunk["start_line"] == 1

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

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

<task type="tdd">
  <name>Task 2 GREEN: Implement _chunk_class() header extraction in chunk_python()</name>
  <files>src/corpus_analyzer/ingest/chunker.py</files>
  <action>
Implement `_chunk_class()` as a new private helper in chunker.py, and modify `chunk_python()` to call it for each ClassDef node.

**`_chunk_class()` signature:**
```python
def _chunk_class(
    node: ast.ClassDef, lines: list[str]
) -> list[dict[str, Any]]:
```

**Header chunk logic:**

The header chunk covers: class decorators (if any, from `node.decorator_list`) through to the end of the content that defines the "class contract".

Determine `header_end_line` as follows:
1. Start with `end_of_header = node.end_lineno` (whole class, will be trimmed)
2. If the class has methods (FunctionDef or AsyncFunctionDef nodes in `node.body`):
   - Find the FIRST method's `lineno` → header covers lines up to `first_method.lineno - 1`
   - EXCEPTION: if `__init__` is the FIRST method (by position in `node.body`), scan its body for self-assignments:
     - Walk `__init__.body` using `ast.walk()` approach — iterate `init_node.body` directly (not recursively) statement by statement
     - Include all leading `ast.Assign` or `ast.AugAssign` or `ast.AnnAssign` statements where the target is `self.x` (i.e. target is `ast.Attribute` with `value` being `ast.Name` with `id == "self"`)
     - Stop at the FIRST statement that is NOT one of those self-assignments
     - If all statements are self-assignments, include all of `__init__` body in header
     - The header end_line = last included `__init__` statement's `end_lineno`
   - If `__init__` is NOT the first method, header ends at `first_method.lineno - 1`
3. If the class has NO methods: header covers the entire class body → `end_line = node.end_lineno`

Determine `start_line`:
- If `node.decorator_list`: `start_line = node.decorator_list[0].lineno`
- Else: `start_line = node.lineno`

Extract text:
```python
chunk_text = "\n".join(lines[start_line - 1 : header_end_line]).rstrip()
```

Return a list with one dict:
```python
[{
    "text": chunk_text,
    "start_line": start_line,
    "end_line": header_end_line,
    "chunk_name": node.name,
    "chunk_text": chunk_text,
}]
```

**Modify `chunk_python()`:**
Replace the current `for i, node in enumerate(top_level_nodes)` loop so that when `isinstance(node, ast.ClassDef)`:
- Call `chunks.extend(_chunk_class(node, lines))` instead of the current direct chunk dict append
- The method chunks will be added in Plan 02; for now just the header is returned from `_chunk_class()`
- FunctionDef / AsyncFunctionDef nodes: keep the existing logic unchanged

Run `uv run pytest tests/ingest/test_chunker.py::TestChunkPythonSubChunking -v` — ALL 10 new tests MUST PASS (GREEN).

Also run `uv run pytest tests/ingest/test_chunker.py -v` to check existing tests — some will now fail (e.g. `test_nested_not_separate` expected 1 chunk for `class Outer` with a method; `test_top_level_functions` expected class chunk count). Note failures but DO NOT fix yet (Plan 02 handles that).

Commit: `feat(20-01): implement _chunk_class() header extraction for SUB-01`
  </action>
  <verify>
    <automated>uv run pytest tests/ingest/test_chunker.py::TestChunkPythonSubChunking -v 2>&1 | tail -10</automated>
    <manual>All 10 new tests pass. Note which existing tests now fail — this is expected and will be addressed in Plan 02.</manual>
  </verify>
  <done>All 10 TestChunkPythonSubChunking tests pass GREEN. _chunk_class() exists in chunker.py and is called from chunk_python() for ClassDef nodes.</done>
</task>

<task type="tdd">
  <name>Task 3: Regression check and REFACTOR</name>
  <files>src/corpus_analyzer/ingest/chunker.py, tests/ingest/test_chunker.py</files>
  <action>
**REFACTOR phase: clean up implementation and migrate broken existing tests.**

1. **Migrate broken existing tests** in `TestChunkPython`:
   - `test_nested_not_separate`: the class `Outer` with method `method` will now produce a header chunk (name="Outer") plus (in future) a method chunk. Update assertion: assert any(c["chunk_name"] == "Outer" for c in chunks) instead of len(chunks) == 1, but keep the assertion that "class Outer:" is in the header text.
   - `test_top_level_functions`: `class MyClass` with just `pass` is now header-only → still 1 chunk for the class. The test input has `def first_func`, `class MyClass`, `def second_func` — count may change. Update assertion to use chunk_name membership check instead of `len(chunks) == 3`: assert any(c["chunk_name"] == "first_func" for c in chunks) and any(c["chunk_name"] == "MyClass" for c in chunks) and any(c["chunk_name"] == "second_func" for c in chunks).
   - Check `test_line_ranges_correct`: uses `class Second` with just `pass` — should still be 1 chunk. Verify line ranges are still correct with new logic.
   - Any other test that used `class` construct and asserted monolithic chunk behaviour.

2. **Run full test suite**: `uv run pytest tests/ingest/test_chunker.py -v` — ALL tests must pass.

3. **Run ruff**: `uv run ruff check src/corpus_analyzer/ingest/chunker.py tests/ingest/test_chunker.py` — must be clean.

4. **Run mypy**: `uv run mypy src/corpus_analyzer/ingest/chunker.py` — must be clean.

5. **Run full suite**: `uv run pytest -v` — must be >= previous passing count (no regressions outside test_chunker.py).

Commit: `refactor(20-01): migrate existing chunker tests to sub-chunking contract`
  </action>
  <verify>
    <automated>uv run pytest tests/ingest/test_chunker.py -v && uv run ruff check src/corpus_analyzer/ingest/chunker.py tests/ingest/test_chunker.py && uv run mypy src/corpus_analyzer/ingest/chunker.py</automated>
    <manual>Full suite green, no mypy errors, ruff clean.</manual>
  </verify>
  <done>All tests in test_chunker.py pass, ruff clean, mypy clean, full suite regression-free.</done>
</task>

</tasks>

<verification>
1. `uv run pytest tests/ingest/test_chunker.py -v` — all tests pass including new TestChunkPythonSubChunking
2. `uv run ruff check src/corpus_analyzer/ingest/chunker.py tests/ingest/test_chunker.py` — clean
3. `uv run mypy src/corpus_analyzer/ingest/chunker.py` — clean
4. `uv run pytest -v` — full suite green (no regressions)
5. Grep confirms `_chunk_class` exists: `grep -n "_chunk_class" src/corpus_analyzer/ingest/chunker.py`
6. A class with only `__init__` self-assignments produces header chunk with those assignments
7. A class with a non-assignment call in `__init__` produces header chunk truncated before that call
</verification>

<success_criteria>
- `_chunk_class()` helper exists in chunker.py and is called from `chunk_python()` for all ClassDef nodes
- Header chunk named `ClassName` covers: decorators + class signature + class-level attributes + docstring + `__init__` self-assignments up to first non-assignment statement
- Classes with no methods produce a single header chunk only
- Top-level functions are unchanged
- 10 new SUB-01 tests pass
- All migrated existing tests pass
- ruff + mypy clean, full test suite green
</success_criteria>

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