# Phase 5.5: Technical Debt & Test Gaps — Quick Reset

## Goal

Address the lowest-risk, highest-value items from `technical_debt.md` and `test_audit.md` before resuming Phase 6 (LLM integration). This phase focuses exclusively on items rated zero-risk, very-low-risk, or 🟢 difficulty — quick wins that collectively raise code quality and test coverage without touching architecture or introducing new features.

After this phase, the codebase has: correct types, no swallowed errors, better parser performance, injectable config paths, and test coverage for the most-exposed gaps. Phase 6 can build on a clean foundation.

## Scope Rationale

Items were selected by crossing two criteria:

1. **From `technical_debt.md`**: All remaining items rated "zero risk" or "very low risk" (Issues 10, 12, 13, 2)
2. **From `test_audit.md`**: All 🟢 cards (SMELL-001, SMELL-002, SMELL-004, GAP-003, GAP-005, GAP-006) plus two tightly-related 🟡 cards that fill critical coverage holes exposed by the 🟢 work (GAP-004, GAP-007)

**Excluded** (deferred to a future cleanup phase or Phase 6+ work):
- Issue 1 (UNIQUE constraints / schema migration) — medium risk, needs migration strategy
- Issue 4 (config.py SRP split) — low risk but structural, better done alongside Phase 6 config work
- Issue 8 (DRY import resolution) — low risk but needs test coverage first (MUTANT-008)
- All 🔴 cards and 👾 Boss Battles — too deep for a reset phase
- SMELL-003 (orchestrator over-mocking) — investigation only, no code change

## Dependencies

- **Phase 5 complete** — 255 tests passing, all Phase 2–4 code built
- **No new dependencies** — all changes use existing packages

## Test Rules (from `my-style/references/testing.md`)

1. All fields asserted on returned models
2. Exact count assertions (`== N`, not `>= N`)
3. Minimum 1 error/edge case per 2 happy paths
4. "Would This Fail?" self-check after each test
5. No mirror tests — assert against independently-determined values

---

## Tasks

### Task 1: Fix `tier` type annotation in `testing.py` (Debt Issue 10)

**Files:** `src/unfamiliar/analysis/testing.py`

**What's wrong:** Line 100 declares `tier: str = "basic"` but `TestInfo.tier` expects `Literal["basic", "enhanced"]`. This forces a `# type: ignore[arg-type]` on line 121.

**Action:**
1. Change `tier: str = "basic"` → `tier: Literal["basic", "enhanced"] = "basic"` (add import if needed)
2. Remove the `# type: ignore[arg-type]` comment on line 121

**Verify:** `ruff check src/unfamiliar/analysis/testing.py` and `mypy src/unfamiliar/analysis/testing.py` — zero errors

**Done when:** The type annotation is correct and the `# type: ignore` is removed. No behavior change.

---

### Task 2: Replace `contextlib.suppress(ValueError)` with logged warning in `file_snapshot.py` (Debt Issue 12)

**Files:** `src/unfamiliar/db/repositories/file_snapshot.py`

**What's wrong:** Lines 150–151 silently suppress `ValueError` during datetime parsing. A malformed `last_analyzed` string becomes `None` with no trace.

**Action:** Replace:
```python
with contextlib.suppress(ValueError):
    last_analyzed = datetime.fromisoformat(row["last_analyzed"])
```
With:
```python
try:
    last_analyzed = datetime.fromisoformat(row["last_analyzed"])
except ValueError:
    logger.warning("Could not parse last_analyzed: %r", row["last_analyzed"])
```

Remove the `contextlib` import if it's no longer used elsewhere in the file.

**Verify:** `ruff check` on the file. Existing tests still pass.

**Done when:** `ValueError` is logged, not suppressed. Functional behavior unchanged (still returns `None`).

---

### Task 3: Cache `Parser()` at instance level in JS and TS adapters (Debt Issue 13)

**Files:** `src/unfamiliar/analysis/adapters/javascript_adapter.py`, `src/unfamiliar/analysis/adapters/typescript_adapter.py`

**What's wrong:** `Parser(_JS_LANGUAGE)` is re-created on every `parse_file()` call. The `Language` is cached at module level, but the `Parser` is not. For large repositories, this causes unnecessary allocation.

**Action:**

For `JavaScriptAdapter`:
- Add `__init__` that creates `self._parser = Parser(_JS_LANGUAGE)`
- In `parse_file()`, use `self._parser` instead of creating a new `Parser`

For `TypeScriptAdapter`:
- Add `__init__` that creates `self._ts_parser = Parser(_TS_LANGUAGE)` and `self._tsx_parser = Parser(_TSX_LANGUAGE)`
- In `parse_file()`, select `self._tsx_parser if path_str.endswith(".tsx") else self._ts_parser`

**Verify:** `ruff check` on both files. Existing adapter tests pass.

