mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## Description `headroom learn` wrote per-project learnings into the project's `CLAUDE.md`, which Claude Code treats as team-shared and git-tracked. That meant machine-specific absolute paths and tool-discovery byproducts polluted the shared file for every teammate. This switches the default to the personal, gitignored `CLAUDE.local.md`, adds a `--target` override, and migrates any stale block out of `CLAUDE.md`. Closes #1072. ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - `ClaudeCodeWriter` now writes CONTEXT_FILE recommendations to `CLAUDE.local.md` by default instead of `CLAUDE.md` (the home-directory case still uses `~/.claude/CLAUDE.md`, which is personal global memory). - Added a `--target` flag (Claude Code only) and `set_context_target()` to override the destination — e.g. `--target CLAUDE.md` to opt back into the shared file, or any relative/absolute path. - On first run after upgrade, a stale Headroom block left in `CLAUDE.md` is moved into `CLAUDE.local.md` and stripped from `CLAUDE.md`, with a warning surfaced by the CLI. If `CLAUDE.md` held nothing but the block, the empty file is removed. - `WriteResult` carries `warnings`; the `learn` CLI prints them. - Updated docs (`failure-learning.mdx`) and `CHANGELOG.md`. This implements the maintainer's stated preference order from the issue (default → `CLAUDE.local.md`, plus a `--target` flag), scoped to the Claude writer only — `AGENTS.md`/`GEMINI.md` have no `.local` convention and are untouched. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output ```text $ pytest tests/test_learn/ tests/test_cli_learn.py -q 196 passed, 2 skipped in 17.80s $ ruff check headroom/learn/writer.py headroom/cli/learn.py All checks passed! $ mypy headroom/learn/writer.py headroom/cli/learn.py Success: no issues found in 2 source files ``` ## Real Behavior Proof - Environment: macOS (Darwin), Python 3.11, headroom on rebased upstream/main - Exact command / steps: ran ClaudeCodeWriter against a temp project whose `CLAUDE.md` held hand-written content plus a legacy Headroom block, then `writer.write([...], dry_run=False)` - Observed result: `CLAUDE.md` kept its hand-written content with the block removed; `CLAUDE.local.md` gained both the migrated `### Old` section and the new `### Env` section; `result.warnings` contained the "Moved Headroom learnings out of …" notice. A block-only `CLAUDE.md` was deleted and a "Removed …" warning emitted. - Not tested: live end-to-end `headroom learn --apply` against real LLM analysis (writer + CLI plumbing covered by unit/CLI tests with mocked analysis) ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] I have updated CHANGELOG.md if applicable ## Additional Notes Scoped to the Claude Code writer per the issue. After migration, `discover_projects` may briefly re-surface a section the LLM re-derives, but the write-side merge dedups by section name so the file stays correct.
551 lines
22 KiB
Python
551 lines
22 KiB
Python
"""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
|