""" Tests for scripts/state_store.py Covers tasks 3.1–3.8 of the add-deterministic-core change. sys.path is patched here (no shared conftest) so the test file is self-contained, as required by the file-ownership constraints. """ from __future__ import annotations import json import os import sys from datetime import datetime, timezone from pathlib import Path from unittest.mock import patch import pytest # --------------------------------------------------------------------------- # Path bootstrap — inject scripts/ so we can import state_store directly. # --------------------------------------------------------------------------- _SCRIPTS_DIR = Path(__file__).parent.parent / "scripts" if str(_SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(_SCRIPTS_DIR)) from state_store import ( # noqa: E402 StateStore, resolve_project_root, ) # --------------------------------------------------------------------------- # Fake filesystem for root-resolution tests (no real disk needed) # --------------------------------------------------------------------------- class FakeFS: """ Simulates a directory tree for resolve_project_root tests. *git_roots* is a set of Path objects that "contain" a .git directory. """ def __init__(self, git_roots: set[Path]): self._git_roots = git_roots def is_dir(self, path: Path) -> bool: # path is something like /project/.git or /project/sub/.git if path.name == ".git": return path.parent in self._git_roots return True # every other dir "exists" def parent(self, path: Path) -> Path: return path.parent # =========================================================================== # Task 3.1 — resolve_project_root # =========================================================================== class TestResolveProjectRoot: def test_git_root_found_at_start_dir(self): """start_dir itself contains .git → return start_dir.""" root = Path("/project") fs = FakeFS(git_roots={root}) assert resolve_project_root(root, fs) == root def test_git_root_found_in_ancestor(self): """start_dir is nested; ancestor contains .git → return ancestor.""" root = Path("/project") nested = root / "src" / "module" fs = FakeFS(git_roots={root}) assert resolve_project_root(nested, fs) == root def test_fallback_to_cwd_when_no_git(self): """No ancestor has .git → return start_dir (cwd semantics).""" start = Path("/no/git/here") fs = FakeFS(git_roots=set()) result = resolve_project_root(start, fs) assert result == start def test_pure_no_os_getcwd_called(self, monkeypatch): """The pure function must never call os.getcwd() internally.""" calls = [] monkeypatch.setattr(os, "getcwd", lambda: calls.append(1) or "/spy") root = Path("/project") fs = FakeFS(git_roots={root}) resolve_project_root(root / "deep", fs) assert calls == [], "resolve_project_root must not call os.getcwd()" def test_git_root_not_intermediate_sibling(self): """Only ancestor .git directories count, not siblings.""" # /project has .git but we start at /other — should fall back to /other root = Path("/project") start = Path("/other/sub") fs = FakeFS(git_roots={root}) assert resolve_project_root(start, fs) == start # =========================================================================== # Task 3.2 — StateStore confines writes; no global index; no .gitignore edits # =========================================================================== class TestWriteConfinement: def test_all_writes_under_dochygiene(self, tmp_path): """Every path written by the store is under .cc-os/dochygiene/.""" (tmp_path / ".git").mkdir() store = StateStore(project_root=tmp_path) written_paths: list[Path] = [] original_replace = os.replace def recording_replace(src, dst): written_paths.append(Path(dst)) return original_replace(src, dst) with patch("os.replace", side_effect=recording_replace): store.set_last_check() store.set_last_reminded() store.write_report('{"ok": true}', "# Report\nDone") state_dir = tmp_path / ".cc-os" / "dochygiene" for p in written_paths: assert str(p).startswith(str(state_dir)), ( f"Write escaped .cc-os/dochygiene/: {p}" ) def test_no_global_index_created(self, tmp_path, monkeypatch): """Operating on one project must not create anything in ~/ or /tmp. HOME is redirected to an empty temp dir: asserting on the user's real home is flaky — other tools (e.g. os-backlog's projects registry) legitimately create ~/.cc-os outside this plugin's control. """ fake_home = tmp_path / "home" fake_home.mkdir() monkeypatch.setenv("HOME", str(fake_home)) project = tmp_path / "project" project.mkdir() (project / ".git").mkdir() store = StateStore(project_root=project) store.set_last_check() store.write_report("{}", "md") home_dh = fake_home / ".dochygiene" home_cc_os = fake_home / ".cc-os" assert not home_dh.exists(), "Global ~/.dochygiene must not be created" assert not home_cc_os.exists(), "Global ~/.cc-os must not be created" def test_gitignore_never_opened_or_written(self, tmp_path): """ .gitignore must never be opened or written. Strategy: pre-create .gitignore with sentinel content, run every store operation, assert bytes are unchanged AND no write path ends in .gitignore. """ (tmp_path / ".git").mkdir() gitignore = tmp_path / ".gitignore" sentinel = b"# sentinel -- must not change\n" gitignore.write_bytes(sentinel) written_paths: list[Path] = [] original_replace = os.replace def recording_replace(src, dst): written_paths.append(Path(dst)) return original_replace(src, dst) with patch("os.replace", side_effect=recording_replace): store = StateStore(project_root=tmp_path) store.set_last_check() store.set_last_clean() store.set_last_reminded() store.write_report("{}", "md") # Byte-identical sentinel check assert gitignore.read_bytes() == sentinel, ".gitignore content was altered" # No write path touches .gitignore for p in written_paths: assert p.name != ".gitignore", f"Write went to .gitignore: {p}" def test_assert_confined_raises_for_out_of_root_path(self, tmp_path): """_assert_confined must raise ValueError for a path outside .cc-os/dochygiene.""" store = StateStore(project_root=tmp_path) with pytest.raises(ValueError, match="outside .cc-os/dochygiene"): store._assert_confined(tmp_path / "other" / "file.txt") # =========================================================================== # Task 3.3 — Lifecycle timestamps (via real disk; injected clock) # =========================================================================== class FrozenClock: def __init__(self, dt: datetime): self._dt = dt def now(self) -> datetime: return self._dt class TestTimestamps: def test_timestamp_round_trip(self, tmp_path): """Write a timestamp; read it back; values match.""" fixed = datetime(2026, 6, 18, 12, 0, 0, tzinfo=timezone.utc) store = StateStore(project_root=tmp_path, clock=FrozenClock(fixed)) store.set_last_check(fixed) result = store.get_last_check() assert result == fixed def test_absent_timestamp_is_none(self, tmp_path): """Reading an absent timestamp returns None (not an error).""" store = StateStore(project_root=tmp_path) assert store.get_last_check() is None assert store.get_last_clean() is None assert store.get_last_reminded() is None def test_all_three_timestamps_coexist(self, tmp_path): """All three timestamps can be written and read independently.""" t1 = datetime(2026, 1, 1, tzinfo=timezone.utc) t2 = datetime(2026, 3, 1, tzinfo=timezone.utc) t3 = datetime(2026, 6, 1, tzinfo=timezone.utc) store = StateStore(project_root=tmp_path) store.set_last_check(t1) store.set_last_clean(t2) store.set_last_reminded(t3) assert store.get_last_check() == t1 assert store.get_last_clean() == t2 assert store.get_last_reminded() == t3 def test_clock_now_used_when_no_value_given(self, tmp_path): """set_timestamp without an explicit value uses the injected clock.""" fixed = datetime(2026, 6, 18, 9, 30, 0, tzinfo=timezone.utc) clock = FrozenClock(fixed) store = StateStore(project_root=tmp_path, clock=clock) store.set_last_reminded() # no explicit value → clock.now() assert store.get_last_reminded() == fixed def test_missing_state_file_reads_as_unset(self, tmp_path): """If state.json does not exist, all timestamps are None.""" store = StateStore(project_root=tmp_path) # Do not call any setter — state.json is absent. assert store.get_timestamp("last_check") is None def test_corrupt_state_file_reads_as_unset(self, tmp_path): """A corrupt state.json does not raise — timestamps read as None.""" state_dir = tmp_path / ".cc-os" / "dochygiene" state_dir.mkdir(parents=True) (state_dir / "state.json").write_bytes(b"not json {{") store = StateStore(project_root=tmp_path) assert store.get_last_check() is None def test_invalid_timestamp_key_raises(self, tmp_path): store = StateStore(project_root=tmp_path) with pytest.raises(ValueError, match="Unknown timestamp key"): store.get_timestamp("last_synced") # =========================================================================== # Task 3.4 — Atomic writes # =========================================================================== class TestAtomicWrites: def test_temp_file_in_same_directory(self, tmp_path): """ The temp file must be created in the same directory as the target, not in /tmp — os.replace is only atomic within one filesystem. """ store = StateStore(project_root=tmp_path) state_dir = tmp_path / ".cc-os" / "dochygiene" temp_paths_seen: list[Path] = [] original_replace = os.replace def spy_replace(src, dst): temp_paths_seen.append(Path(src)) return original_replace(src, dst) with patch("os.replace", side_effect=spy_replace): store.set_last_check() assert temp_paths_seen, "os.replace was never called" for tp in temp_paths_seen: assert tp.parent == state_dir, ( f"Temp file {tp} is not in state_dir {state_dir}" ) def test_torn_write_safety(self, tmp_path): """ Prove the atomic-write mechanism: At the moment os.replace is called, the *source* (temp) holds the complete new content and the *destination* holds either the complete old content or does not exist — never a partial write. We monkeypatch os.replace to intercept the call and check both invariants. """ store = StateStore(project_root=tmp_path) state_path = tmp_path / ".cc-os" / "dochygiene" / "state.json" # Write an initial "old" state. store.set_last_check(datetime(2026, 1, 1, tzinfo=timezone.utc)) old_bytes = state_path.read_bytes() observed_src_content: list[bytes] = [] observed_dst_content: list[bytes | None] = [] original_replace = os.replace def intercepting_replace(src, dst): # Record source content (must be complete new content). observed_src_content.append(Path(src).read_bytes()) # Record destination content (must be old-complete or non-existent). dst_path = Path(dst) if dst_path.exists(): observed_dst_content.append(dst_path.read_bytes()) else: observed_dst_content.append(None) return original_replace(src, dst) with patch("os.replace", side_effect=intercepting_replace): store.set_last_clean(datetime(2026, 6, 1, tzinfo=timezone.utc)) assert observed_src_content, "os.replace was not called" for src_bytes in observed_src_content: # Source must be valid JSON (complete new write). parsed = json.loads(src_bytes) assert isinstance(parsed, dict) for dst_bytes in observed_dst_content: if dst_bytes is not None: # Destination must be the old complete content. assert dst_bytes == old_bytes, ( "Destination was not the prior complete state at replace time" ) # None means no prior file — valid (first write). def test_no_direct_truncate_write_to_target(self, tmp_path): """ The store must never open the target in 'w'/'wb' mode directly — all writes go through the temp-then-replace path. """ store = StateStore(project_root=tmp_path) state_dir = tmp_path / ".cc-os" / "dochygiene" state_dir.mkdir(parents=True, exist_ok=True) state_target = state_dir / "state.json" opened_paths_with_write_mode: list[str] = [] real_open = open def spying_open(file, mode="r", *args, **kwargs): if isinstance(mode, str) and ("w" in mode or "x" in mode): opened_paths_with_write_mode.append(str(file)) return real_open(file, mode, *args, **kwargs) with patch("builtins.open", side_effect=spying_open): store.set_last_check() for p in opened_paths_with_write_mode: assert Path(p) != state_target, ( f"Target {state_target} was opened directly in write mode" ) # =========================================================================== # Task 3.5 — Report rollover # =========================================================================== class TestReportRollover: def _report_files(self, tmp_path: Path) -> list[Path]: state_dir = tmp_path / ".cc-os" / "dochygiene" return sorted(state_dir.glob("report.*")) def test_first_write_creates_exactly_one_pair(self, tmp_path): store = StateStore(project_root=tmp_path) store.write_report('{"run": 1}', "# Run 1") files = self._report_files(tmp_path) names = {f.name for f in files} assert names == {"report.json", "report.md"} def test_second_write_replaces_first(self, tmp_path): store = StateStore(project_root=tmp_path) store.write_report('{"run": 1}', "# Run 1") store.write_report('{"run": 2}', "# Run 2") files = self._report_files(tmp_path) names = {f.name for f in files} assert names == {"report.json", "report.md"}, ( f"Expected exactly one pair, found: {names}" ) json_content = json.loads((tmp_path / ".cc-os" / "dochygiene" / "report.json").read_text()) assert json_content["run"] == 2, "Old report JSON was not replaced" md_content = (tmp_path / ".cc-os" / "dochygiene" / "report.md").read_text() assert "Run 2" in md_content, "Old report MD was not replaced" def test_many_writes_leave_exactly_one_pair(self, tmp_path): store = StateStore(project_root=tmp_path) for i in range(10): store.write_report(f'{{"run": {i}}}', f"# Run {i}") files = self._report_files(tmp_path) names = {f.name for f in files} assert names == {"report.json", "report.md"} def test_rollover_does_not_delete_state_json(self, tmp_path): """Report rollover must NEVER delete state.json (critical trap #1).""" store = StateStore(project_root=tmp_path) store.set_last_check(datetime(2026, 6, 1, tzinfo=timezone.utc)) state_before = (tmp_path / ".cc-os" / "dochygiene" / "state.json").read_bytes() store.write_report("{}", "md") store.write_report("{}", "md2") # second rollover state_after = (tmp_path / ".cc-os" / "dochygiene" / "state.json").read_bytes() assert state_before == state_after, ( "state.json was modified or deleted during report rollover" ) def test_report_content_survives_rollover(self, tmp_path): """After a rollover the latest content is readable.""" store = StateStore(project_root=tmp_path) store.write_report('{"v": "first"}', "# First") store.write_report('{"v": "second"}', "# Second") pair = store.read_report() assert pair is not None json_blob, md_blob = pair assert json.loads(json_blob)["v"] == "second" assert "Second" in md_blob def test_read_report_returns_none_when_absent(self, tmp_path): store = StateStore(project_root=tmp_path) assert store.read_report() is None # =========================================================================== # Task 3.6 — Root resolution: write confinement (no path outside root written) # =========================================================================== class TestRootResolutionConfinement: def test_writes_confined_after_git_root_resolution(self, tmp_path): """ Scenario: git-root case — writes are under /.cc-os/dochygiene/. """ (tmp_path / ".git").mkdir() store = StateStore(project_root=tmp_path) store.set_last_check() store.write_report("{}", "md") state_dir = tmp_path / ".cc-os" / "dochygiene" for child in state_dir.iterdir(): # All files live inside .cc-os/dochygiene assert child.parent == state_dir # Nothing written outside the project root cc_os_dir = tmp_path / ".cc-os" unexpected = [ p for p in tmp_path.iterdir() if p != cc_os_dir and p.name != ".git" ] assert not unexpected, f"Unexpected files outside .cc-os/dochygiene: {unexpected}" def test_writes_confined_in_cwd_fallback(self, tmp_path): """ Scenario: cwd-fallback case — no .git anywhere, store still confines writes. """ store = StateStore(project_root=tmp_path) # no .git created store.set_last_reminded() state_dir = tmp_path / ".cc-os" / "dochygiene" assert state_dir.exists() for child in state_dir.iterdir(): assert child.parent == state_dir def test_gitignore_not_edited_when_creating_state_dir(self, tmp_path): """ Creating .cc-os/dochygiene/ for the first time must not touch .gitignore. """ (tmp_path / ".git").mkdir() gitignore = tmp_path / ".gitignore" sentinel = b"*.pyc\n" gitignore.write_bytes(sentinel) store = StateStore(project_root=tmp_path) store.set_last_check() # triggers _ensure_state_dir internally assert gitignore.read_bytes() == sentinel # =========================================================================== # Fixtures under tests/fixtures/state_store/ are used as doc-tree inputs # for integration smoke tests below. # =========================================================================== class TestFixtures: """ Smoke-test that the fixture directory exists and the store works on a fixture-style project layout (a directory with .git). """ FIXTURE_DIR = Path(__file__).parent / "fixtures" / "state_store" def test_fixture_dir_exists(self): assert self.FIXTURE_DIR.exists(), ( f"Fixture dir missing: {self.FIXTURE_DIR}" ) def test_store_works_in_fixture_project(self): fixture_proj = self.FIXTURE_DIR / "sample_project" assert fixture_proj.exists(), ( f"sample_project fixture missing: {fixture_proj}" ) # Use the fixture as a read-only project root; write state into a tmp copy. import tempfile, shutil with tempfile.TemporaryDirectory() as td: proj = Path(td) / "proj" shutil.copytree(fixture_proj, proj) store = StateStore(project_root=proj) store.set_last_check(datetime(2026, 6, 18, tzinfo=timezone.utc)) assert store.get_last_check() == datetime(2026, 6, 18, tzinfo=timezone.utc) # =========================================================================== # ADR-027 — legacy .dochygiene/ fallback reads + auto-migration on first write # =========================================================================== class TestLegacyFallbackAndMigration: def _write_legacy_state(self, tmp_path: Path, state: dict) -> Path: legacy_dir = tmp_path / ".dochygiene" legacy_dir.mkdir(parents=True, exist_ok=True) (legacy_dir / "state.json").write_text(json.dumps(state)) return legacy_dir def _write_legacy_report(self, tmp_path: Path, json_blob: str, md_blob: str) -> Path: legacy_dir = tmp_path / ".dochygiene" legacy_dir.mkdir(parents=True, exist_ok=True) (legacy_dir / "report.json").write_text(json_blob) (legacy_dir / "report.md").write_text(md_blob) return legacy_dir def test_reads_fall_back_to_legacy_timestamp(self, tmp_path): """A timestamp written only under legacy .dochygiene/ is still readable.""" fixed = datetime(2026, 6, 18, 12, 0, 0, tzinfo=timezone.utc) self._write_legacy_state(tmp_path, {"last_check": fixed.isoformat()}) store = StateStore(project_root=tmp_path) assert store.get_last_check() == fixed # Canonical dir must NOT have been created by a pure read. assert not (tmp_path / ".cc-os").exists() def test_reads_fall_back_to_legacy_report(self, tmp_path): """A report written only under legacy .dochygiene/ is still readable.""" self._write_legacy_report(tmp_path, '{"v": "legacy"}', "# Legacy") store = StateStore(project_root=tmp_path) pair = store.read_report() assert pair is not None json_blob, md_blob = pair assert json.loads(json_blob)["v"] == "legacy" assert "Legacy" in md_blob assert not (tmp_path / ".cc-os").exists() def test_canonical_state_takes_precedence_over_legacy(self, tmp_path): """If both canonical and legacy state exist, canonical wins.""" legacy_ts = datetime(2026, 1, 1, tzinfo=timezone.utc) canonical_ts = datetime(2026, 6, 1, tzinfo=timezone.utc) self._write_legacy_state(tmp_path, {"last_check": legacy_ts.isoformat()}) store = StateStore(project_root=tmp_path) store.set_last_check(canonical_ts) # triggers migration + then overwrites assert store.get_last_check() == canonical_ts def test_first_write_migrates_legacy_state_and_removes_legacy_dir(self, tmp_path): """ First write triggers migration: pre-existing legacy files are copied into the canonical dir, and the legacy dir is removed entirely. """ legacy_ts = datetime(2026, 1, 1, tzinfo=timezone.utc) self._write_legacy_state(tmp_path, {"last_check": legacy_ts.isoformat()}) self._write_legacy_report(tmp_path, '{"v": "legacy"}', "# Legacy") store = StateStore(project_root=tmp_path) store.set_last_clean(datetime(2026, 6, 1, tzinfo=timezone.utc)) legacy_dir = tmp_path / ".dochygiene" canonical_dir = tmp_path / ".cc-os" / "dochygiene" assert not legacy_dir.exists(), "Legacy .dochygiene/ must be removed after migration" assert canonical_dir.is_dir() # Migrated last_check must be preserved alongside the newly-written last_clean. assert store.get_last_check() == legacy_ts assert store.get_last_clean() == datetime(2026, 6, 1, tzinfo=timezone.utc) # Migrated report is preserved too. pair = store.read_report() assert pair is not None assert json.loads(pair[0])["v"] == "legacy" def test_migration_does_not_clobber_existing_canonical_files(self, tmp_path): """ If the canonical dir already has a file with the same name as a legacy file, the canonical file wins (never overwritten by the legacy copy). """ canonical_dir = tmp_path / ".cc-os" / "dochygiene" canonical_dir.mkdir(parents=True) (canonical_dir / "state.json").write_text( json.dumps({"last_check": "2026-06-01T00:00:00+00:00"}) ) self._write_legacy_state(tmp_path, {"last_check": "2026-01-01T00:00:00+00:00"}) store = StateStore(project_root=tmp_path) # Any write triggers the migration pass. store.set_last_reminded(datetime(2026, 7, 1, tzinfo=timezone.utc)) assert not (tmp_path / ".dochygiene").exists() assert store.get_last_check() == datetime(2026, 6, 1, tzinfo=timezone.utc) def test_new_project_with_no_legacy_dir_is_unaffected(self, tmp_path): """No legacy dir present → normal operation, no migration attempted.""" store = StateStore(project_root=tmp_path) store.set_last_check(datetime(2026, 6, 1, tzinfo=timezone.utc)) assert not (tmp_path / ".dochygiene").exists() assert (tmp_path / ".cc-os" / "dochygiene" / "state.json").is_file()