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

must_haves:
  truths:
    - "chunk_typescript() produces a header chunk (ClassName) plus one ClassName.method_name chunk per method_definition node in any class_declaration or abstract_class_declaration"
    - "The constructor becomes ClassName.constructor"
    - "Abstract method declarations produce per-method chunks just like concrete methods"
    - "Top-level functions, interfaces, enums, and type aliases are unaffected — their chunks are unchanged"
    - "The monolithic class chunk is fully replaced by header + per-method chunks"
    - "Re-chunking the same TypeScript file produces identical results deterministically"
  artifacts:
    - path: "src/corpus_analyzer/ingest/ts_chunker.py"
      provides: "_chunk_ts_class() helper + integration into chunk_typescript() main loop"
      contains: "ClassName.method_name"
    - path: "tests/ingest/test_chunker.py"
      provides: "TestChunkTypeScriptMethodChunks test class covering all SUB-03 cases"
      contains: "TestChunkTypeScriptMethodChunks"
  key_links:
    - from: "chunk_typescript() class_declaration branch"
      to: "_chunk_ts_class()"
      via: "call instead of single-chunk emit for class nodes"
      pattern: "_chunk_ts_class"
    - from: "_chunk_ts_class()"
      to: "method_definition nodes in class_body"
      via: "iterating node children where child.type == 'method_definition'"
      pattern: "method_definition"
    - from: "chunk_name"
      to: "ClassName.method_name"
      via: "f'{class_name}.{method_name}' construction"
      pattern: "f\"{class_name}\\."
---

<objective>
Implement TypeScript method sub-chunking (SUB-03): add a `_chunk_ts_class()` helper to ts_chunker.py that splits a class node into a header chunk (ClassName) plus one chunk per `method_definition` node, named `ClassName.method_name`. Mirror the Python Phase 20 pattern as closely as possible. The monolithic class chunk is fully replaced.

Purpose: TypeScript methods are independently searchable — LLM callers can find `SearchCoordinator.search` or `CorpusIndex.open` without reading the entire class.
Output: Extended ts_chunker.py with `_chunk_ts_class()` and full TDD test coverage for all SUB-03 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/21-typescript-method-sub-chunking/21-CONTEXT.md

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

<tasks>

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

All tests call `chunk_typescript(ts_file)` on a `.ts` file. Tests MUST FAIL against the current ts_chunker.py (which emits a single monolithic class chunk).

Write the following test cases:

