diff --git a/tests/test_work_record_check.py b/tests/test_work_record_check.py new file mode 100644 index 0000000..9e43c01 --- /dev/null +++ b/tests/test_work_record_check.py @@ -0,0 +1,182 @@ +"""Unit tests for C-31 (work-record sidetrack detector, CUST-WP-0060-T05). + +Covers: + - _load_work_record_patterns — registry discovery + parsing, missing/broken + registry handling + - _check_unregistered_work_records — rogue ids flagged, registered kinds + (incl. grandfathered legacy patterns) pass, template placeholders and + skip-dirs ignored, malformed yaml doesn't crash the scan + +No network calls, no DB, no live API — these tests run fully offline and use +a synthetic registry fixture rather than the live canon checkout, so they +are stable independent of the-custodian's on-disk state. +""" +from __future__ import annotations + +import sys +import textwrap +from pathlib import Path + +import pytest +import yaml + +sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) + +from consistency_check import ( # noqa: E402 + ConsistencyReport, + _check_unregistered_work_records, + _load_work_record_patterns, +) + +REGISTRY_YAML = textwrap.dedent( + """ + version: "0.1" + status: active + kinds: + - kind: workplan + id_patterns: ['^[A-Z]+-WP-[0-9]{4}$'] + - kind: task + id_patterns: ['^[A-Z]+-WP-[0-9]{4}-T[0-9]{2,3}$'] + legacy_patterns: + - pattern: '^[A-Z]+-WP-[0-9]{4}-T[0-9]$' + source: single-digit legacy + - kind: intake + id_patterns: ['^[A-Z]+-IN-[0-9]{4}$'] + legacy_patterns: + - pattern: '^AWQ-[0-9]{3}$' + source: binky AutopilotWorkQueue + - kind: decision + id_patterns: ['^[A-Z]+-DEC-[0-9]{4}-[0-9]{3}$'] + legacy_patterns: + - pattern: '^DEC-[0-9]{4}-[0-9]{3}$' + source: binky DecisionQueue + - kind: engagement + id_patterns: ['^[A-Z]+-ENG-[0-9]{4}-[0-9]{3}$'] + legacy_patterns: + - pattern: '^OH-[0-9]{4}-[0-9]{3}$' + source: binky OfficeHourQueue + """ +) + + +@pytest.fixture +def registry_file(tmp_path, monkeypatch): + reg = tmp_path / "work-record-types.yaml" + reg.write_text(REGISTRY_YAML, encoding="utf-8") + monkeypatch.setenv("WORK_RECORD_REGISTRY", str(reg)) + return reg + + +class TestLoadWorkRecordPatterns: + def test_loads_patterns_from_env_override(self, registry_file): + patterns = _load_work_record_patterns() + assert patterns is not None + assert any(p.match("AWQ-010") for p in patterns) + assert any(p.match("CUST-WP-0060") for p in patterns) + + def test_returns_none_when_no_registry_available(self, tmp_path, monkeypatch): + monkeypatch.setenv("WORK_RECORD_REGISTRY", str(tmp_path / "missing.yaml")) + monkeypatch.setattr(Path, "home", lambda: tmp_path / "nohome") + assert _load_work_record_patterns() is None + + def test_returns_none_on_unparseable_registry(self, tmp_path, monkeypatch): + bad = tmp_path / "bad.yaml" + bad.write_text("kinds: [unterminated", encoding="utf-8") + monkeypatch.setenv("WORK_RECORD_REGISTRY", str(bad)) + assert _load_work_record_patterns() is None + + +class TestCheckUnregisteredWorkRecords: + def _report(self, repo_dir: Path) -> ConsistencyReport: + report = ConsistencyReport(repo_slug="test", repo_path=str(repo_dir)) + _check_unregistered_work_records(repo_dir, report) + return report + + def test_flags_id_matching_no_registered_kind(self, tmp_path, registry_file): + (tmp_path / "notes.md").write_text( + "```yaml\nid: FOO-QX-001\ntitle: rogue species\n```\n", + encoding="utf-8", + ) + report = self._report(tmp_path) + assert [i.check_id for i in report.issues] == ["C-31"] + assert "FOO-QX-001" in report.issues[0].message + assert report.issues[0].severity == "WARN" + assert report.issues[0].fixable is False + + def test_passes_registered_kind_ids(self, tmp_path, registry_file): + (tmp_path / "notes.md").write_text( + "```yaml\nid: CUST-WP-0060\n```\n" + "```yaml\nid: CUST-WP-0060-T01\n```\n" + "```yaml\nid: BINKY-IN-0001\n```\n" + "```yaml\nid: BINKY-DEC-2026-001\n```\n" + "```yaml\nid: BINKY-ENG-2026-001\n```\n", + encoding="utf-8", + ) + report = self._report(tmp_path) + assert report.issues == [] + + @pytest.mark.parametrize( + "legacy_id", + ["AWQ-010", "DEC-2026-004", "OH-2026-003", "CUST-WP-0060-T1"], + ) + def test_passes_grandfathered_legacy_ids(self, tmp_path, registry_file, legacy_id): + (tmp_path / "queue.md").write_text( + f"```yaml\nid: {legacy_id}\ntitle: legacy item\n```\n", + encoding="utf-8", + ) + report = self._report(tmp_path) + assert report.issues == [], f"{legacy_id} should be grandfathered, got {report.issues}" + + @pytest.mark.parametrize("placeholder", ["BINKY-WP-NNNN", "CUST-WP-0060-TNN"]) + def test_ignores_template_placeholders(self, tmp_path, registry_file, placeholder): + (tmp_path / "AGENTS.md").write_text( + f"```yaml\nid: {placeholder}\n```\n", encoding="utf-8" + ) + report = self._report(tmp_path) + assert report.issues == [] + + def test_ignores_non_id_like_strings(self, tmp_path, registry_file): + (tmp_path / "prose.md").write_text( + "```yaml\nid: not-an-id-scheme\n```\n" + "```yaml\nid: 12345\n```\n", + encoding="utf-8", + ) + report = self._report(tmp_path) + assert report.issues == [] + + def test_deduplicates_repeated_ids_in_same_repo(self, tmp_path, registry_file): + (tmp_path / "a.md").write_text("```yaml\nid: FOO-QX-001\n```\n", encoding="utf-8") + (tmp_path / "b.md").write_text("```yaml\nid: FOO-QX-001\n```\n", encoding="utf-8") + report = self._report(tmp_path) + assert len(report.issues) == 1 + + def test_skips_git_and_history_directories(self, tmp_path, registry_file): + skipped = tmp_path / ".git" / "nested" + skipped.mkdir(parents=True) + (skipped / "notes.md").write_text( + "```yaml\nid: FOO-QX-001\n```\n", encoding="utf-8" + ) + report = self._report(tmp_path) + assert report.issues == [] + + def test_malformed_fenced_block_does_not_crash_scan(self, tmp_path, registry_file): + (tmp_path / "broken.md").write_text( + "```yaml\nid: [unterminated\n```\n" + "```yaml\nid: FOO-QX-001\n```\n", + encoding="utf-8", + ) + report = self._report(tmp_path) + # the malformed block is silently skipped by the id-line regex + # (it never matches `id: ` on its own line); the well-formed + # rogue id in the same file is still caught. + assert [i.check_id for i in report.issues] == ["C-31"] + assert "FOO-QX-001" in report.issues[0].message + + def test_no_op_when_registry_unavailable(self, tmp_path, monkeypatch): + monkeypatch.setenv("WORK_RECORD_REGISTRY", str(tmp_path / "missing.yaml")) + monkeypatch.setattr(Path, "home", lambda: tmp_path / "nohome") + (tmp_path / "notes.md").write_text( + "```yaml\nid: FOO-QX-001\n```\n", encoding="utf-8" + ) + report = self._report(tmp_path) + assert report.issues == []