**Done when:** Parsers are cached on the adapter instance. No behavioral change.

---

### Task 4: Make `_BASE_DIR` injectable in `config.py` (Debt Issue 2)

**Files:** `src/unfamiliar/config.py`

**What's wrong:** `_BASE_DIR = Path.home() / ".unfamiliar"` is evaluated at import time (line 32). Functions reference this module-level constant, making them untestable without patching `Path.home()`.

**Action:**
1. Remove the `_BASE_DIR` module-level constant
2. Add `base_dir: Path | None = None` parameter to `load_config()` and `get_campaign_dir()`
3. Inside each function, compute `_base = base_dir or (Path.home() / ".unfamiliar")`
4. Replace all references to `_BASE_DIR` with `_base`
5. Update any callers in `cli.py` that call these functions (they should pass no `base_dir`, getting the default behavior)

**Verify:** `ruff check src/unfamiliar/config.py src/unfamiliar/cli.py`. Existing config tests pass. Manual sanity: `python3 -c "from unfamiliar.config import load_config"` doesn't error.

**Done when:** `_BASE_DIR` is gone. Functions accept an injectable path. Default behavior identical to before.

---

### Task 5: Fix `test_idempotent_double_init` — add meaningful assertions (Audit SMELL-001)

**Files:** `tests/test_db_connection.py`

**What's wrong:** `test_idempotent_double_init` (line 157) only checks that schema version is still "1.0" after double init. It doesn't verify the database is actually in a valid state (no duplicates, tables intact).

**Action:** Add assertions that verify:
1. Exactly one row in `meta` table with `key = 'schema_version'`
2. All expected tables still exist (same set as `test_creates_schema_tables`)
3. No duplicate meta entries

If these are already covered by `test_meta_has_exactly_one_schema_version_row` elsewhere, consider removing this test with a comment explaining why.

**Verify:** `pytest tests/test_db_connection.py -v`

**Done when:** The test has meaningful structural assertions or is removed with documented justification.

---

### Task 6: Fix `test_all_4_signals_additive_to_200` — assert behavior not arithmetic (Audit SMELL-002)

**Files:** `tests/test_scoring.py`

**What's wrong:** The test hardcodes `== 200`, mirroring the implementation's weight sum (100 + 50 + 30 + 20). If weights change, the test would be updated to match without catching the regression.

**Action:** Replace the exact-sum assertion with behavioral assertions:
1. Combined score is strictly greater than any individual signal's score
2. The file appears first in sort order (highest priority)
3. All 4 reason strings are present (keep this — it tests that all signals fire)
4. Optionally: score is strictly positive

**Verify:** `pytest tests/test_scoring.py -v`

**Done when:** The test validates scoring behavior (all signals additive, correct ordering) rather than echoing the implementation's arithmetic.

---

### Task 7: Fix `test_hash_matches_expected_value` — test properties not Python stdlib (Audit SMELL-004)

**Files:** `tests/test_config.py`

**What's wrong:** The test computes SHA256 of known content and asserts against a hardcoded hex string. This tests `hashlib`, not application logic.

**Action:** Either:
- **Option A:** Replace with a behavioral property test: "different file contents produce different campaign directory names"
- **Option B:** Remove the test if `test_hash_is_deterministic` already covers the relevant behavior, and add a comment explaining the removal

**Verify:** `pytest tests/test_config.py -v`

**Done when:** Test validates a behavioral property of the hashing or is removed with justification.

---

### Task 8: Add tests for `PythonAdapter._detect_app_type` (Audit GAP-003)

**Files:** `tests/test_adapters.py`

**What's untested:** `_detect_app_type()` classifies Python files by AST patterns (Flask, Django, FastAPI, Typer). 23 mutants survived.

**Action:** Add a test class `TestDetectAppType` with tests for:
1. Flask: code containing `Flask(__name__)` → detected as Flask app
2. Django: code containing `urlpatterns = [...]` → detected as Django
3. FastAPI: code containing `FastAPI()` → detected as FastAPI
4. Typer: code containing `typer.Typer()` → detected as Typer
5. No match: plain Python code → no app type detected

Parse each snippet with the Python adapter and check `entry_points` or the internal `_detect_app_type` return value.

**Verify:** `pytest tests/test_adapters.py -v -k "detect_app_type"`

**Done when:** Tests exist for 4+ app type patterns plus the no-match case.

---

### Task 9: Add tests for `MissionRepository.get_missions_for_module` (Audit GAP-005)

**Files:** `tests/test_repositories.py`

**What's untested:** `get_missions_for_module()` — reverse lookup from module to linked missions. 10 mutants.

**Action:** Add tests:
1. Create a module and 2 missions, link both to the module via `link_modules()`, call `get_missions_for_module(module_id)` → exactly 2 missions returned, correct IDs
2. Module with no linked missions → returns empty list
3. Assert returned mission fields (not just count)