1. `test_class_methods_produce_dot_name_chunks` — class with two methods produces ClassName.method_a and ClassName.method_b chunks
   - Input:
     ```typescript
     class MyClass {
       method_a(): void {}
       method_b(): void {}
     }
     ```
   - 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_constructor_becomes_dot_constructor` — constructor produces a ClassName.constructor chunk
   - Input:
     ```typescript
     class MyClass {
       constructor(private x: number) {}
     }
     ```
   - Assert: any(c["chunk_name"] == "MyClass.constructor" for c in chunks)

3. `test_abstract_method_chunked` — abstract method in abstract class produces a per-method chunk
   - Input:
     ```typescript
     abstract class Base {
       abstract run(): void;
     }
     ```
   - Assert: any(c["chunk_name"] == "Base.run" for c in chunks)

4. `test_abstract_class_concrete_method_chunked` — concrete method in abstract class produces a per-method chunk
   - Input:
     ```typescript
     abstract class Base {
       greet(): string { return "hello"; }
     }
     ```
   - Assert: any(c["chunk_name"] == "Base.greet" for c in chunks)

5. `test_header_chunk_always_emitted` — header chunk named ClassName is always present even when class has methods
   - Input:
     ```typescript
     class MyClass {
       go(): void {}
     }
     ```
   - Assert: any(c["chunk_name"] == "MyClass" for c in chunks)

6. `test_monolithic_replaced_by_header_plus_methods` — class with 2 methods produces at least 3 chunks (header + 2 methods)
   - Input:
     ```typescript
     class MyClass {
       alpha(): void {}
       beta(): void {}
     }
     ```
   - Assert: len([c for c in chunks if c["chunk_name"].startswith("MyClass")]) >= 3

7. `test_method_chunk_text_contains_method_name` — method chunk text contains the method signature
   - Input:
     ```typescript
     class MyClass {
       myMethod(): string { return "x"; }
     }
     ```
   - Find chunk with name "MyClass.myMethod", assert "myMethod" in chunk["text"]

8. `test_method_chunk_line_range` — method chunk start_line and end_line point to the method node in the file
   - Input (class starts line 1, method on line 2, closing brace line 2 — single-line method):
     ```typescript
     class MyClass {
       go(): void {}
     }
     ```
   - Find chunk with name "MyClass.go", assert start_line >= 2 (method is not on line 1) and end_line >= start_line

9. `test_no_methods_no_method_chunks` — class with only field declarations produces no ClassName.method chunks
   - Input:
     ```typescript
     class MyClass {
       x: number = 1;
     }
     ```
   - Assert: not any("." in c["chunk_name"] and c["chunk_name"].startswith("MyClass") for c in chunks)

10. `test_multi_class_file_all_classes_sub_chunked` — two classes in one file both get sub-chunked
    - Input:
      ```typescript
      class Alpha {
        run(): void {}
      }
      class Beta {
        go(): void {}
      }
      ```
    - Assert: any(c["chunk_name"] == "Alpha.run" for c in chunks)
    - Assert: any(c["chunk_name"] == "Beta.go" for c in chunks)

11. `test_top_level_function_unaffected` — top-level function in same file still produces its own flat chunk
    - Input:
      ```typescript
      function helper(): void {}
      class MyClass {
        method(): void {}
      }
      ```
    - Assert: any(c["chunk_name"] == "helper" for c in chunks)
    - Assert: any(c["chunk_name"] == "MyClass.method" for c in chunks)

12. `test_exported_class_sub_chunked` — exported class (`export class Foo`) still gets sub-chunked
    - Input:
      ```typescript
      export class Foo {
        bar(): void {}
      }
      ```
    - Assert: any(c["chunk_name"] == "Foo.bar" for c in chunks)

13. `test_deterministic_output` — calling chunk_typescript() twice on the same file produces identical results
    - Write a .ts file with a class with 2 methods
    - Assert: chunk_typescript(ts_file) == chunk_typescript(ts_file)

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

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

<task type="tdd">
  <name>Task 2 GREEN: Add _chunk_ts_class() and integrate into chunk_typescript()</name>
  <files>src/corpus_analyzer/ingest/ts_chunker.py</files>
  <action>
Add a `_chunk_ts_class()` helper function to ts_chunker.py and integrate it into the `chunk_typescript()` main loop.

**New helper — `_chunk_ts_class()`:**

Add this function after `_extract_name()` and before `chunk_typescript()`:

```python
def _chunk_ts_class(
    node: Any,
    export_node: Any | None,
    lines: list[str],
    class_name: str,
) -> list[dict[str, Any]]:
    """Split a class or abstract class node into a header chunk plus per-method chunks.

    The header chunk covers the class signature, field declarations, and any
    content before the first method_definition node. One chunk per
    method_definition is appended after the header, named ClassName.method_name.

    Args:
        node: The class_declaration or abstract_class_declaration AST node.
        export_node: The export_statement wrapper node if present, else None.
        lines: All source lines (0-indexed) of the file.
        class_name: The resolved class name string (already extracted).

    Returns:
        List of chunk dicts: one header chunk followed by one chunk per method_definition.
    """
```

**Header chunk boundary rule (per CONTEXT.md locked decisions):**
- Header starts at the class signature (or export wrapper) start row — same JSDoc lookback logic already in `chunk_typescript()` main loop
- Header ends immediately before the first `method_definition` node in the class body
- If there are NO `method_definition` nodes, header covers the entire class node
- The header chunk_name is `class_name` (the bare ClassName)

**Implementation steps for `_chunk_ts_class()`:**

1. Find the `class_body` child of `node` (child where `child.type == "class_body"`).
2. Collect all `method_definition` children from `class_body.children` (direct children only — do NOT recurse into nested class bodies).
3. Determine `outer` (export_node if present, else node) for the full class span — same as main loop.
4. Determine header start row using the same JSDoc lookback logic as main loop (reuse or inline).
5. If no method_definition nodes: emit a single header chunk spanning the full class (same as current behaviour).
6. If method_definition nodes exist:
   - Header `end_row` = (first method_definition node).start_point[0] (exclusive) — so header text = lines[header_start_row : first_method.start_point[0]]
   - Header chunk: start_line = header_start_row + 1, end_line = first_method.start_point[0] (0-indexed row of first method is the end of header, 1-indexed = that row number)
   - Actually: header end_line = first_method.start_point[0] (i.e. the line BEFORE the method, 1-indexed). Use `first_method.start_point[0]` as end_line directly since start_point is 0-indexed and we want the line just before (1-indexed = start_point[0]).
7. For each method_definition node in order:
   - `method_start_line = method_node.start_point[0] + 1`  (1-indexed)
   - `method_end_line = method_node.end_point[0] + 1`  (1-indexed)
   - Extract method name: `method_node.child_by_field_name("name")` → `.text.decode("utf-8")`; fall back to `"<anonymous>"` if None
   - `method_text = "\n".join(lines[method_node.start_point[0] : method_node.end_point[0] + 1]).rstrip()`
   - chunk_name = `f"{class_name}.{method_name}"`
   - Append chunk dict with text, start_line, end_line, chunk_name, chunk_text = method_text

**Integration into `chunk_typescript()` main loop:**

In the section after `if node.type not in _TARGET_TYPES: continue`, add a branch for class nodes BEFORE the existing single-chunk emit:

```python
# Sub-chunk class declarations into header + per-method chunks (SUB-03)
if node.type in ("class_declaration", "abstract_class_declaration"):
    chunk_name = _extract_name(node, export_node)
    sub_chunks = _chunk_ts_class(node, export_node, lines, chunk_name)
    chunks.extend(sub_chunks)
    continue
