Files
TheRock/rocm-libraries/projects/hipblaslt/.agent/docs/doc_agent_state.py
T
derek.holloway 7502bf64a4
CodeQL / Analyze (actions) (push) Canceled after 0s
CodeQL / Analyze (python) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Multi-Arch CI / setup (push) Canceled after 0s
Publish build_manylinux_x86_64 images / publish_build_manylinux_x86_64 (push) Canceled after 0s
Setup (Multi-Arch) / setup (push) Canceled after 0s
Multi-Arch CI / Linux::${{ fromJSON(needs.setup.outputs.linux_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubi10 image / publish_no_rocm_image_ubi10 (push) Canceled after 0s
Multi-Arch CI / Windows::${{ fromJSON(needs.setup.outputs.windows_build_config || '{}').build_variant_label || 'skip' }} (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Multi-Arch CI / Manifest Diff (push) Canceled after 0s
TheRock Manifest Diff Report / Generate Manifest Diff Report (push) Canceled after 0s
Multi-Arch CI / CI Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_ocl_rt images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04_rocgdb images / publish_no_rocm_image_ubuntu24_04_rocgdb (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_ocl_rt (push) Canceled after 0s
Publish TheRock Docker image / build-and-push-image (push) Canceled after 0s
Unit Tests / Unit Tests :: ubuntu-24.04 (push) Canceled after 0s
Unit Tests / Unit Tests :: windows-2022 (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_media (push) Canceled after 0s
Unit Tests / Unit Tests Summary (push) Canceled after 0s
Publish no_rocm_image_ubuntu24_04 images / publish_no_rocm_image_ubuntu24_04_openmpi (push) Canceled after 0s
Flatten subprojects into a single base monorepo
2026-09-02 16:34:47 -07:00

1306 lines
49 KiB
Python

#!/usr/bin/env python3
"""
State management helper for the documentation agent.
Usage:
python doc_agent_state.py init
python doc_agent_state.py get-work
python doc_agent_state.py mark-visited --dir DIR --covered FILE1,FILE2
python doc_agent_state.py finish-run
python doc_agent_state.py show
State model
-----------
The state file tracks every directory under the configured targets that
contains documentable source files. Each directory entry holds:
last_visited — ISO timestamp of the last run that worked on
this directory (None if never visited).
files_covered — source filenames discussed in at least one doc.
runs_since_last_visit — how many runs have passed since this directory
was last worked on (reset to 0 on visit).
visited_this_run — flag set by mark-visited, cleared by finish-run.
Used to determine which directories to increment.
Uncovered files are not stored in state. Instead, ``get-work`` computes
them on the fly as ``all_files - files_covered`` using the current
filesystem scan. This avoids stale entries for deleted or renamed files.
Lifecycle of a directory across runs:
1. ``init`` scans the filesystem and creates an entry for every
directory with documentable files.
2. ``get-work`` re-scans the filesystem each run:
- New directories are added to state.
- Deleted directories are removed from state.
It then selects up to two directories to work on (one reactive,
one proactive) based on priority queues.
Reactive changes are tracked at the **directory** level, not per
file. ``git diff`` identifies which files changed, and those
changes are grouped by parent directory. Only the directory
names enter the reactive queue — there is no per-file backlog.
Because only two directories can be worked on per run, reactive
directories that aren't picked are saved to ``pending_reactive``
so they carry over to the next run instead of being lost when
``last_commit`` advances. Note that for carried-over
directories, the file-level diff is no longer available (the
stored commit hash has advanced past those changes). The agent
must compare existing docs against current source code to
identify what needs updating.
3. ``mark-visited`` is called after the agent documents a directory.
It updates files_covered and resets runs_since_last_visit to 0.
4. ``finish-run`` increments runs_since_last_visit for all
directories NOT visited this run and records the current commit.
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
from datetime import datetime, timezone
from fnmatch import fnmatch
from pathlib import Path
from typing import Any
SCRIPT_DIR: Path = Path(__file__).resolve().parent
STATE_FILE: Path = SCRIPT_DIR / ".doc-agent-state.json"
TARGETS_FILE: Path = SCRIPT_DIR / "targets.json"
DOCUMENTABLE_EXTENSIONS: set[str] = {".py", ".cpp", ".h", ".hpp", ".yaml", ".yml", ".cmake", ".sh"}
SKIP_DIRS: set[str] = {".*", "docs", "__pycache__", "build", "dist", "*.egg-info"}
SKIP_FILES: set[str] = {".*"}
def load_targets() -> tuple[list[str], list[dict[str, str]]]:
"""Load target directories and exclude rules from the targets config file.
Returns a tuple of (targets, excludes) where targets is a list of directory
paths relative to the repo root and excludes is a list of dicts, each with
``"dir"`` (a directory prefix) and ``"pattern"`` (an fnmatch pattern for
filenames). Any file whose directory starts with ``dir`` and whose name
matches ``pattern`` is excluded from scanning.
"""
if not TARGETS_FILE.exists():
print(f"Error: targets file not found at {TARGETS_FILE}", file=sys.stderr)
sys.exit(1)
with open(TARGETS_FILE) as f:
config = json.load(f)
repo_root = get_repo_root()
targets: list[str] = []
for t in config["targets"]:
target_path = repo_root / t
if not target_path.is_dir():
print(f"Warning: target directory '{t}' does not exist, skipping.", file=sys.stderr)
continue
targets.append(t)
excludes: list[dict[str, str]] = config.get("exclude", [])
return targets, excludes
def load_state() -> dict[str, Any] | None:
if STATE_FILE.exists():
with open(STATE_FILE) as f:
return json.load(f)
return None
def save_state(state: dict[str, Any]) -> None:
with open(STATE_FILE, "w") as f:
json.dump(state, f, indent=2)
print(f"State written to {STATE_FILE}")
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def get_repo_root() -> Path:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True, text=True, cwd=SCRIPT_DIR
)
if result.returncode != 0:
print("Error: not inside a git repository", file=sys.stderr)
sys.exit(1)
return Path(result.stdout.strip())
def git_rev_parse_head() -> str:
result = subprocess.run(
["git", "rev-parse", "HEAD"],
capture_output=True, text=True, cwd=SCRIPT_DIR
)
if result.returncode != 0:
print(f"Error running git rev-parse HEAD: {result.stderr}", file=sys.stderr)
sys.exit(1)
return result.stdout.strip()
def git_diff_files(last_commit: str, targets: list[str]) -> list[str]:
repo_root = get_repo_root()
cmd = ["git", "diff", "--name-only", f"{last_commit}..HEAD", "--"] + targets
result = subprocess.run(cmd, capture_output=True, text=True, cwd=repo_root)
if result.returncode != 0:
return []
return [line.strip() for line in result.stdout.strip().splitlines() if line.strip()]
def should_skip_dir(dirname: str) -> bool:
"""Return True if a directory should be excluded from scanning.
A directory is skipped if it matches any pattern in SKIP_DIRS.
The ".*" entry in SKIP_DIRS handles all hidden directories.
"""
return any(fnmatch(dirname, pat) for pat in SKIP_DIRS)
def is_documentable(filename: str) -> bool:
"""Return True if a file should be considered for documentation.
A file is skipped if it matches any pattern in SKIP_FILES (e.g. ".*"
for hidden files). Otherwise it must have an extension listed in
DOCUMENTABLE_EXTENSIONS.
"""
if any(fnmatch(filename, pat) for pat in SKIP_FILES):
return False
return Path(filename).suffix in DOCUMENTABLE_EXTENSIONS
def is_excluded(dir_path: str, filename: str, excludes: list[dict[str, str]]) -> bool:
"""Return True if a file should be excluded from scanning.
Each exclude rule has a ``"dir"`` prefix and a ``"pattern"`` fnmatch glob.
A file is excluded when its directory starts with the rule's dir and its
filename matches the rule's pattern.
"""
for exc in excludes:
if dir_path.startswith(exc["dir"]) and fnmatch(filename, exc["pattern"]):
return True
return False
def scan_directories(targets: list[str], excludes: list[dict[str, str]] | None = None) -> dict[str, list[str]]:
"""Scan all target roots and return a dict of dir_path -> list of documentable filenames."""
if excludes is None:
excludes = []
repo_root = get_repo_root()
directories: dict[str, list[str]] = {}
for target in targets:
code_root = repo_root / target
for root, dirs, files in os.walk(code_root):
# In-place modification of dirs prunes os.walk's traversal —
# subdirectories removed here will not be visited.
dirs[:] = [d for d in dirs if not should_skip_dir(d)]
rel_path = str(Path(root).relative_to(repo_root))
documentable = [
f for f in files
if is_documentable(f) and not is_excluded(rel_path, f, excludes)
]
if documentable:
directories[rel_path] = sorted(documentable)
return directories
def has_docs_dir(dir_path: str) -> bool:
repo_root = get_repo_root()
docs_path = repo_root / dir_path / "docs"
return docs_path.is_dir()
def cmd_init(_args: argparse.Namespace) -> None:
"""Initialize the state file by scanning the directory tree."""
if STATE_FILE.exists():
print("State file already exists. Use 'show' to inspect it.", file=sys.stderr)
sys.exit(1)
targets, excludes = load_targets()
scanned = scan_directories(targets, excludes)
directories = {}
for dir_path, files in scanned.items():
directories[dir_path] = {
"last_visited": None,
"files_covered": [],
"runs_since_last_visit": 0,
"visited_this_run": False,
}
state = {
"last_commit": git_rev_parse_head(),
"last_run": now_iso(),
"run_count": 0,
"directories": directories,
}
save_state(state)
print(f"Initialized state with {len(directories)} directories across {len(targets)} target(s).")
def cmd_get_work(_args: argparse.Namespace) -> None:
"""Determine the two work items for this run and print them.
Work is selected from two priority queues:
**Reactive queue** — directories where source files have changed since
the last run (git diff). These need their existing documentation
updated to reflect the code changes. Sorted by number of changed
files (most changes first).
**Proactive queue** — directories that need *new* documentation work,
independent of recent code changes. Prioritised in this order:
1. Directories with no ``docs/`` directory yet (brand-new docs).
2. Directories whose docs exist but have uncovered source files.
3. Directories whose docs are fully covered but haven't been
reviewed for staleness recently (sorted by
``runs_since_last_visit``, most stale first).
The two slots are filled as follows:
* slot1 = top of the reactive queue (if non-empty), otherwise top
of the proactive queue.
* slot2 = first directory with missing documentation (no docs or
partial docs), then reactive, then staleness review — skipping
slot1 to avoid duplicates.
"""
state = load_state()
if state is None:
print("No state file found. Run 'init' first.", file=sys.stderr)
sys.exit(1)
targets, excludes = load_targets()
# Refresh directory list: add new dirs, remove deleted ones
scanned = scan_directories(targets, excludes)
for dir_path in scanned:
if dir_path not in state["directories"]:
state["directories"][dir_path] = {
"last_visited": None,
"files_covered": [],
"runs_since_last_visit": 0,
"visited_this_run": False,
}
removed = [d for d in state["directories"] if d not in scanned]
for d in removed:
del state["directories"][d]
# ── Reactive queue ──────────────────────────────────────────────
# Directories whose source files changed since last run, plus any
# carried over from previous runs that weren't worked on yet.
reactive_set: set[str] = set(state.get("pending_reactive", []))
last_commit = state.get("last_commit")
if last_commit:
changed_files = git_diff_files(last_commit, targets)
dir_change_count: dict[str, int] = {}
for f in changed_files:
parent = str(Path(f).parent)
if parent in state["directories"]:
dir_change_count[parent] = dir_change_count.get(parent, 0) + 1
reactive_set.add(parent)
# Sort by change count (dirs from pending_reactive with no new
# changes get a count of 0, so they appear after dirs with fresh changes).
reactive_queue: list[str] = sorted(
reactive_set,
key=lambda d: dir_change_count.get(d, 0),
reverse=True,
)
else:
reactive_queue = sorted(reactive_set)
# ── Proactive queue ─────────────────────────────────────────────
# Directories that need new documentation work regardless of
# recent code changes. Priority: no docs > partial docs > stale.
no_docs: list[str] = []
partial_docs: list[str] = []
stalest: list[str] = []
for dir_path, entry in state["directories"].items():
all_files = set(scanned.get(dir_path, []))
uncovered = all_files - set(entry["files_covered"])
if not has_docs_dir(dir_path):
no_docs.append(dir_path)
elif uncovered:
partial_docs.append(dir_path)
else:
stalest.append(dir_path)
stalest.sort(key=lambda d: state["directories"][d]["runs_since_last_visit"],
reverse=True)
# High-priority proactive: directories with missing docs (no_docs + partial_docs).
# Low-priority proactive: staleness reviews (stalest).
# Reactive work (code changed) is more important than staleness reviews
# but less important than genuinely missing documentation.
high_priority_proactive: list[str] = no_docs + partial_docs
# ── Fill the two work slots ─────────────────────────────────────
slot1: str | None = None
slot1_source: str | None = None
slot2: str | None = None
slot2_source: str | None = None
# slot1: prefer reactive work; fall back to proactive.
if reactive_queue:
slot1 = reactive_queue[0]
slot1_source = "reactive"
elif high_priority_proactive:
slot1 = high_priority_proactive[0]
slot1_source = "proactive"
elif stalest:
slot1 = stalest[0]
slot1_source = "proactive"
# slot2: prefer high-priority proactive, then reactive, then stalest.
# Skip slot1 to avoid duplicates.
for candidate in high_priority_proactive:
if candidate != slot1:
slot2 = candidate
slot2_source = "proactive"
break
if slot2 is None:
for candidate in reactive_queue:
if candidate != slot1:
slot2 = candidate
slot2_source = "reactive"
break
if slot2 is None:
for candidate in stalest:
if candidate != slot1:
slot2 = candidate
slot2_source = "proactive"
break
# ── Build output ────────────────────────────────────────────────
output: dict[str, Any] = {
"slot1": None,
"slot2": None,
"reactive_queue_size": len(reactive_queue),
"proactive_queue_size": len(high_priority_proactive) + len(stalest),
}
if slot1:
entry = state["directories"][slot1]
all_files = scanned.get(slot1, [])
output["slot1"] = {
"directory": slot1,
"source": slot1_source,
"has_docs": has_docs_dir(slot1),
"files_covered": entry["files_covered"],
"files_uncovered": sorted(set(all_files) - set(entry["files_covered"])),
"all_files": all_files,
"runs_since_last_visit": entry["runs_since_last_visit"],
}
if slot2:
entry = state["directories"][slot2]
all_files = scanned.get(slot2, [])
output["slot2"] = {
"directory": slot2,
"source": slot2_source,
"has_docs": has_docs_dir(slot2),
"files_covered": entry["files_covered"],
"files_uncovered": sorted(set(all_files) - set(entry["files_covered"])),
"all_files": all_files,
"runs_since_last_visit": entry["runs_since_last_visit"],
}
# Persist reactive directories that weren't picked up this run.
# These carry over so they aren't lost when last_commit advances.
picked = {slot1, slot2}
state["pending_reactive"] = [d for d in reactive_queue if d not in picked]
# Save any directory list updates we made
save_state(state)
print(json.dumps(output, indent=2))
def cmd_mark_visited(args: argparse.Namespace) -> None:
"""Mark a directory as visited and update its covered source file list."""
state = load_state()
if state is None:
print("No state file found. Run 'init' first.", file=sys.stderr)
sys.exit(1)
dir_path = args.dir
if dir_path not in state["directories"]:
print(f"Directory '{dir_path}' not found in state.", file=sys.stderr)
sys.exit(1)
covered = [f.strip() for f in args.covered.split(",") if f.strip()] if args.covered else []
entry = state["directories"][dir_path]
# Add newly covered source files (avoid duplicates)
existing_covered = set(entry["files_covered"])
for f in covered:
existing_covered.add(f)
entry["files_covered"] = sorted(existing_covered)
entry["last_visited"] = now_iso()
entry["runs_since_last_visit"] = 0
entry["visited_this_run"] = True
save_state(state)
print(f"Marked '{dir_path}' as visited. Covered: {len(entry['files_covered'])}")
def cmd_finish_run(_args: argparse.Namespace) -> None:
"""End-of-run bookkeeping: increment counters, update commit hash."""
state = load_state()
if state is None:
print("No state file found. Run 'init' first.", file=sys.stderr)
sys.exit(1)
visited_dirs: set[str] = set()
for dir_path, entry in state["directories"].items():
if entry.get("visited_this_run"):
entry["visited_this_run"] = False
visited_dirs.add(dir_path)
else:
entry["runs_since_last_visit"] += 1
# Remove visited directories from pending_reactive
state["pending_reactive"] = [
d for d in state.get("pending_reactive", []) if d not in visited_dirs
]
state["last_commit"] = git_rev_parse_head()
state["last_run"] = now_iso()
state["run_count"] = state.get("run_count", 0) + 1
save_state(state)
print(f"Run #{state['run_count']} completed.")
def cmd_show(_args: argparse.Namespace) -> None:
"""Print the current state file contents."""
state = load_state()
if state is None:
print("No state file found.")
return
print(json.dumps(state, indent=2))
def main() -> None:
parser = argparse.ArgumentParser(description="Documentation agent state manager")
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("init", help="Initialize state file by scanning directory tree")
subparsers.add_parser("get-work", help="Get the two work items for this run")
mark_parser = subparsers.add_parser("mark-visited", help="Mark a directory as visited")
mark_parser.add_argument("--dir", required=True, help="Directory path (relative to repo root)")
mark_parser.add_argument("--covered", default="", help="Comma-separated source files now covered by documentation")
subparsers.add_parser("finish-run", help="End-of-run bookkeeping")
subparsers.add_parser("show", help="Print current state")
args = parser.parse_args()
commands = {
"init": cmd_init,
"get-work": cmd_get_work,
"mark-visited": cmd_mark_visited,
"finish-run": cmd_finish_run,
"show": cmd_show,
}
commands[args.command](args)
if __name__ == "__main__":
main()
# ── Unit Tests ─────────────────────────────────────────────────────────
# Run with: pytest doc_agent_state.py -v
try:
import pytest
except ImportError:
pass
def test_should_skip_dir_hidden():
assert should_skip_dir(".git") is True
assert should_skip_dir(".hidden") is True
def test_should_skip_dir_named():
assert should_skip_dir("docs") is True
assert should_skip_dir("__pycache__") is True
assert should_skip_dir("build") is True
assert should_skip_dir("dist") is True
def test_should_skip_dir_egg_info():
assert should_skip_dir("foo.egg-info") is True
def test_should_skip_dir_allowed():
assert should_skip_dir("src") is False
assert should_skip_dir("library") is False
assert should_skip_dir("tests") is False
def test_is_documentable_valid_extensions():
for ext in DOCUMENTABLE_EXTENSIONS:
assert is_documentable(f"file{ext}") is True, f"Expected {ext} to be documentable"
def test_is_documentable_invalid_extensions():
assert is_documentable("file.txt") is False
assert is_documentable("file.json") is False
assert is_documentable("file.md") is False
assert is_documentable("file.o") is False
def test_is_documentable_hidden_files():
assert is_documentable(".hidden.py") is False
assert is_documentable(".gitignore") is False
def test_is_documentable_no_extension():
assert is_documentable("Makefile") is False
def test_now_iso_format():
result = now_iso()
# Should be parseable as ISO format and have timezone info
dt = datetime.fromisoformat(result)
assert dt.tzinfo is not None
def test_load_state_missing_file(tmp_path, monkeypatch):
monkeypatch.setattr("doc_agent_state.STATE_FILE", tmp_path / "nonexistent.json")
assert load_state() is None
def test_load_state_existing_file(tmp_path, monkeypatch):
state_file = tmp_path / "state.json"
data = {"last_commit": "abc123", "run_count": 1}
state_file.write_text(json.dumps(data))
monkeypatch.setattr("doc_agent_state.STATE_FILE", state_file)
result = load_state()
assert result == data
def test_save_state_creates_file(tmp_path, monkeypatch):
state_file = tmp_path / "state.json"
monkeypatch.setattr("doc_agent_state.STATE_FILE", state_file)
data = {"last_commit": "abc123", "run_count": 1}
save_state(data)
assert state_file.exists()
assert json.loads(state_file.read_text()) == data
def test_save_state_overwrites(tmp_path, monkeypatch):
state_file = tmp_path / "state.json"
state_file.write_text('{"old": true}')
monkeypatch.setattr("doc_agent_state.STATE_FILE", state_file)
new_data = {"new": True}
save_state(new_data)
assert json.loads(state_file.read_text()) == new_data
def test_load_targets_missing_file(tmp_path, monkeypatch):
monkeypatch.setattr("doc_agent_state.TARGETS_FILE", tmp_path / "nonexistent.json")
with pytest.raises(SystemExit):
load_targets()
def test_load_targets_valid(tmp_path, monkeypatch):
# Create a targets file and matching directories
targets_file = tmp_path / "targets.json"
targets_file.write_text(json.dumps({"targets": ["subdir_a", "subdir_b"]}))
(tmp_path / "subdir_a").mkdir()
(tmp_path / "subdir_b").mkdir()
monkeypatch.setattr("doc_agent_state.TARGETS_FILE", targets_file)
monkeypatch.setattr("doc_agent_state.get_repo_root", lambda: tmp_path)
targets, excludes = load_targets()
assert targets == ["subdir_a", "subdir_b"]
assert excludes == []
def test_load_targets_skips_missing_dirs(tmp_path, monkeypatch):
targets_file = tmp_path / "targets.json"
targets_file.write_text(json.dumps({"targets": ["exists", "missing"]}))
(tmp_path / "exists").mkdir()
monkeypatch.setattr("doc_agent_state.TARGETS_FILE", targets_file)
monkeypatch.setattr("doc_agent_state.get_repo_root", lambda: tmp_path)
targets, excludes = load_targets()
assert targets == ["exists"]
assert excludes == []
def test_load_targets_with_excludes(tmp_path, monkeypatch):
targets_file = tmp_path / "targets.json"
config = {
"targets": ["src"],
"exclude": [{"dir": "src/tests", "pattern": "*.yaml"}],
}
targets_file.write_text(json.dumps(config))
(tmp_path / "src").mkdir()
monkeypatch.setattr("doc_agent_state.TARGETS_FILE", targets_file)
monkeypatch.setattr("doc_agent_state.get_repo_root", lambda: tmp_path)
targets, excludes = load_targets()
assert targets == ["src"]
assert excludes == [{"dir": "src/tests", "pattern": "*.yaml"}]
def test_load_targets_with_multiple_excludes(tmp_path, monkeypatch):
targets_file = tmp_path / "targets.json"
config = {
"targets": ["src"],
"exclude": [
{"dir": "src/tests/common", "pattern": "*.yaml"},
{"dir": "src/docs", "pattern": "*"},
],
}
targets_file.write_text(json.dumps(config))
(tmp_path / "src").mkdir()
monkeypatch.setattr("doc_agent_state.TARGETS_FILE", targets_file)
monkeypatch.setattr("doc_agent_state.get_repo_root", lambda: tmp_path)
targets, excludes = load_targets()
assert targets == ["src"]
assert len(excludes) == 2
assert excludes[0] == {"dir": "src/tests/common", "pattern": "*.yaml"}
assert excludes[1] == {"dir": "src/docs", "pattern": "*"}
def test_get_repo_root_success(monkeypatch):
fake_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="/fake/root\n", stderr="")
monkeypatch.setattr("subprocess.run", lambda *a, **kw: fake_result)
assert get_repo_root() == Path("/fake/root")
def test_get_repo_root_failure(monkeypatch):
fake_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="not a repo")
monkeypatch.setattr("subprocess.run", lambda *a, **kw: fake_result)
with pytest.raises(SystemExit):
get_repo_root()
def test_git_rev_parse_head_success(monkeypatch):
fake_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="abc123def\n", stderr="")
monkeypatch.setattr("subprocess.run", lambda *a, **kw: fake_result)
assert git_rev_parse_head() == "abc123def"
def test_git_rev_parse_head_failure(monkeypatch):
fake_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="error")
monkeypatch.setattr("subprocess.run", lambda *a, **kw: fake_result)
with pytest.raises(SystemExit):
git_rev_parse_head()
def test_git_diff_files_success(monkeypatch):
fake_result = subprocess.CompletedProcess(
args=[], returncode=0, stdout="src/foo.py\nsrc/bar.cpp\n", stderr=""
)
monkeypatch.setattr("subprocess.run", lambda *a, **kw: fake_result)
monkeypatch.setattr("doc_agent_state.get_repo_root", lambda: Path("/fake"))
result = git_diff_files("abc123", ["src"])
assert result == ["src/foo.py", "src/bar.cpp"]
def test_git_diff_files_failure(monkeypatch):
fake_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="error")
monkeypatch.setattr("subprocess.run", lambda *a, **kw: fake_result)
monkeypatch.setattr("doc_agent_state.get_repo_root", lambda: Path("/fake"))
result = git_diff_files("abc123", ["src"])
assert result == []
def test_git_diff_files_empty_output(monkeypatch):
fake_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", lambda *a, **kw: fake_result)
monkeypatch.setattr("doc_agent_state.get_repo_root", lambda: Path("/fake"))
result = git_diff_files("abc123", ["src"])
assert result == []
def test_scan_directories(tmp_path, monkeypatch):
monkeypatch.setattr("doc_agent_state.get_repo_root", lambda: tmp_path)
# Create a target with some files
src = tmp_path / "src"
src.mkdir()
(src / "main.cpp").write_text("int main(){}")
(src / "util.h").write_text("// header")
(src / "readme.txt").write_text("not documentable")
(src / ".hidden.py").write_text("hidden")
# Create a hidden subdir that should be skipped
hidden = src / ".secret"
hidden.mkdir()
(hidden / "secret.py").write_text("secret")
result = scan_directories(["src"])
assert "src" in result
assert sorted(result["src"]) == ["main.cpp", "util.h"]
assert ".secret" not in str(result)
def test_scan_directories_nested(tmp_path, monkeypatch):
monkeypatch.setattr("doc_agent_state.get_repo_root", lambda: tmp_path)
src = tmp_path / "src"
sub = src / "sub"
sub.mkdir(parents=True)
(sub / "lib.py").write_text("pass")
result = scan_directories(["src"])
assert "src/sub" in result
assert result["src/sub"] == ["lib.py"]
def test_scan_directories_skips_build(tmp_path, monkeypatch):
monkeypatch.setattr("doc_agent_state.get_repo_root", lambda: tmp_path)
src = tmp_path / "src"
build = src / "build"
build.mkdir(parents=True)
(build / "output.cpp").write_text("generated")
(src / "real.cpp").write_text("real code")
result = scan_directories(["src"])
assert "src/build" not in result
assert "src" in result
def test_is_excluded_matches():
excludes = [{"dir": "src/tests/common", "pattern": "*.yaml"}]
assert is_excluded("src/tests/common", "foo.yaml", excludes) is True
def test_is_excluded_subdir_matches():
excludes = [{"dir": "src/tests", "pattern": "*.yaml"}]
assert is_excluded("src/tests/common/deep", "foo.yaml", excludes) is True
def test_is_excluded_no_match_wrong_dir():
excludes = [{"dir": "src/tests/common", "pattern": "*.yaml"}]
assert is_excluded("src/lib", "foo.yaml", excludes) is False
def test_is_excluded_no_match_wrong_pattern():
excludes = [{"dir": "src/tests/common", "pattern": "*.yaml"}]
assert is_excluded("src/tests/common", "main.cpp", excludes) is False
def test_is_excluded_empty_excludes():
assert is_excluded("src/tests", "anything.yaml", []) is False
def test_is_excluded_multiple_rules():
excludes = [
{"dir": "src/tests", "pattern": "*.yaml"},
{"dir": "src/build", "pattern": "*.cpp"},
]
assert is_excluded("src/tests/common", "foo.yaml", excludes) is True
assert is_excluded("src/build/gen", "out.cpp", excludes) is True
assert is_excluded("src/lib", "real.cpp", excludes) is False
def test_scan_directories_with_excludes(tmp_path, monkeypatch):
monkeypatch.setattr("doc_agent_state.get_repo_root", lambda: tmp_path)
# Create target with mixed files
tests = tmp_path / "src" / "tests" / "common"
tests.mkdir(parents=True)
(tests / "test1.yaml").write_text("test")
(tests / "test2.yaml").write_text("test")
(tests / "helper.py").write_text("code") # should NOT be excluded
(tmp_path / "src" / "main.cpp").write_text("code")
excludes = [{"dir": "src/tests/common", "pattern": "*.yaml"}]
result = scan_directories(["src"], excludes)
# main.cpp should be in src/
assert "src" in result
assert "main.cpp" in result["src"]
# helper.py should remain, yamls should be excluded
assert "src/tests/common" in result
assert result["src/tests/common"] == ["helper.py"]
def test_scan_directories_with_multiple_excludes(tmp_path, monkeypatch):
"""Multiple exclude rules each filter their respective directories."""
monkeypatch.setattr("doc_agent_state.get_repo_root", lambda: tmp_path)
# First excluded dir: yaml tests
tests = tmp_path / "src" / "tests" / "common"
tests.mkdir(parents=True)
(tests / "test1.yaml").write_text("test")
(tests / "helper.py").write_text("code")
# Second excluded dir: published docs (exclude everything)
docs = tmp_path / "src" / "docs"
docs.mkdir(parents=True)
(docs / "guide.md").write_text("guide") # not documentable anyway
(docs / "build.cmake").write_text("cmake") # documentable but excluded
(docs / "setup.sh").write_text("script") # documentable but excluded
# Non-excluded dir
lib = tmp_path / "src" / "lib"
lib.mkdir(parents=True)
(lib / "core.cpp").write_text("code")
(lib / "config.yaml").write_text("config")
excludes = [
{"dir": "src/tests/common", "pattern": "*.yaml"},
{"dir": "src/docs", "pattern": "*"},
]
result = scan_directories(["src"], excludes)
# tests/common: yaml excluded, helper.py remains
assert "src/tests/common" in result
assert result["src/tests/common"] == ["helper.py"]
# docs: everything excluded, directory should be absent
assert "src/docs" not in result
# lib: nothing excluded
assert "src/lib" in result
assert "core.cpp" in result["src/lib"]
assert "config.yaml" in result["src/lib"]
def test_scan_directories_excludes_drops_empty_dir(tmp_path, monkeypatch):
monkeypatch.setattr("doc_agent_state.get_repo_root", lambda: tmp_path)
# Directory with only excluded files should be omitted
tests = tmp_path / "src" / "tests" / "common"
tests.mkdir(parents=True)
(tests / "test1.yaml").write_text("test")
(tests / "test2.yaml").write_text("test")
excludes = [{"dir": "src/tests/common", "pattern": "*.yaml"}]
result = scan_directories(["src"], excludes)
assert "src/tests/common" not in result
def test_has_docs_dir_true(tmp_path, monkeypatch):
monkeypatch.setattr("doc_agent_state.get_repo_root", lambda: tmp_path)
(tmp_path / "mydir" / "docs").mkdir(parents=True)
assert has_docs_dir("mydir") is True
def test_has_docs_dir_false(tmp_path, monkeypatch):
monkeypatch.setattr("doc_agent_state.get_repo_root", lambda: tmp_path)
(tmp_path / "mydir").mkdir()
assert has_docs_dir("mydir") is False
def test_cmd_init_creates_state(tmp_path, monkeypatch):
state_file = tmp_path / "state.json"
monkeypatch.setattr("doc_agent_state.STATE_FILE", state_file)
monkeypatch.setattr("doc_agent_state.get_repo_root", lambda: tmp_path)
monkeypatch.setattr("doc_agent_state.git_rev_parse_head", lambda: "fakecommit")
# Create target dir with a file
(tmp_path / "src").mkdir()
(tmp_path / "src" / "main.cpp").write_text("code")
targets_file = tmp_path / "targets.json"
targets_file.write_text(json.dumps({"targets": ["src"]}))
monkeypatch.setattr("doc_agent_state.TARGETS_FILE", targets_file)
cmd_init(argparse.Namespace())
assert state_file.exists()
state = json.loads(state_file.read_text())
assert state["last_commit"] == "fakecommit"
assert state["run_count"] == 0
assert "src" in state["directories"]
def test_cmd_init_with_excludes(tmp_path, monkeypatch):
state_file = tmp_path / "state.json"
monkeypatch.setattr("doc_agent_state.STATE_FILE", state_file)
monkeypatch.setattr("doc_agent_state.get_repo_root", lambda: tmp_path)
monkeypatch.setattr("doc_agent_state.git_rev_parse_head", lambda: "fakecommit")
# Create target dir with mixed files
tests = tmp_path / "src" / "tests"
tests.mkdir(parents=True)
(tests / "test.yaml").write_text("test")
(tmp_path / "src" / "main.cpp").write_text("code")
targets_file = tmp_path / "targets.json"
targets_file.write_text(json.dumps({
"targets": ["src"],
"exclude": [{"dir": "src/tests", "pattern": "*.yaml"}],
}))
monkeypatch.setattr("doc_agent_state.TARGETS_FILE", targets_file)
cmd_init(argparse.Namespace())
state = json.loads(state_file.read_text())
# src/ should be present (has main.cpp), src/tests should not (only excluded yamls)
assert "src" in state["directories"]
assert "src/tests" not in state["directories"]
def test_cmd_init_refuses_if_state_exists(tmp_path, monkeypatch):
state_file = tmp_path / "state.json"
state_file.write_text("{}")
monkeypatch.setattr("doc_agent_state.STATE_FILE", state_file)
with pytest.raises(SystemExit):
cmd_init(argparse.Namespace())
def test_cmd_show_no_state(tmp_path, monkeypatch, capsys):
monkeypatch.setattr("doc_agent_state.STATE_FILE", tmp_path / "nonexistent.json")
cmd_show(argparse.Namespace())
assert "No state file found" in capsys.readouterr().out
def test_cmd_show_with_state(tmp_path, monkeypatch, capsys):
state_file = tmp_path / "state.json"
data = {"run_count": 5}
state_file.write_text(json.dumps(data))
monkeypatch.setattr("doc_agent_state.STATE_FILE", state_file)
cmd_show(argparse.Namespace())
output = capsys.readouterr().out
assert '"run_count": 5' in output
def test_cmd_mark_visited_updates_state(tmp_path, monkeypatch):
state_file = tmp_path / "state.json"
state = {
"last_commit": "abc",
"directories": {
"src": {
"last_visited": None,
"files_covered": ["old.py"],
"runs_since_last_visit": 3,
}
},
}
state_file.write_text(json.dumps(state))
monkeypatch.setattr("doc_agent_state.STATE_FILE", state_file)
args = argparse.Namespace(dir="src", covered="a.py,new.py")
cmd_mark_visited(args)
updated = json.loads(state_file.read_text())
entry = updated["directories"]["src"]
assert "a.py" in entry["files_covered"]
assert "new.py" in entry["files_covered"]
assert "old.py" in entry["files_covered"] # existing covered preserved
assert "files_uncovered" not in entry
assert entry["runs_since_last_visit"] == 0
assert entry["last_visited"] is not None
assert entry["visited_this_run"] is True
def test_cmd_mark_visited_no_state(tmp_path, monkeypatch):
monkeypatch.setattr("doc_agent_state.STATE_FILE", tmp_path / "nonexistent.json")
args = argparse.Namespace(dir="src", covered="")
with pytest.raises(SystemExit):
cmd_mark_visited(args)
def test_cmd_mark_visited_unknown_dir(tmp_path, monkeypatch):
state_file = tmp_path / "state.json"
state_file.write_text(json.dumps({"directories": {}}))
monkeypatch.setattr("doc_agent_state.STATE_FILE", state_file)
args = argparse.Namespace(dir="nonexistent", covered="")
with pytest.raises(SystemExit):
cmd_mark_visited(args)
def test_cmd_finish_run_increments_counters(tmp_path, monkeypatch):
state_file = tmp_path / "state.json"
state = {
"last_commit": "old",
"last_run": "2024-01-01",
"run_count": 2,
"directories": {
"visited": {
"last_visited": "2024-01-01",
"files_covered": [],
"runs_since_last_visit": 0,
"visited_this_run": True,
},
"not_visited": {
"last_visited": "2023-01-01",
"files_covered": [],
"runs_since_last_visit": 5,
"visited_this_run": False,
},
"never_visited": {
"last_visited": None,
"files_covered": [],
"runs_since_last_visit": 0,
"visited_this_run": False,
},
},
}
state_file.write_text(json.dumps(state))
monkeypatch.setattr("doc_agent_state.STATE_FILE", state_file)
monkeypatch.setattr("doc_agent_state.git_rev_parse_head", lambda: "newcommit")
cmd_finish_run(argparse.Namespace())
updated = json.loads(state_file.read_text())
assert updated["run_count"] == 3
assert updated["last_commit"] == "newcommit"
# "visited" had visited_this_run=True → stays at 0, flag cleared
assert updated["directories"]["visited"]["runs_since_last_visit"] == 0
assert updated["directories"]["visited"]["visited_this_run"] is False
# "not_visited" had visited_this_run=False → incremented to 6
assert updated["directories"]["not_visited"]["runs_since_last_visit"] == 6
# "never_visited" had visited_this_run=False → incremented to 1
assert updated["directories"]["never_visited"]["runs_since_last_visit"] == 1
def test_cmd_finish_run_no_state(tmp_path, monkeypatch):
monkeypatch.setattr("doc_agent_state.STATE_FILE", tmp_path / "nonexistent.json")
with pytest.raises(SystemExit):
cmd_finish_run(argparse.Namespace())
def test_cmd_get_work_no_state(tmp_path, monkeypatch):
monkeypatch.setattr("doc_agent_state.STATE_FILE", tmp_path / "nonexistent.json")
with pytest.raises(SystemExit):
cmd_get_work(argparse.Namespace())
def test_cmd_get_work_proactive_no_docs_first(tmp_path, monkeypatch, capsys):
"""Directories without docs/ should appear before those with docs."""
state_file = tmp_path / "state.json"
monkeypatch.setattr("doc_agent_state.STATE_FILE", state_file)
monkeypatch.setattr("doc_agent_state.get_repo_root", lambda: tmp_path)
monkeypatch.setattr("doc_agent_state.git_diff_files", lambda *a: [])
# Create two target dirs: one with docs, one without
(tmp_path / "has_docs" / "docs").mkdir(parents=True)
(tmp_path / "has_docs" / "file.cpp").write_text("code")
(tmp_path / "no_docs").mkdir()
(tmp_path / "no_docs" / "file.py").write_text("code")
targets_file = tmp_path / "targets.json"
targets_file.write_text(json.dumps({"targets": ["has_docs", "no_docs"]}))
monkeypatch.setattr("doc_agent_state.TARGETS_FILE", targets_file)
state = {
"last_commit": "abc",
"directories": {
"has_docs": {
"last_visited": None,
"files_covered": [],
"runs_since_last_visit": 10,
},
"no_docs": {
"last_visited": None,
"files_covered": [],
"runs_since_last_visit": 0,
},
},
}
state_file.write_text(json.dumps(state))
cmd_get_work(argparse.Namespace())
output = json.loads(capsys.readouterr().out.split("\n", 1)[-1]) # skip "State written" line
assert output["slot1"]["directory"] == "no_docs"
assert output["slot1"]["source"] == "proactive"
def test_cmd_get_work_computes_uncovered_on_the_fly(tmp_path, monkeypatch, capsys):
"""files_uncovered in output should be computed from all_files - files_covered."""
state_file = tmp_path / "state.json"
monkeypatch.setattr("doc_agent_state.STATE_FILE", state_file)
monkeypatch.setattr("doc_agent_state.get_repo_root", lambda: tmp_path)
monkeypatch.setattr("doc_agent_state.git_diff_files", lambda *a: [])
src = tmp_path / "src"
src.mkdir()
(src / "covered.py").write_text("code")
(src / "uncovered.py").write_text("code")
(src / "also_uncovered.cpp").write_text("code")
targets_file = tmp_path / "targets.json"
targets_file.write_text(json.dumps({"targets": ["src"]}))
monkeypatch.setattr("doc_agent_state.TARGETS_FILE", targets_file)
state = {
"last_commit": "abc",
"directories": {
"src": {
"last_visited": "2024-01-01",
"files_covered": ["covered.py"],
"runs_since_last_visit": 0,
},
},
}
state_file.write_text(json.dumps(state))
cmd_get_work(argparse.Namespace())
output = json.loads(capsys.readouterr().out.split("\n", 1)[-1])
# Uncovered should be computed: all_files - files_covered
slot = output["slot1"] or output["slot2"]
assert sorted(slot["files_uncovered"]) == ["also_uncovered.cpp", "uncovered.py"]
assert slot["files_covered"] == ["covered.py"]
assert sorted(slot["all_files"]) == ["also_uncovered.cpp", "covered.py", "uncovered.py"]
# State should NOT contain files_uncovered
updated = json.loads(state_file.read_text())
assert "files_uncovered" not in updated["directories"]["src"]
def test_cmd_get_work_slot2_falls_back_to_reactive(tmp_path, monkeypatch, capsys):
"""When proactive queue is empty, slot2 should fill from reactive queue."""
state_file = tmp_path / "state.json"
monkeypatch.setattr("doc_agent_state.STATE_FILE", state_file)
monkeypatch.setattr("doc_agent_state.get_repo_root", lambda: tmp_path)
# Two directories, both fully documented (proactive queue will be empty)
for name in ("dir_a", "dir_b"):
d = tmp_path / name
(d / "docs").mkdir(parents=True)
(d / "file.py").write_text("code")
targets_file = tmp_path / "targets.json"
targets_file.write_text(json.dumps({"targets": ["dir_a", "dir_b"]}))
monkeypatch.setattr("doc_agent_state.TARGETS_FILE", targets_file)
# Both directories have all files covered → empty proactive queue
# Both have reactive changes
monkeypatch.setattr("doc_agent_state.git_diff_files",
lambda *a: ["dir_a/file.py", "dir_b/file.py"])
state = {
"last_commit": "abc",
"directories": {
"dir_a": {
"last_visited": "2024-01-01",
"files_covered": ["file.py"],
"runs_since_last_visit": 0,
"visited_this_run": False,
},
"dir_b": {
"last_visited": "2024-01-01",
"files_covered": ["file.py"],
"runs_since_last_visit": 0,
"visited_this_run": False,
},
},
}
state_file.write_text(json.dumps(state))
cmd_get_work(argparse.Namespace())
output = json.loads(capsys.readouterr().out.split("\n", 1)[-1])
assert output["slot1"] is not None
assert output["slot1"]["source"] == "reactive"
assert output["slot2"] is not None
assert output["slot2"]["source"] == "reactive"
assert output["slot1"]["directory"] != output["slot2"]["directory"]
def test_cmd_get_work_saves_pending_reactive(tmp_path, monkeypatch, capsys):
"""Reactive directories not picked for slots are saved to pending_reactive."""
state_file = tmp_path / "state.json"
monkeypatch.setattr("doc_agent_state.STATE_FILE", state_file)
monkeypatch.setattr("doc_agent_state.get_repo_root", lambda: tmp_path)
# Three directories, all fully documented
for name in ("dir_a", "dir_b", "dir_c"):
d = tmp_path / name
(d / "docs").mkdir(parents=True)
(d / "file.py").write_text("code")
targets_file = tmp_path / "targets.json"
targets_file.write_text(json.dumps({"targets": ["dir_a", "dir_b", "dir_c"]}))
monkeypatch.setattr("doc_agent_state.TARGETS_FILE", targets_file)
# All three have reactive changes — only 2 can be picked
monkeypatch.setattr("doc_agent_state.git_diff_files",
lambda *a: ["dir_a/file.py", "dir_b/file.py", "dir_c/file.py"])
state = {
"last_commit": "abc",
"directories": {
name: {
"last_visited": "2024-01-01",
"files_covered": ["file.py"],
"runs_since_last_visit": 0,
"visited_this_run": False,
}
for name in ("dir_a", "dir_b", "dir_c")
},
}
state_file.write_text(json.dumps(state))
cmd_get_work(argparse.Namespace())
capsys.readouterr() # consume output
updated = json.loads(state_file.read_text())
# Two dirs were picked for slots, one should be in pending_reactive
assert len(updated["pending_reactive"]) == 1
assert updated["pending_reactive"][0] in ("dir_a", "dir_b", "dir_c")
def test_cmd_get_work_merges_pending_reactive(tmp_path, monkeypatch, capsys):
"""pending_reactive from previous run is merged into the reactive queue."""
state_file = tmp_path / "state.json"
monkeypatch.setattr("doc_agent_state.STATE_FILE", state_file)
monkeypatch.setattr("doc_agent_state.get_repo_root", lambda: tmp_path)
# One directory carried over from last run
d = tmp_path / "dir_a"
(d / "docs").mkdir(parents=True)
(d / "file.py").write_text("code")
targets_file = tmp_path / "targets.json"
targets_file.write_text(json.dumps({"targets": ["dir_a"]}))
monkeypatch.setattr("doc_agent_state.TARGETS_FILE", targets_file)
# No new reactive changes this run
monkeypatch.setattr("doc_agent_state.git_diff_files", lambda *a: [])
state = {
"last_commit": "abc",
"pending_reactive": ["dir_a"],
"directories": {
"dir_a": {
"last_visited": "2024-01-01",
"files_covered": ["file.py"],
"runs_since_last_visit": 0,
"visited_this_run": False,
},
},
}
state_file.write_text(json.dumps(state))
cmd_get_work(argparse.Namespace())
output = json.loads(capsys.readouterr().out.split("\n", 1)[-1])
# dir_a should be picked up from pending_reactive
assert output["slot1"] is not None
assert output["slot1"]["directory"] == "dir_a"
assert output["slot1"]["source"] == "reactive"
def test_cmd_finish_run_clears_visited_from_pending_reactive(tmp_path, monkeypatch):
"""finish-run removes visited directories from pending_reactive."""
state_file = tmp_path / "state.json"
monkeypatch.setattr("doc_agent_state.STATE_FILE", state_file)
monkeypatch.setattr("doc_agent_state.git_rev_parse_head", lambda: "newcommit")
state = {
"last_commit": "old",
"last_run": "2024-01-01",
"run_count": 1,
"pending_reactive": ["dir_a", "dir_b"],
"directories": {
"dir_a": {
"last_visited": "2024-01-01",
"files_covered": [],
"runs_since_last_visit": 0,
"visited_this_run": True,
},
"dir_b": {
"last_visited": None,
"files_covered": [],
"runs_since_last_visit": 0,
"visited_this_run": False,
},
},
}
state_file.write_text(json.dumps(state))
cmd_finish_run(argparse.Namespace())
updated = json.loads(state_file.read_text())
# dir_a was visited → removed from pending_reactive
# dir_b was not visited → stays
assert updated["pending_reactive"] == ["dir_b"]