"""Unit tests for the stateless / pure-function layer of consistency_check.py. Covers: - parse_frontmatter — YAML frontmatter splitting - parse_task_blocks — ```task``` block extraction - get_tasks_from_workplan — block vs. frontmatter fallback - ConsistencyReport — issue accumulation and severity filtering - render_text — text rendering smoke tests - report_to_dict — serialisation shape No network calls, no DB, no live API — these tests run fully offline. """ from __future__ import annotations import sys from pathlib import Path import pytest # Make scripts/ importable without installing sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) from consistency_check import ( ConsistencyReport, Issue, FILE_TO_DB_WORKSTREAM_STATUS, STATUS_ORDER, _BACKGROUND_CHECKS, _detect_behind_remote, _git_pull, _patch_task_status_in_file, _report_needs_action, get_tasks_from_workplan, normalise_workstream_status, parse_frontmatter, parse_task_blocks, render_text, report_to_dict, ) # --------------------------------------------------------------------------- # parse_frontmatter # --------------------------------------------------------------------------- class TestParseFrontmatter: def test_valid_frontmatter(self): text = "---\nid: WP-001\ntitle: Test\nstatus: active\n---\n\n# Body" meta, body = parse_frontmatter(text) assert meta["id"] == "WP-001" assert meta["title"] == "Test" assert meta["status"] == "active" assert "# Body" in body def test_no_frontmatter_returns_empty_meta(self): text = "# Just a heading\n\nSome body text." meta, body = parse_frontmatter(text) assert meta == {} assert body == text def test_empty_frontmatter(self): text = "---\n---\n\nBody only." meta, body = parse_frontmatter(text) assert isinstance(meta, dict) assert "Body only." in body def test_frontmatter_with_quoted_strings(self): text = '---\nid: "WP-007"\ntitle: "My Workplan"\n---\nBody' meta, _ = parse_frontmatter(text) assert meta["id"] == "WP-007" assert meta["title"] == "My Workplan" def test_incomplete_frontmatter_delimiter(self): """Only one --- means no valid frontmatter block.""" text = "---\nid: foo\nNo closing delimiter" meta, body = parse_frontmatter(text) assert meta == {} def test_body_preserved_fully(self): text = "---\nid: X\n---\nLine1\nLine2\nLine3" _, body = parse_frontmatter(text) assert "Line1" in body assert "Line2" in body assert "Line3" in body # --------------------------------------------------------------------------- # parse_task_blocks # --------------------------------------------------------------------------- class TestParseTaskBlocks: def test_single_task_block(self): body = "## T01\n\n```task\nid: WP-T01\nstatus: done\npriority: high\n```\n" tasks = parse_task_blocks(body) assert len(tasks) == 1 assert tasks[0]["id"] == "WP-T01" assert tasks[0]["status"] == "done" def test_multiple_task_blocks(self): body = ( "## T01\n```task\nid: T01\nstatus: done\n```\n\n" "## T02\n```task\nid: T02\nstatus: todo\n```\n" ) tasks = parse_task_blocks(body) assert len(tasks) == 2 assert tasks[0]["id"] == "T01" assert tasks[1]["id"] == "T02" def test_no_task_blocks(self): body = "# Workplan\n\nNo task blocks here.\n" tasks = parse_task_blocks(body) assert tasks == [] def test_task_block_with_all_fields(self): body = ( "```task\n" "id: CUST-WP-0008-T01\n" "status: done\n" "priority: critical\n" "assignee: custodian\n" 'state_hub_task_id: "abc-123"\n' "```\n" ) tasks = parse_task_blocks(body) assert len(tasks) == 1 t = tasks[0] assert t["id"] == "CUST-WP-0008-T01" assert t["status"] == "done" assert t["priority"] == "critical" assert t["state_hub_task_id"] == "abc-123" def test_ignores_non_task_fenced_blocks(self): body = ( "```python\nprint('hello')\n```\n\n" "```task\nid: T01\nstatus: todo\n```\n" "```bash\necho hi\n```\n" ) tasks = parse_task_blocks(body) assert len(tasks) == 1 assert tasks[0]["id"] == "T01" # --------------------------------------------------------------------------- # get_tasks_from_workplan # --------------------------------------------------------------------------- class TestGetTasksFromWorkplan: def test_prefers_task_blocks_over_frontmatter(self): meta = {"tasks": [{"id": "from-frontmatter", "status": "todo"}]} body = "```task\nid: from-block\nstatus: done\n```\n" tasks = get_tasks_from_workplan(meta, body) assert len(tasks) == 1 assert tasks[0]["id"] == "from-block" def test_falls_back_to_frontmatter_tasks(self): meta = {"tasks": [{"id": "FM-01", "status": "todo"}, {"id": "FM-02", "status": "done"}]} body = "# No task blocks here\n" tasks = get_tasks_from_workplan(meta, body) assert len(tasks) == 2 assert tasks[0]["id"] == "FM-01" assert tasks[1]["id"] == "FM-02" def test_returns_empty_when_no_tasks(self): meta = {} body = "# Just prose\n" tasks = get_tasks_from_workplan(meta, body) assert tasks == [] def test_ignores_non_list_frontmatter_tasks(self): meta = {"tasks": "not-a-list"} body = "# No blocks\n" tasks = get_tasks_from_workplan(meta, body) assert tasks == [] # --------------------------------------------------------------------------- # ConsistencyReport — issue accumulation and severity filtering # --------------------------------------------------------------------------- class TestConsistencyReport: def _report(self): return ConsistencyReport(repo_slug="test-repo", repo_path="/tmp/test-repo") def test_add_fail_issue(self): r = self._report() r.add(severity="FAIL", check_id="C-01", message="No workplans/ dir") assert len(r.issues) == 1 assert len(r.failures) == 1 assert len(r.warnings) == 0 assert len(r.infos) == 0 def test_add_warn_issue(self): r = self._report() r.add(severity="WARN", check_id="C-04", message="Status drift", fixable=True) assert len(r.warnings) == 1 assert r.warnings[0].fixable is True def test_add_info_issue(self): r = self._report() r.add(severity="INFO", check_id="C-08", message="Completed orphan") assert len(r.infos) == 1 def test_mixed_severities(self): r = self._report() r.add(severity="FAIL", check_id="C-01", message="F1") r.add(severity="FAIL", check_id="C-03", message="F2") r.add(severity="WARN", check_id="C-04", message="W1") r.add(severity="INFO", check_id="C-08", message="I1") assert len(r.failures) == 2 assert len(r.warnings) == 1 assert len(r.infos) == 1 assert len(r.issues) == 4 def test_add_returns_issue(self): r = self._report() issue = r.add(severity="FAIL", check_id="C-01", message="msg") assert isinstance(issue, Issue) assert issue.severity == "FAIL" def test_empty_report(self): r = self._report() assert r.failures == [] assert r.warnings == [] assert r.infos == [] assert r.issues == [] # --------------------------------------------------------------------------- # render_text # --------------------------------------------------------------------------- class TestRenderText: def _clean_report(self): return ConsistencyReport(repo_slug="my-repo", repo_path="/path/to/repo") def test_pass_result_shown(self): r = self._clean_report() text = render_text(r) assert "PASS" in text assert "my-repo" in text def test_fail_result_shown(self): r = self._clean_report() r.add(severity="FAIL", check_id="C-01", message="No workplans/ directory") text = render_text(r) assert "FAIL" in text assert "C-01" in text assert "No workplans/ directory" in text def test_warn_result_shown(self): r = self._clean_report() r.add(severity="WARN", check_id="C-04", message="Status drift", fixable=True) text = render_text(r) assert "WARN" in text assert "[fixable]" in text def test_fix_applied_shown(self): r = self._clean_report() r.fixes_applied.append("C-04: updated status active→done for WP-001") text = render_text(r) assert "FIXES APPLIED" in text assert "C-04" in text def test_summary_counts_correct(self): r = self._clean_report() r.add(severity="FAIL", check_id="C-01", message="f") r.add(severity="WARN", check_id="C-04", message="w") r.add(severity="INFO", check_id="C-08", message="i") text = render_text(r) assert "1 fail" in text assert "1 warn" in text assert "1 info" in text def test_info_hidden_when_show_info_false(self): r = self._clean_report() r.add(severity="INFO", check_id="C-08", message="Completed orphan") text = render_text(r, show_info=False) assert "C-08" not in text # --------------------------------------------------------------------------- # report_to_dict # --------------------------------------------------------------------------- class TestReportToDict: def test_clean_report_result_is_pass(self): r = ConsistencyReport(repo_slug="r", repo_path="/p") d = report_to_dict(r) assert d["result"] == "pass" assert d["summary"] == {"fail": 0, "warn": 0, "info": 0} assert d["issues"] == [] def test_fail_result(self): r = ConsistencyReport(repo_slug="r", repo_path="/p") r.add(severity="FAIL", check_id="C-01", message="missing") d = report_to_dict(r) assert d["result"] == "fail" assert d["summary"]["fail"] == 1 def test_warn_only_result(self): r = ConsistencyReport(repo_slug="r", repo_path="/p") r.add(severity="WARN", check_id="C-04", message="drift") d = report_to_dict(r) assert d["result"] == "warn" assert d["summary"]["warn"] == 1 assert d["summary"]["fail"] == 0 def test_issue_fields_serialised(self): r = ConsistencyReport(repo_slug="r", repo_path="/p") r.add( severity="FAIL", check_id="C-03", message="stale ref", file_path="workplans/WP.md", db_id="abc-123", file_value="abc-123", db_value="", ) d = report_to_dict(r) issue = d["issues"][0] assert issue["severity"] == "FAIL" assert issue["check_id"] == "C-03" assert issue["file_path"] == "workplans/WP.md" assert issue["db_id"] == "abc-123" def test_fixes_applied_in_dict(self): r = ConsistencyReport(repo_slug="r", repo_path="/p") r.fixes_applied.append("C-04: status fixed") d = report_to_dict(r) assert "C-04: status fixed" in d["fixes_applied"] def test_repo_slug_and_path_preserved(self): r = ConsistencyReport(repo_slug="the-custodian", repo_path="/home/worsch/the-custodian") d = report_to_dict(r) assert d["repo_slug"] == "the-custodian" assert d["repo_path"] == "/home/worsch/the-custodian" # --------------------------------------------------------------------------- # Status vocabulary normalisation # --------------------------------------------------------------------------- class TestNormaliseWorkstreamStatus: """FILE_TO_DB_WORKSTREAM_STATUS maps workplan file vocabulary to DB vocabulary. Workplan files use task-style "done"; the DB workstream API uses "completed". The C-04 check and fix code must normalise before comparing or PATCHing. """ def test_done_maps_to_completed(self): assert normalise_workstream_status("done") == "completed" def test_completed_is_identity(self): assert normalise_workstream_status("completed") == "completed" def test_active_is_identity(self): assert normalise_workstream_status("active") == "active" def test_blocked_is_identity(self): assert normalise_workstream_status("blocked") == "blocked" def test_archived_is_identity(self): assert normalise_workstream_status("archived") == "archived" def test_unknown_value_returned_as_is(self): # Don't crash on unexpected values — return them unchanged assert normalise_workstream_status("foobar") == "foobar" def test_map_constant_covers_done(self): assert "done" in FILE_TO_DB_WORKSTREAM_STATUS assert FILE_TO_DB_WORKSTREAM_STATUS["done"] == "completed" def test_c04_no_spurious_drift_when_done_vs_completed(self): """done (file) vs completed (DB) must NOT be reported as C-04 drift.""" assert normalise_workstream_status("done") == normalise_workstream_status("completed") def test_c04_real_drift_still_detected(self): """done (file) vs active (DB) IS real drift and must be detected.""" assert normalise_workstream_status("done") != normalise_workstream_status("active") # --------------------------------------------------------------------------- # STATUS_ORDER / no-regress rule (T01 / C-15) # --------------------------------------------------------------------------- class TestStatusOrder: """STATUS_ORDER drives the no-regress rule: DB-ahead wins, file-ahead syncs.""" def test_todo_is_lowest(self): assert STATUS_ORDER["todo"] == 0 def test_done_and_cancelled_are_highest(self): assert STATUS_ORDER["done"] == STATUS_ORDER["cancelled"] == 2 def test_in_progress_and_blocked_are_mid(self): assert STATUS_ORDER["in_progress"] == STATUS_ORDER["blocked"] == 1 def test_db_ahead_detected(self): """done (DB) vs todo (file) — DB is ahead.""" assert STATUS_ORDER["done"] > STATUS_ORDER["todo"] def test_file_ahead_detected(self): """done (file) vs todo (DB) — file is ahead, should sync.""" assert STATUS_ORDER["todo"] < STATUS_ORDER["done"] def test_same_rank_treated_as_db_ahead(self): """in_progress (DB) vs blocked (file) — same rank, no regression.""" assert STATUS_ORDER["in_progress"] >= STATUS_ORDER["blocked"] def test_todo_to_done_is_regression(self): """Applying file=todo to DB=done would be a regression.""" db_rank = STATUS_ORDER["done"] file_rank = STATUS_ORDER["todo"] assert db_rank >= file_rank # → should emit C-15, not C-10 # --------------------------------------------------------------------------- # _detect_behind_remote (T02 / C-16) # --------------------------------------------------------------------------- class TestDetectBehindRemote: """_detect_behind_remote must return False on any error (best-effort).""" def test_returns_false_for_nonexistent_path(self): assert _detect_behind_remote("/nonexistent/path/xyz") is False def test_returns_false_for_non_git_dir(self, tmp_path): assert _detect_behind_remote(str(tmp_path)) is False def test_returns_false_for_repo_without_remote(self, tmp_path): """A local-only repo with no remote tracking branch → not behind.""" import subprocess subprocess.run(["git", "init", str(tmp_path)], capture_output=True) subprocess.run( ["git", "-C", str(tmp_path), "commit", "--allow-empty", "-m", "init"], capture_output=True, ) # No remote configured → rev-parse @{u} fails → best-effort returns False assert _detect_behind_remote(str(tmp_path)) is False def test_returns_false_when_up_to_date(self, tmp_path): """Clone from a local bare repo, no new commits → not behind.""" import subprocess bare = tmp_path / "bare.git" clone = tmp_path / "clone" bare.mkdir() subprocess.run(["git", "init", "--bare", str(bare)], capture_output=True) subprocess.run(["git", "clone", str(bare), str(clone)], capture_output=True) subprocess.run( ["git", "-C", str(clone), "commit", "--allow-empty", "-m", "init"], capture_output=True, ) subprocess.run(["git", "-C", str(clone), "push", "origin", "HEAD"], capture_output=True) assert _detect_behind_remote(str(clone)) is False def test_returns_false_when_local_ahead(self, tmp_path): """Local has unpushed commits — NOT considered behind.""" import subprocess bare = tmp_path / "bare.git" clone = tmp_path / "clone" bare.mkdir() subprocess.run(["git", "init", "--bare", str(bare)], capture_output=True) subprocess.run(["git", "clone", str(bare), str(clone)], capture_output=True) subprocess.run( ["git", "-C", str(clone), "commit", "--allow-empty", "-m", "init"], capture_output=True, ) subprocess.run(["git", "-C", str(clone), "push", "origin", "HEAD"], capture_output=True) # Add a local-only commit (not pushed) subprocess.run( ["git", "-C", str(clone), "commit", "--allow-empty", "-m", "local only"], capture_output=True, ) assert _detect_behind_remote(str(clone)) is False def test_returns_true_when_behind(self, tmp_path): """Remote has a commit the clone doesn't → clone is behind.""" import subprocess bare = tmp_path / "bare.git" clone = tmp_path / "clone" bare.mkdir() subprocess.run(["git", "init", "--bare", str(bare)], capture_output=True) subprocess.run(["git", "clone", str(bare), str(clone)], capture_output=True) # Initial commit pushed so there's an upstream ref subprocess.run( ["git", "-C", str(clone), "commit", "--allow-empty", "-m", "init"], capture_output=True, ) subprocess.run(["git", "-C", str(clone), "push", "origin", "HEAD"], capture_output=True) # Add a commit directly in the bare repo (simulates remote-only progress) work = tmp_path / "work" subprocess.run(["git", "clone", str(bare), str(work)], capture_output=True) subprocess.run( ["git", "-C", str(work), "commit", "--allow-empty", "-m", "remote commit"], capture_output=True, ) subprocess.run(["git", "-C", str(work), "push"], capture_output=True) # After fetch the clone should appear behind assert _detect_behind_remote(str(clone)) is True # --------------------------------------------------------------------------- # _patch_task_status_in_file (T03 writeback) # --------------------------------------------------------------------------- class TestPatchTaskStatusInFile: """_patch_task_status_in_file must only change the status field in the matching task block and leave everything else untouched.""" def _make_workplan(self, tmp_path, content: str) -> Path: f = tmp_path / "workplan.md" f.write_text(content, encoding="utf-8") return f def test_patches_matching_task_block(self, tmp_path): content = ( "---\nid: WP-001\n---\n" "## My Task\n\n" "```task\n" "id: T01\n" "status: todo\n" "priority: high\n" "```\n" ) f = self._make_workplan(tmp_path, content) result = _patch_task_status_in_file(f, "T01", "done") assert result is True patched = f.read_text() assert "status: done" in patched assert "status: todo" not in patched assert "priority: high" in patched # other fields untouched def test_does_not_patch_non_matching_block(self, tmp_path): content = ( "---\nid: WP-001\n---\n" "```task\n" "id: T02\n" "status: todo\n" "```\n" ) f = self._make_workplan(tmp_path, content) result = _patch_task_status_in_file(f, "T01", "done") assert result is False assert "status: todo" in f.read_text() def test_patches_correct_block_among_multiple(self, tmp_path): content = ( "---\nid: WP-001\n---\n" "```task\nid: T01\nstatus: todo\n```\n" "```task\nid: T02\nstatus: in_progress\n```\n" ) f = self._make_workplan(tmp_path, content) _patch_task_status_in_file(f, "T02", "done") patched = f.read_text() assert "id: T01\nstatus: todo" in patched assert "id: T02\nstatus: done" in patched def test_does_not_touch_non_status_fields(self, tmp_path): content = ( "---\nid: WP\n---\n" "```task\nid: T01\nstatus: todo\npriority: high\nstate_hub_task_id: \"abc\"\n```\n" ) f = self._make_workplan(tmp_path, content) _patch_task_status_in_file(f, "T01", "done") patched = f.read_text() assert "priority: high" in patched assert 'state_hub_task_id: "abc"' in patched def test_idempotent_when_already_correct(self, tmp_path): content = "---\nid: WP\n---\n```task\nid: T01\nstatus: done\n```\n" f = self._make_workplan(tmp_path, content) result = _patch_task_status_in_file(f, "T01", "done") # status matches, re.sub produces same text → no write → False assert result is False # --------------------------------------------------------------------------- # _git_pull (T02 remote fix helper) # --------------------------------------------------------------------------- class TestGitPull: def test_returns_false_for_nonexistent_path(self): ok, msg = _git_pull("/nonexistent/path") assert ok is False assert msg def test_returns_true_for_up_to_date_repo(self, tmp_path): import subprocess bare = tmp_path / "bare.git" clone = tmp_path / "clone" bare.mkdir() subprocess.run(["git", "init", "--bare", str(bare)], capture_output=True) subprocess.run(["git", "clone", str(bare), str(clone)], capture_output=True) subprocess.run( ["git", "-C", str(clone), "commit", "--allow-empty", "-m", "init"], capture_output=True, ) subprocess.run(["git", "-C", str(clone), "push", "origin", "HEAD"], capture_output=True) ok, msg = _git_pull(str(clone)) assert ok is True def test_pulls_new_remote_commit(self, tmp_path): import subprocess bare = tmp_path / "bare.git" clone = tmp_path / "clone" work = tmp_path / "work" bare.mkdir() subprocess.run(["git", "init", "--bare", str(bare)], capture_output=True) subprocess.run(["git", "clone", str(bare), str(clone)], capture_output=True) subprocess.run( ["git", "-C", str(clone), "commit", "--allow-empty", "-m", "init"], capture_output=True, ) subprocess.run(["git", "-C", str(clone), "push", "origin", "HEAD"], capture_output=True) # Push a new commit from a second clone subprocess.run(["git", "clone", str(bare), str(work)], capture_output=True) subprocess.run( ["git", "-C", str(work), "commit", "--allow-empty", "-m", "remote work"], capture_output=True, ) subprocess.run(["git", "-C", str(work), "push"], capture_output=True) # Pull should succeed and bring in the new commit ok, msg = _git_pull(str(clone)) assert ok is True head = subprocess.run( ["git", "-C", str(clone), "log", "--oneline", "-1"], capture_output=True, text=True, ).stdout assert "remote work" in head # --------------------------------------------------------------------------- # _report_needs_action (smart skip logic) # --------------------------------------------------------------------------- class TestReportNeedsAction: def _make_report(self, issues: list[tuple[str, str, bool]]) -> ConsistencyReport: """Build a report from (severity, check_id, fixable) tuples.""" r = ConsistencyReport(repo_slug="test", repo_path="/tmp/test") for severity, check_id, fixable in issues: r.add(severity=severity, check_id=check_id, message="test", fixable=fixable) return r def test_clean_report_not_behind_is_skipped(self): r = self._make_report([]) assert _report_needs_action(r, behind_remote=False) is False def test_behind_remote_always_needs_action(self): r = self._make_report([]) assert _report_needs_action(r, behind_remote=True) is True def test_fail_always_needs_action(self): r = self._make_report([("FAIL", "C-07", False)]) assert _report_needs_action(r, behind_remote=False) is True def test_c08_only_is_background_noise(self): """C-08 (legacy archived workstream) alone should not trigger a fix cycle.""" r = self._make_report([("INFO", "C-08", False)]) assert _report_needs_action(r, behind_remote=False) is False def test_c08_plus_actionable_warn_needs_action(self): r = self._make_report([("INFO", "C-08", False), ("WARN", "C-10", True)]) assert _report_needs_action(r, behind_remote=False) is True def test_fixable_warn_needs_action(self): r = self._make_report([("WARN", "C-15", True)]) assert _report_needs_action(r, behind_remote=False) is True def test_non_fixable_warn_needs_action(self): """Non-fixable WARNs (e.g. C-07 ghost) still warrant attention.""" r = self._make_report([("WARN", "C-14", False)]) assert _report_needs_action(r, behind_remote=False) is True def test_background_checks_constant_contains_c08(self): assert "C-08" in _BACKGROUND_CHECKS