2026-07-03 15:12:31 +00:00
|
|
|
|
"""
|
|
|
|
|
|
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):
|
2026-07-12 21:47:01 +00:00
|
|
|
|
"""Every path written by the store is under .cc-os/dochygiene/."""
|
2026-07-03 15:12:31 +00:00
|
|
|
|
(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")
|
|
|
|
|
|
|
2026-07-12 21:47:01 +00:00
|
|
|
|
state_dir = tmp_path / ".cc-os" / "dochygiene"
|
2026-07-03 15:12:31 +00:00
|
|
|
|
for p in written_paths:
|
|
|
|
|
|
assert str(p).startswith(str(state_dir)), (
|
2026-07-12 21:47:01 +00:00
|
|
|
|
f"Write escaped .cc-os/dochygiene/: {p}"
|
2026-07-03 15:12:31 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-15 11:41:59 +00:00
|
|
|
|
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)
|
2026-07-03 15:12:31 +00:00
|
|
|
|
store.set_last_check()
|
|
|
|
|
|
store.write_report("{}", "md")
|
|
|
|
|
|
|
2026-07-15 11:41:59 +00:00
|
|
|
|
home_dh = fake_home / ".dochygiene"
|
|
|
|
|
|
home_cc_os = fake_home / ".cc-os"
|
2026-07-03 15:12:31 +00:00
|
|
|
|
assert not home_dh.exists(), "Global ~/.dochygiene must not be created"
|
2026-07-12 21:47:01 +00:00
|
|
|
|
assert not home_cc_os.exists(), "Global ~/.cc-os must not be created"
|
2026-07-03 15:12:31 +00:00
|
|
|
|
|
|
|
|
|
|
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):
|
2026-07-12 21:47:01 +00:00
|
|
|
|
"""_assert_confined must raise ValueError for a path outside .cc-os/dochygiene."""
|
2026-07-03 15:12:31 +00:00
|
|
|
|
store = StateStore(project_root=tmp_path)
|
2026-07-12 21:47:01 +00:00
|
|
|
|
with pytest.raises(ValueError, match="outside .cc-os/dochygiene"):
|
2026-07-03 15:12:31 +00:00
|
|
|
|
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."""
|
2026-07-12 21:47:01 +00:00
|
|
|
|
state_dir = tmp_path / ".cc-os" / "dochygiene"
|
|
|
|
|
|
state_dir.mkdir(parents=True)
|
2026-07-03 15:12:31 +00:00
|
|
|
|
(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)
|
2026-07-12 21:47:01 +00:00
|
|
|
|
state_dir = tmp_path / ".cc-os" / "dochygiene"
|
2026-07-03 15:12:31 +00:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-07-12 21:47:01 +00:00
|
|
|
|
state_path = tmp_path / ".cc-os" / "dochygiene" / "state.json"
|
2026-07-03 15:12:31 +00:00
|
|
|
|
|
|
|
|
|
|
# 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)
|
2026-07-12 21:47:01 +00:00
|
|
|
|
state_dir = tmp_path / ".cc-os" / "dochygiene"
|
|
|
|
|
|
state_dir.mkdir(parents=True, exist_ok=True)
|
2026-07-03 15:12:31 +00:00
|
|
|
|
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]:
|
2026-07-12 21:47:01 +00:00
|
|
|
|
state_dir = tmp_path / ".cc-os" / "dochygiene"
|
2026-07-03 15:12:31 +00:00
|
|
|
|
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}"
|
|
|
|
|
|
)
|
2026-07-12 21:47:01 +00:00
|
|
|
|
json_content = json.loads((tmp_path / ".cc-os" / "dochygiene" / "report.json").read_text())
|
2026-07-03 15:12:31 +00:00
|
|
|
|
assert json_content["run"] == 2, "Old report JSON was not replaced"
|
2026-07-12 21:47:01 +00:00
|
|
|
|
md_content = (tmp_path / ".cc-os" / "dochygiene" / "report.md").read_text()
|
2026-07-03 15:12:31 +00:00
|
|
|
|
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))
|
|
|
|
|
|
|
2026-07-12 21:47:01 +00:00
|
|
|
|
state_before = (tmp_path / ".cc-os" / "dochygiene" / "state.json").read_bytes()
|
2026-07-03 15:12:31 +00:00
|
|
|
|
|
|
|
|
|
|
store.write_report("{}", "md")
|
|
|
|
|
|
store.write_report("{}", "md2") # second rollover
|
|
|
|
|
|
|
2026-07-12 21:47:01 +00:00
|
|
|
|
state_after = (tmp_path / ".cc-os" / "dochygiene" / "state.json").read_bytes()
|
2026-07-03 15:12:31 +00:00
|
|
|
|
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):
|
|
|
|
|
|
"""
|
2026-07-12 21:47:01 +00:00
|
|
|
|
Scenario: git-root case — writes are under <git-root>/.cc-os/dochygiene/.
|
2026-07-03 15:12:31 +00:00
|
|
|
|
"""
|
|
|
|
|
|
(tmp_path / ".git").mkdir()
|
|
|
|
|
|
store = StateStore(project_root=tmp_path)
|
|
|
|
|
|
store.set_last_check()
|
|
|
|
|
|
store.write_report("{}", "md")
|
|
|
|
|
|
|
2026-07-12 21:47:01 +00:00
|
|
|
|
state_dir = tmp_path / ".cc-os" / "dochygiene"
|
2026-07-03 15:12:31 +00:00
|
|
|
|
for child in state_dir.iterdir():
|
2026-07-12 21:47:01 +00:00
|
|
|
|
# All files live inside .cc-os/dochygiene
|
2026-07-03 15:12:31 +00:00
|
|
|
|
assert child.parent == state_dir
|
|
|
|
|
|
|
|
|
|
|
|
# Nothing written outside the project root
|
2026-07-12 21:47:01 +00:00
|
|
|
|
cc_os_dir = tmp_path / ".cc-os"
|
2026-07-03 15:12:31 +00:00
|
|
|
|
unexpected = [
|
|
|
|
|
|
p for p in tmp_path.iterdir()
|
2026-07-12 21:47:01 +00:00
|
|
|
|
if p != cc_os_dir and p.name != ".git"
|
2026-07-03 15:12:31 +00:00
|
|
|
|
]
|
2026-07-12 21:47:01 +00:00
|
|
|
|
assert not unexpected, f"Unexpected files outside .cc-os/dochygiene: {unexpected}"
|
2026-07-03 15:12:31 +00:00
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
|
2026-07-12 21:47:01 +00:00
|
|
|
|
state_dir = tmp_path / ".cc-os" / "dochygiene"
|
2026-07-03 15:12:31 +00:00
|
|
|
|
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):
|
|
|
|
|
|
"""
|
2026-07-12 21:47:01 +00:00
|
|
|
|
Creating .cc-os/dochygiene/ for the first time must not touch .gitignore.
|
2026-07-03 15:12:31 +00:00
|
|
|
|
"""
|
|
|
|
|
|
(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)
|
2026-07-12 21:47:01 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ===========================================================================
|
|
|
|
|
|
# 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()
|