cc-os/plugins/os-doc-hygiene/scripts/state_store.py

320 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
State store for doc-hygiene.
Provides:
- resolve_project_root(start_dir, fs) — pure function, no side effects
- StateStore — confines all writes to <project_root>/.dochygiene/
Design invariants honoured:
#3 State lives in-project; no global index; never edit .gitignore.
#4 Report rollover keeps exactly one .md + .json pair.
#6 Deterministic-first; no model invoked here.
#9 (scanner's concern, not ours)
Atomic-write mechanism: write to a temp file in the same directory,
fsync the file descriptor, then os.replace (POSIX-atomic) onto the target.
A concurrent reader therefore observes either the prior complete file or the
new complete file, never a partial write.
"""
from __future__ import annotations
import json
import os
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, Protocol
# ---------------------------------------------------------------------------
# Lightweight filesystem abstraction used only by resolve_project_root
# so the pure function can be tested without touching real disk.
# ---------------------------------------------------------------------------
class _RootFS(Protocol):
"""Minimal filesystem surface for root resolution."""
def is_dir(self, path: Path) -> bool: ...
def parent(self, path: Path) -> Path: ...
class RealRootFS:
"""Production implementation — delegates to pathlib/os."""
def is_dir(self, path: Path) -> bool:
return path.is_dir()
def parent(self, path: Path) -> Path:
return path.parent
# ---------------------------------------------------------------------------
# Pure root-resolution function (task 3.1)
# ---------------------------------------------------------------------------
def resolve_project_root(start_dir: Path, fs: Optional[_RootFS] = None) -> Path:
"""
Walk upward from *start_dir* (inclusive) looking for a .git directory.
Returns the first ancestor (or start_dir itself) that contains a .git
directory. If no git root is found, returns start_dir unchanged.
This function is PURE: it never calls os.getcwd() or any other
stateful function; all filesystem access goes through *fs*. Production
callers pass os.getcwd() as start_dir; tests pass a fake fs and a
synthetic path.
"""
if fs is None:
fs = RealRootFS()
current = Path(start_dir)
while True:
if fs.is_dir(current / ".git"):
return current
parent = fs.parent(current)
if parent == current:
# Reached the filesystem root without finding .git.
break
current = parent
return Path(start_dir)
# ---------------------------------------------------------------------------
# Clock abstraction (task 3.3 / design D3)
# ---------------------------------------------------------------------------
class _Clock(Protocol):
"""Returns the current UTC datetime."""
def now(self) -> datetime: ...
class RealClock:
"""Production clock — returns the real UTC time."""
def now(self) -> datetime:
return datetime.now(timezone.utc)
# ---------------------------------------------------------------------------
# StateStore (tasks 3.23.5)
# ---------------------------------------------------------------------------
_STATE_FILE = "state.json"
_REPORT_JSON = "report.json"
_REPORT_MD = "report.md"
# The set of filenames that are *not* report files and must never be deleted
# during rollover. Explicit allowlist is safer than trying to infer.
_NON_REPORT_FILES = {_STATE_FILE}
class StateStore:
"""
Manages all persistent state for doc-hygiene within a single project.
All writes are confined to <project_root>/.dochygiene/ (invariant #3).
No global index is maintained; each project has its own independent store.
The store never opens or edits .gitignore (invariant #3).
Parameters
----------
project_root:
The resolved project root directory (output of resolve_project_root).
Injected so the store is testable with a tmp_path.
clock:
Provides "now". Injected for testability (design D3).
"""
TIMESTAMPS = ("last_check", "last_clean", "last_reminded")
def __init__(self, project_root: Path, clock: Optional[_Clock] = None) -> None:
self._root = Path(project_root)
self._clock = clock or RealClock()
self._state_dir = self._root / ".dochygiene"
# ------------------------------------------------------------------
# Directory bootstrap
# ------------------------------------------------------------------
def _ensure_state_dir(self) -> Path:
"""Create .dochygiene/ if it does not exist. Never touches .gitignore."""
self._state_dir.mkdir(parents=True, exist_ok=True)
return self._state_dir
# ------------------------------------------------------------------
# Atomic write (task 3.4 / design D9)
# ------------------------------------------------------------------
def _atomic_write(self, target: Path, data: bytes) -> None:
"""
Write *data* to *target* atomically.
Strategy: write to a NamedTemporaryFile in the same directory,
fsync, then os.replace onto the target. os.replace is POSIX-atomic
within one filesystem, so a concurrent reader sees either the prior
complete file or the new complete file, never a partial write.
"""
# Confirm the target is under our managed directory (confinement check).
self._assert_confined(target)
target_dir = target.parent
target_dir.mkdir(parents=True, exist_ok=True)
fd, tmp_path_str = tempfile.mkstemp(
dir=str(target_dir),
prefix="." + target.name + ".tmp_",
)
tmp_path = Path(tmp_path_str)
try:
with os.fdopen(fd, "wb") as fh:
fh.write(data)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp_path_str, str(target))
except Exception:
# Best-effort cleanup on failure; do not mask the original error.
try:
tmp_path.unlink(missing_ok=True)
except OSError:
pass
raise
def _assert_confined(self, path: Path) -> None:
"""Raise ValueError if *path* is not under self._state_dir."""
try:
path.resolve().relative_to(self._state_dir.resolve())
except ValueError:
raise ValueError(
f"StateStore attempted to write outside .dochygiene/: {path}"
)
# ------------------------------------------------------------------
# State JSON helpers
# ------------------------------------------------------------------
def _state_path(self) -> Path:
return self._state_dir / _STATE_FILE
def _read_state(self) -> dict:
"""Read state.json; return {} if missing or unreadable (never raises)."""
path = self._state_path()
try:
return json.loads(path.read_bytes())
except (FileNotFoundError, json.JSONDecodeError, OSError):
return {}
def _write_state(self, state: dict) -> None:
self._ensure_state_dir()
data = json.dumps(state, indent=2, sort_keys=True).encode()
self._atomic_write(self._state_path(), data)
# ------------------------------------------------------------------
# Lifecycle timestamps (task 3.3)
# ------------------------------------------------------------------
def get_timestamp(self, key: str) -> Optional[datetime]:
"""
Return the stored datetime for *key*, or None if absent/unset.
The value is stored as an ISO-8601 string in state.json.
"""
if key not in self.TIMESTAMPS:
raise ValueError(f"Unknown timestamp key: {key!r}")
raw = self._read_state().get(key)
if raw is None:
return None
return datetime.fromisoformat(raw)
def set_timestamp(self, key: str, value: Optional[datetime] = None) -> None:
"""
Write *key* to state.json.
If *value* is None, uses the injected clock's now().
The value is serialised as an ISO-8601 string (UTC).
"""
if key not in self.TIMESTAMPS:
raise ValueError(f"Unknown timestamp key: {key!r}")
ts = value if value is not None else self._clock.now()
# Normalise to UTC ISO-8601 string.
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
state = self._read_state()
state[key] = ts.isoformat()
self._write_state(state)
# Convenience shorthands
def set_last_check(self, value: Optional[datetime] = None) -> None:
self.set_timestamp("last_check", value)
def get_last_check(self) -> Optional[datetime]:
return self.get_timestamp("last_check")
def set_last_clean(self, value: Optional[datetime] = None) -> None:
self.set_timestamp("last_clean", value)
def get_last_clean(self) -> Optional[datetime]:
return self.get_timestamp("last_clean")
def set_last_reminded(self, value: Optional[datetime] = None) -> None:
self.set_timestamp("last_reminded", value)
def get_last_reminded(self) -> Optional[datetime]:
return self.get_timestamp("last_reminded")
# ------------------------------------------------------------------
# Report rollover (task 3.5 / design D10)
# ------------------------------------------------------------------
def _delete_existing_reports(self) -> None:
"""
Delete any existing report.json and report.md in .dochygiene/.
Only report files (report.json, report.md) are deleted.
state.json and any other files are never touched.
"""
for name in (_REPORT_JSON, _REPORT_MD):
p = self._state_dir / name
try:
p.unlink()
except FileNotFoundError:
pass
def write_report(self, json_blob: str, md_blob: str) -> None:
"""
Write a new report pair, atomically, after deleting any prior pair.
After this call exactly one .json and one .md report file exist in
.dochygiene/ (invariant #4).
Parameters
----------
json_blob:
The machine-readable report JSON (as a string).
md_blob:
The human-readable report Markdown.
"""
self._ensure_state_dir()
# Delete prior pair first (rollover).
self._delete_existing_reports()
# Atomically write the new pair.
self._atomic_write(
self._state_dir / _REPORT_JSON,
json_blob.encode(),
)
self._atomic_write(
self._state_dir / _REPORT_MD,
md_blob.encode(),
)
def read_report(self) -> Optional[tuple[str, str]]:
"""
Return (json_blob, md_blob) if a report exists, else None.
"""
json_path = self._state_dir / _REPORT_JSON
md_path = self._state_dir / _REPORT_MD
try:
return json_path.read_text(), md_path.read_text()
except FileNotFoundError:
return None