```

This replaces the single-chunk path for class nodes only. All other node types (function_declaration, interface_declaration, etc.) continue through the existing single-chunk path unchanged.

**Key implementation notes:**
- Only iterate `class_body.children` directly — do not recurse into nested class `class_body` nodes. A `method_definition` that contains a class literal in its body should not produce sub-chunks for that inner class.
- The method name field in tree-sitter's TypeScript grammar is accessed via `method_node.child_by_field_name("name")`.
- Constructor name: the method_definition node for a constructor has name text `"constructor"` → produces `ClassName.constructor`.
- Abstract methods: tree-sitter emits abstract method declarations as `method_definition` nodes (the grammar treats them the same) → they are iterated and produce per-method chunks identically.
- Getter/setter naming and private method naming are at Claude's discretion (per CONTEXT.md). Use the method_name text as-is from the AST node (e.g. `"get foo"` or `"#foo"` → whatever tree-sitter returns for the name field). Do not apply any transformation.

Run `uv run pytest tests/ingest/test_chunker.py::TestChunkTypeScriptMethodChunks -v` — ALL 13 MUST PASS (GREEN).
Also run `uv run pytest tests/ingest/test_chunker.py -v` — all existing TestChunkTypeScript tests must still pass (update any that assert exact chunk count for a class if needed — use the same fix as Phase 20: check chunk_name membership rather than exact count).

Commit: `feat(21-01): add _chunk_ts_class() with per-method chunks for SUB-03`
  </action>
  <verify>
    <automated>uv run pytest tests/ingest/test_chunker.py -v 2>&1 | tail -20</automated>
    <manual>All TestChunkTypeScriptMethodChunks tests pass AND all pre-existing TestChunkTypeScript tests still pass.</manual>
  </verify>
  <done>All 13 new method-chunk tests pass GREEN. All existing TestChunkTypeScript tests still pass. _chunk_ts_class() returns header + per-method chunks. chunk_typescript() calls _chunk_ts_class() for class nodes.</done>
</task>

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

2. Run `uv run mypy src/corpus_analyzer/ingest/ts_chunker.py` — fix any type annotation issues. Ensure `_chunk_ts_class()` has full type annotations and a PEP 257 docstring:
   `def _chunk_ts_class(node: Any, export_node: Any | None, lines: list[str], class_name: str) -> list[dict[str, Any]]:`

3. Run `uv run pytest -v` — full suite. Must be >= 369 tests (the pre-phase count) with 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/ts_chunker.py tests/ingest/test_chunker.py` — apply formatting.

If any test outside `test_chunker.py` fails due to class sub-chunking changes (e.g. test_round_trip.py or test_indexer.py asserting exact chunk counts for TS classes), update those tests using the same approach as Phase 20: check chunk_name membership rather than exact count.

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

</tasks>

<verification>
1. `uv run pytest tests/ingest/test_chunker.py::TestChunkTypeScriptMethodChunks -v` — all 13 new tests pass
2. `uv run pytest tests/ingest/test_chunker.py -v` — no regressions in existing TestChunkTypeScript tests
3. `uv run pytest -v` — full suite green (>= 369 tests)
4. `uv run ruff check src/corpus_analyzer/ingest/ts_chunker.py` — clean
5. `uv run mypy src/corpus_analyzer/ingest/ts_chunker.py` — clean
6. A TypeScript class with 2 methods produces 3+ chunks: 1 header (ClassName) + 2 method chunks (ClassName.method_a, ClassName.method_b)
7. Top-level functions/interfaces/enums produce a single chunk each — unaffected by this change
8. `export class Foo { bar(): void {} }` → chunks include both "Foo" (header) and "Foo.bar" (method)
</verification>

<success_criteria>
- _chunk_ts_class() returns header chunk + one method chunk per method_definition in the class body
- Method chunks named ClassName.method_name for all method types (constructor, concrete, abstract)
- constructor → ClassName.constructor
- Nested class inside method is NOT recursively sub-chunked (opaque content in method chunk)
- Multi-class files: all classes sub-chunked
- Classes with no method_definition nodes: header only, no method chunks
- Exported classes are sub-chunked correctly
- Top-level functions, interfaces, enums, type aliases: unaffected
- 13 new SUB-03 tests pass GREEN
- All pre-existing TestChunkTypeScript tests still pass
- ruff + mypy clean, full test suite green (>= 369 tests)
</success_criteria>

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