**Verify:** `pytest tests/test_repositories.py -v -k "get_missions_for_module"`

**Done when:** Tests verify correct and empty results with field-level assertions.

---

### Task 10: Add tests for `FileSnapshotRepository.get_by_path` (Audit GAP-006)

**Files:** `tests/test_repositories.py`

**What's untested:** `get_by_path(campaign_id, file_path)` — retrieves a snapshot by path. 9 mutants.

**Action:** Add tests:
1. Create snapshots via `create_many()`, call `get_by_path(campaign_id, "src/main.py")` → correct snapshot returned with all fields asserted
2. Non-existent path → returns `None`
3. Same path in different campaigns → returns correct campaign's snapshot

**Verify:** `pytest tests/test_repositories.py -v -k "get_by_path"`

**Done when:** Tests would fail if the SQL query or row mapping is wrong.

---

### Task 11: Add tests for repository `update()` methods (Audit GAP-004)

**Files:** `tests/test_repositories.py`

**What's untested:** `CampaignRepository.update()` (generic, not `update_status`) and `ModuleRepository.update()` — 38 combined surviving mutants.

**Action:** Add tests:
1. **CampaignRepository.update():** Create campaign, update `status` and `token_budget` fields, re-fetch → verify changed fields; verify `updated_at` changed; verify unchanged fields preserved
2. **ModuleRepository.update():** Create module, update `category_relevance`, re-fetch → verify JSON round-tripped; verify other fields unchanged

**Verify:** `pytest tests/test_repositories.py -v -k "update"`

**Done when:** Both generic `update()` methods have tests with field-level verification.

---

### Task 12: Add tests for `ChainRepository` CRUD gaps (Audit GAP-007)

**Files:** `tests/test_repositories.py`

**What's untested:** `create_many()` (22 mutants), `get()` (9), `get_by_campaign()` (9) — 40 total surviving mutants.

**Action:** Add tests:
1. `create_many()`: Create 3 chains in bulk → all 3 returned with IDs, all fields asserted
2. `get(chain_id)`: Returns correct chain with all fields; `get(999)` returns `None`
3. `get_by_campaign(campaign_id)`: Returns only chains for that campaign, correct count

**Verify:** `pytest tests/test_repositories.py -v -k "chain"`

**Done when:** All three methods verified. Existing chain tests still pass.

---

### Task 13: Update planning documents and run full verification

**Files:** `_planning/technical_debt.md`, `_planning/test_audit.md`, `_planning/state.md`, `_planning/roadmap.md`

**Action:**
1. Check off completed items in `technical_debt.md` priority table (Issues 10, 12, 13, 2)
2. Check off completed cards in `test_audit.md` session-ready order (SMELL-001, SMELL-002, SMELL-004, GAP-003, GAP-004, GAP-005, GAP-006, GAP-007)
3. Run full test suite: `python3 -m pytest tests/ -v --tb=short`
4. Run linting: `ruff check src/unfamiliar/ tests/`
5. Run type checking: `mypy src/unfamiliar/`
6. Update `state.md` with phase 5.5 session entry
7. Add phase 5.5 entry to `roadmap.md`
8. Create `ua_testing.md`, `user_feedback.md`, and `phase_summary.md`

**Verify:** All tests pass. All linting and type-checking pass. Planning docs updated.

**Done when:** Clean bill of health on the full suite. Planning documents reflect completed work.

---

## Estimated Effort

| Task | Source | Time | Risk |
|------|--------|------|------|
| 1. Fix `tier` type | Debt #10 | 10 min | zero |
| 2. Log datetime parse error | Debt #12 | 5 min | zero |
| 3. Cache Parser instances | Debt #13 | 20 min | low |
| 4. Injectable `_BASE_DIR` | Debt #2 | 45 min | low |
| 5. Fix idempotent init test | SMELL-001 | 10 min | zero |
| 6. Fix scoring mirror test | SMELL-002 | 15 min | zero |
| 7. Fix hash mirror test | SMELL-004 | 10 min | zero |
| 8. Test `_detect_app_type` | GAP-003 | 15 min | zero |
| 9. Test `get_missions_for_module` | GAP-005 | 15 min | zero |
| 10. Test `get_by_path` | GAP-006 | 15 min | zero |
| 11. Test `update()` methods | GAP-004 | 20 min | zero |
| 12. Test Chain CRUD gaps | GAP-007 | 20 min | zero |
| 13. Planning docs + verification | — | 15 min | zero |
| **Total** | | **~3.5 hrs** | **mostly zero** |

---

## What This Phase Does NOT Do

- No schema migrations (Issue 1 deferred)
- No module splitting (Issue 4 deferred)
- No DRY extraction (Issue 8 deferred — needs tests first)
- No 🔴 mutant hunts or 👾 boss battles
- No new features or Phase 6 work
- No SMELL-003 investigation (orchestrator mocking is acceptable as a wiring test)
