"""Tests for recommendation writer — marker-based file updates.""" from pathlib import Path import pytest from headroom.learn.models import ProjectInfo, Recommendation, RecommendationTarget from headroom.learn.writer import ( _MARKER_END, _MARKER_START, ClaudeCodeWriter, _merge_into_file, _parse_prior_recommendations, _read_text_tolerant, extract_marker_block, ) def _project(tmp_path: Path) -> ProjectInfo: proj_dir = tmp_path / "myproject" proj_dir.mkdir() data_dir = tmp_path / "data" data_dir.mkdir() memory_dir = data_dir / "memory" memory_dir.mkdir() return ProjectInfo( name="myproject", project_path=proj_dir, data_path=data_dir, ) def _rec(target: RecommendationTarget, section: str, content: str) -> Recommendation: return Recommendation( target=target, section=section, content=content, confidence=0.8, evidence_count=5 ) class TestClaudeCodeWriter: def test_dry_run_does_not_write(self, tmp_path): proj = _project(tmp_path) writer = ClaudeCodeWriter() recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")] result = writer.write(recs, proj, dry_run=True) assert result.dry_run is True assert len(result.files_written) == 1 # File should NOT exist (dry run) claude_local = proj.project_path / "CLAUDE.local.md" assert not claude_local.exists() # Default target is the personal CLAUDE.local.md, never the shared CLAUDE.md assert result.files_written[0].name == "CLAUDE.local.md" def test_apply_writes_claude_local_md(self, tmp_path): proj = _project(tmp_path) writer = ClaudeCodeWriter() recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use `uv run python`")] result = writer.write(recs, proj, dry_run=False) assert result.dry_run is False # Learnings go to the personal, gitignored CLAUDE.local.md by default... claude_local = proj.project_path / "CLAUDE.local.md" assert claude_local.exists() content = claude_local.read_text() assert "uv run python" in content assert _MARKER_START in content assert _MARKER_END in content # ...and never touch the team-shared CLAUDE.md. assert not (proj.project_path / "CLAUDE.md").exists() def test_apply_writes_memory_md(self, tmp_path): proj = _project(tmp_path) writer = ClaudeCodeWriter() recs = [_rec(RecommendationTarget.MEMORY_FILE, "Retry Prevention", "- Don't retry globs")] writer.write(recs, proj, dry_run=False) memory_md = proj.data_path / "memory" / "MEMORY.md" assert memory_md.exists() assert "Don't retry globs" in memory_md.read_text() def test_hand_written_claude_md_left_untouched(self, tmp_path): proj = _project(tmp_path) claude_md = proj.project_path / "CLAUDE.md" original = "# My Project\n\nExisting instructions here.\n" claude_md.write_text(original) writer = ClaudeCodeWriter() recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")] writer.write(recs, proj, dry_run=False) # A hand-written CLAUDE.md with no headroom block is left exactly as-is. assert claude_md.read_text() == original # Learnings land in the personal CLAUDE.local.md instead. local_content = (proj.project_path / "CLAUDE.local.md").read_text() assert "Use uv" in local_content def test_carries_forward_prior_sections_not_resurfaced(self, tmp_path): """Re-running learn must not drop prior sections that the new run didn't re-surface.""" proj = _project(tmp_path) claude_md = proj.project_path / "CLAUDE.local.md" prior_block = ( f"# My Project\n\n{_MARKER_START}\n" "## Headroom Learned Patterns\n" "*Auto-generated by `headroom learn` on 2026-01-01 — do not edit manually*\n\n" "### Large Files\n" "*~15,000 tokens/session saved*\n" "- src/App.tsx is huge\n\n" "### Build Commands\n" "- cargo check from src-tauri/\n\n" f"{_MARKER_END}\n" ) claude_md.write_text(prior_block) writer = ClaudeCodeWriter() recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")] writer.write(recs, proj, dry_run=False) content = claude_md.read_text() assert "My Project" in content # New section present assert "Use uv" in content # Prior sections preserved (neither heading re-surfaced by the new run) assert "### Large Files" in content assert "src/App.tsx is huge" in content assert "### Build Commands" in content assert "cargo check from src-tauri/" in content # Tokens annotation round-tripped assert "*~15,000 tokens/session saved*" in content # Still exactly one marker pair assert content.count(_MARKER_START) == 1 assert content.count(_MARKER_END) == 1 def test_new_run_overrides_same_named_prior_section(self, tmp_path): """When a section appears in both prior and new, the new run wins.""" proj = _project(tmp_path) claude_md = proj.project_path / "CLAUDE.local.md" prior_block = ( f"{_MARKER_START}\n" "## Headroom Learned Patterns\n" "*Auto-generated by `headroom learn` on 2026-01-01 — do not edit manually*\n\n" "### Environment\n" "- old stale environment note\n\n" f"{_MARKER_END}\n" ) claude_md.write_text(prior_block) writer = ClaudeCodeWriter() recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- fresh environment note")] writer.write(recs, proj, dry_run=False) content = claude_md.read_text() assert "fresh environment note" in content assert "old stale environment note" not in content # Only one Environment section in the final block assert content.count("### Environment") == 1 def test_replacing_existing_block_handles_literal_backslash_escapes(self, tmp_path): """LLM text with backslash escapes must not be interpreted as a regex replacement.""" proj = _project(tmp_path) claude_md = proj.project_path / "CLAUDE.md" claude_md.write_text( f"# Existing\n\n{_MARKER_START}\n" "## Headroom Learned Patterns\n\n" "### Windows Paths\n" "- stale\n\n" f"{_MARKER_END}\n" ) full_content = _merge_into_file( claude_md, [ _rec( RecommendationTarget.CONTEXT_FILE, "Windows Paths", r"- Keep the literal \u sequence and C:\Users\john.doe\repo path", ) ], ) assert r"\u sequence" in full_content assert r"C:\Users\john.doe\repo" in full_content assert "stale" not in full_content def test_memory_md_carry_forward(self, tmp_path): """Carry-forward also works for MEMORY.md.""" proj = _project(tmp_path) memory_md = proj.data_path / "memory" / "MEMORY.md" memory_md.write_text( f"{_MARKER_START}\n" "## Headroom Learned Patterns\n" "*Auto-generated by `headroom learn` on 2026-01-01 — do not edit manually*\n\n" "### User Workflow Preferences\n" "- User rejects sleep-based polling\n\n" f"{_MARKER_END}\n" ) writer = ClaudeCodeWriter() recs = [ _rec(RecommendationTarget.MEMORY_FILE, "Related Codebases", "- web app at ~/Code/web") ] writer.write(recs, proj, dry_run=False) content = memory_md.read_text() assert "User rejects sleep-based polling" in content assert "web app at ~/Code/web" in content def test_section_without_tokens_annotation_round_trips(self, tmp_path): """Prior sections emitted without a tokens annotation must still carry forward cleanly.""" proj = _project(tmp_path) claude_md = proj.project_path / "CLAUDE.local.md" claude_md.write_text( f"{_MARKER_START}\n" "## Headroom Learned Patterns\n" "*Auto-generated by `headroom learn` on 2026-01-01 — do not edit manually*\n\n" "### Misc\n" "- one-liner pattern\n\n" f"{_MARKER_END}\n" ) writer = ClaudeCodeWriter() recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Other", "- new one")] writer.write(recs, proj, dry_run=False) content = claude_md.read_text() assert "### Misc" in content assert "one-liner pattern" in content # No spurious tokens annotation injected for a prior that didn't have one misc_idx = content.index("### Misc") after_misc = content[misc_idx : misc_idx + 200] assert "tokens/session saved" not in after_misc def test_appends_to_existing_memory_md(self, tmp_path): proj = _project(tmp_path) memory_md = proj.data_path / "memory" / "MEMORY.md" memory_md.write_text("# Existing Memory\n\nSome facts.\n") writer = ClaudeCodeWriter() recs = [_rec(RecommendationTarget.MEMORY_FILE, "Retry Prevention", "- New pattern")] writer.write(recs, proj, dry_run=False) content = memory_md.read_text() assert "Existing Memory" in content assert "Some facts" in content assert "New pattern" in content def _legacy_block(section: str, body: str) -> str: return ( f"# My Project\n\nExisting instructions.\n\n{_MARKER_START}\n" "## Headroom Learned Patterns\n" "*Auto-generated by `headroom learn` on 2026-01-01 — do not edit manually*\n\n" f"### {section}\n{body}\n\n" f"{_MARKER_END}\n" ) class TestContextTargetOverride: """--target / set_context_target controls where CONTEXT_FILE recs are written.""" def test_target_override_relative_path(self, tmp_path): proj = _project(tmp_path) writer = ClaudeCodeWriter() writer.set_context_target("CLAUDE.md") recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")] writer.write(recs, proj, dry_run=False) # Explicit target opts back into the team-shared CLAUDE.md. assert (proj.project_path / "CLAUDE.md").exists() assert "Use uv" in (proj.project_path / "CLAUDE.md").read_text() assert not (proj.project_path / "CLAUDE.local.md").exists() def test_target_override_via_constructor(self, tmp_path): proj = _project(tmp_path) writer = ClaudeCodeWriter(context_target="docs/LEARNINGS.md") recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")] writer.write(recs, proj, dry_run=False) target = proj.project_path / "docs" / "LEARNINGS.md" assert target.exists() assert "Use uv" in target.read_text() def test_target_absolute_path(self, tmp_path): proj = _project(tmp_path) abs_target = tmp_path / "elsewhere" / "NOTES.md" writer = ClaudeCodeWriter(context_target=str(abs_target)) recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")] writer.write(recs, proj, dry_run=False) assert abs_target.exists() assert "Use uv" in abs_target.read_text() class TestLegacyClaudeMdMigration: """A stale headroom block in the shared CLAUDE.md migrates to CLAUDE.local.md.""" def test_migrates_block_and_strips_legacy(self, tmp_path): proj = _project(tmp_path) claude_md = proj.project_path / "CLAUDE.md" claude_md.write_text(_legacy_block("Build Commands", "- cargo check from src-tauri/")) writer = ClaudeCodeWriter() recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")] result = writer.write(recs, proj, dry_run=False) # Hand-written content stays in CLAUDE.md; the headroom block is gone. legacy = claude_md.read_text() assert "Existing instructions." in legacy assert _MARKER_START not in legacy assert "Build Commands" not in legacy # CLAUDE.local.md now owns the migrated section AND the new one. local = (proj.project_path / "CLAUDE.local.md").read_text() assert "### Build Commands" in local assert "cargo check from src-tauri/" in local assert "### Environment" in local assert "Use uv" in local assert local.count(_MARKER_START) == 1 # The migration is surfaced to the user. assert any("CLAUDE.md" in w for w in result.warnings) def test_block_only_claude_md_is_removed(self, tmp_path): proj = _project(tmp_path) claude_md = proj.project_path / "CLAUDE.md" # CLAUDE.md holds nothing but the Headroom block (no hand-written content). claude_md.write_text( f"{_MARKER_START}\n## Headroom Learned Patterns\n\n" "### Build Commands\n- cargo check\n\n" f"{_MARKER_END}\n" ) writer = ClaudeCodeWriter() recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")] result = writer.write(recs, proj, dry_run=False) # The empty husk is deleted rather than left behind as an empty file. assert not claude_md.exists() local = (proj.project_path / "CLAUDE.local.md").read_text() assert "### Build Commands" in local assert "### Environment" in local assert any("Removed" in w for w in result.warnings) def test_dry_run_block_only_claude_md_not_removed(self, tmp_path): proj = _project(tmp_path) claude_md = proj.project_path / "CLAUDE.md" original = ( f"{_MARKER_START}\n## Headroom Learned Patterns\n\n" "### Build Commands\n- cargo check\n\n" f"{_MARKER_END}\n" ) claude_md.write_text(original) writer = ClaudeCodeWriter() recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")] result = writer.write(recs, proj, dry_run=True) # Dry run leaves the file on disk but still previews the removal. assert claude_md.read_text() == original assert any("Removed" in w for w in result.warnings) def test_dry_run_migration_writes_nothing(self, tmp_path): proj = _project(tmp_path) claude_md = proj.project_path / "CLAUDE.md" original = _legacy_block("Build Commands", "- cargo check") claude_md.write_text(original) writer = ClaudeCodeWriter() recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")] result = writer.write(recs, proj, dry_run=True) # Nothing written on disk, but the warning still fires for the preview. assert claude_md.read_text() == original assert not (proj.project_path / "CLAUDE.local.md").exists() assert any("CLAUDE.md" in w for w in result.warnings) def test_no_migration_when_local_already_owns_block(self, tmp_path): proj = _project(tmp_path) claude_md = proj.project_path / "CLAUDE.md" legacy = _legacy_block("Build Commands", "- cargo check") claude_md.write_text(legacy) local_md = proj.project_path / "CLAUDE.local.md" local_md.write_text( f"{_MARKER_START}\n## Headroom Learned Patterns\n\n" "### Environment\n- prior local note\n\n" f"{_MARKER_END}\n" ) writer = ClaudeCodeWriter() recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- fresh note")] result = writer.write(recs, proj, dry_run=False) # CLAUDE.md is left untouched (local is already the source of truth). assert claude_md.read_text() == legacy assert not result.warnings local = local_md.read_text() assert "fresh note" in local assert "prior local note" not in local def test_target_override_skips_migration(self, tmp_path): proj = _project(tmp_path) claude_md = proj.project_path / "CLAUDE.md" legacy = _legacy_block("Build Commands", "- cargo check") claude_md.write_text(legacy) writer = ClaudeCodeWriter() writer.set_context_target("CLAUDE.md") recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")] result = writer.write(recs, proj, dry_run=False) # Explicit CLAUDE.md target merges in place, no migration warning. assert not result.warnings content = claude_md.read_text() assert "### Environment" in content assert "### Build Commands" in content class TestHomeDirectoryContext: """The home directory keeps writing to ~/.claude/CLAUDE.md (personal global memory).""" def test_home_dir_writes_global_claude_md(self, tmp_path, monkeypatch): fake_home = tmp_path / "home" fake_home.mkdir() monkeypatch.setattr(Path, "home", classmethod(lambda cls: fake_home)) proj = ProjectInfo( name="home", project_path=fake_home, data_path=tmp_path / "data", ) writer = ClaudeCodeWriter() recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")] writer.write(recs, proj, dry_run=False) global_md = fake_home / ".claude" / "CLAUDE.md" assert global_md.exists() assert "Use uv" in global_md.read_text() assert not (fake_home / "CLAUDE.local.md").exists() class TestParsePriorRecommendations: """Direct coverage for _parse_prior_recommendations edge cases.""" def test_no_marker_block_returns_empty(self): """A file without any marker block yields no prior recommendations.""" assert _parse_prior_recommendations("# Project\n\nJust a regular README.\n") == [] def test_empty_marker_block_yields_no_recs(self): """A marker block with nothing between the markers yields no recs.""" content = f"prefix\n{_MARKER_START}\n{_MARKER_END}\nsuffix\n" assert _parse_prior_recommendations(content) == [] def test_marker_block_with_empty_heading_is_skipped(self): """A stray `### ` (empty heading) inside the block is skipped, not raised.""" # Leading `### ` with no heading text, followed by a real section. content = ( f"{_MARKER_START}\n" "## Headroom Learned Patterns\n" "### \n" "some orphan content\n" "\n" "### Real Section\n" "- real bullet\n" "\n" f"{_MARKER_END}\n" ) recs = _parse_prior_recommendations(content) # Only the real section is parsed; the empty-heading entry is dropped. assert len(recs) == 1 assert recs[0].section == "Real Section" assert "real bullet" in recs[0].content class TestExtractMarkerBlock: """Direct coverage for extract_marker_block.""" def test_returns_raw_block_when_present(self): """Marker block is returned verbatim with delimiters, for LLM prompts.""" content = ( "# Project README\n\n" "Some text.\n\n" f"{_MARKER_START}\n" "## Headroom Learned Patterns\n" "### Environment\n" "- Use uv run python\n" f"{_MARKER_END}\n" "Trailing text.\n" ) block = extract_marker_block(content) assert block is not None assert block.startswith(_MARKER_START) assert block.endswith(_MARKER_END) assert "### Environment" in block assert "Use uv run python" in block assert "Trailing text." not in block def test_returns_none_when_absent(self): """File without any marker delimiters yields None.""" assert extract_marker_block("# Project\n\nJust a regular README.\n") is None def test_returns_none_when_only_start_marker(self): """Partial/malformed block (start only) yields None — writer expects both delimiters.""" content = f"prefix\n{_MARKER_START}\n### Something\n- content\n" assert extract_marker_block(content) is None def test_returns_empty_block_when_markers_are_adjacent(self): """A block with nothing between the markers is still returned (caller's choice what to do).""" content = f"prefix\n{_MARKER_START}\n{_MARKER_END}\nsuffix\n" block = extract_marker_block(content) assert block is not None assert block == f"{_MARKER_START}\n{_MARKER_END}" class TestEncodingResilience: """Regression tests for #1202 — ``learn --apply`` must not crash merging into an existing context file that carries a stray non-UTF-8 byte (e.g. a legacy cp1252 em-dash ``0x97``).""" def test_read_text_tolerant_preserves_valid_utf8(self, tmp_path): path = tmp_path / "AGENTS.md" path.write_text("Use em-dashes — and arrows →.", encoding="utf-8") assert _read_text_tolerant(path) == "Use em-dashes — and arrows →." def test_read_text_tolerant_survives_stray_legacy_byte(self, tmp_path): # Predominantly valid UTF-8 (genuine em-dash E2 80 94) plus one stray # cp1252 em-dash byte (0x97) that strict UTF-8 cannot decode. path = tmp_path / "AGENTS.md" path.write_bytes("real em-dash — here\n".encode() + b"legacy \x97 byte\n") # The old strict read aborts the whole --apply on that single byte. with pytest.raises(UnicodeDecodeError): path.read_text(encoding="utf-8") text = _read_text_tolerant(path) # Valid UTF-8 content is preserved (no cp1252 "â€" mojibake) and the # stray byte is replaced rather than fatal. assert "real em-dash — here" in text assert "\x97" not in text assert "â€" not in text def test_merge_into_file_applies_over_file_with_stray_byte(self, tmp_path): path = tmp_path / "AGENTS.md" path.write_bytes("# Notes — existing\n".encode() + b"stray \x97 byte\n") recs = [_rec(RecommendationTarget.CONTEXT_FILE, "Environment", "- Use uv")] merged = _merge_into_file(path, recs) assert "Use uv" in merged assert "Notes — existing" in merged