""" Tests for scripts/report_builder.py — the model-free finalize pass. Covers Phase 3 (add-check) report_builder: - expected_sha256 = sha256 of the WHOLE fixture file bytes (hand-computed) - (is_destructive, is_reversible) + derived safety_tier per kind, covering delete-range→confirm, move-to-archive→auto, insert-frontmatter→auto, dedupe→auto, replace-text→auto, generative→confirm - raw_tokens populated by the estimator; weighting fields null - per-entry exact_edit.generated_at = the injected hash instant (distinct from the envelope generated_at — proven with a monotonic clock) - tool_version read from .claude-plugin/plugin.json - cleared (zero-signal / no-entry) shortlisted files yield no entries - malformed proposals are rejected BEFORE any computation - CRITICAL round-trip: assembled report passes validate_report.py sys.path is patched here (no shared conftest) so the test file is self-contained, matching the repo's test style. """ from __future__ import annotations import hashlib import json import sys from datetime import datetime, timedelta, timezone from pathlib import Path import pytest # --------------------------------------------------------------------------- # Path bootstrap — inject scripts/ so we can import report_builder directly. # --------------------------------------------------------------------------- _SCRIPTS_DIR = Path(__file__).parent.parent / "scripts" if str(_SCRIPTS_DIR) not in sys.path: sys.path.insert(0, str(_SCRIPTS_DIR)) from report_builder import ( # noqa: E402 ReportBuilder, MalformedProposalError, ) from validate_report import ReportValidator # noqa: E402 from token_estimator import default_estimator # noqa: E402 # --------------------------------------------------------------------------- # Test doubles # --------------------------------------------------------------------------- class MonotonicClock: """Returns a strictly increasing UTC datetime on each now() call. This is what makes the generated_at test meaningful: a fixed clock would make per-entry stamps vacuously equal to the envelope stamp. """ def __init__(self, start: datetime, step_seconds: int = 1) -> None: self._next = start self._step = timedelta(seconds=step_seconds) self.calls: list[datetime] = [] def now(self) -> datetime: value = self._next self._next = self._next + self._step self.calls.append(value) return value _T0 = datetime(2026, 6, 24, 12, 0, 0, tzinfo=timezone.utc) # --------------------------------------------------------------------------- # Fixtures — a small doc tree + a scan artifact # --------------------------------------------------------------------------- @pytest.fixture def doc_tree(tmp_path: Path) -> Path: """Create a project root with several markdown files.""" (tmp_path / "docs").mkdir() (tmp_path / "CLAUDE.md").write_text( "\n".join(f"line {i}" for i in range(1, 21)) + "\n", encoding="utf-8", ) (tmp_path / "docs" / "old-plan.md").write_text( "# Old plan\n\nbody\n", encoding="utf-8" ) (tmp_path / "docs" / "setup.md").write_text( "# Setup\n\nall done\n", encoding="utf-8" ) (tmp_path / "docs" / "dupe.md").write_text( "a\nb\nc\nd\ne\n", encoding="utf-8" ) (tmp_path / "docs" / "links.md").write_text( "see [x](missing.md)\nmore text\n", encoding="utf-8" ) (tmp_path / "docs" / "big.md").write_text( "\n".join(f"para {i}" for i in range(1, 51)) + "\n", encoding="utf-8" ) (tmp_path / "docs" / "clean.md").write_text( "# Clean\n\nnothing to fix\n", encoding="utf-8" ) return tmp_path @pytest.fixture def scan_artifact(doc_tree: Path) -> dict: return { "project_root": str(doc_tree), "scope_globs": ["**/*.md"], "excluded_dirs": ["build", ".cc-os", ".dochygiene"], "files_scanned": 7, "shortlist": [ "CLAUDE.md", "docs/old-plan.md", "docs/setup.md", "docs/dupe.md", "docs/links.md", "docs/big.md", "docs/clean.md", ], "signals": {}, } def _proposals() -> list: """One proposal per exact_edit kind + one generative + a gloss.""" return [ { # delete-range → confirm "path": "CLAUDE.md", "category": {"class": "stale", "subtype": "contradicted"}, "signals": [{"name": "broken_reference", "detail": "links a dead path"}], "op": "Delete contradicted block (lines 5-9).", "op_type": "deterministic", "confidence": 0.9, "gloss": "block contradicts the live config", "exact_edit": { "kind": "delete-range", "anchor": {"start_line": 5, "end_line": 9}, }, }, { # move-to-archive → auto "path": "docs/old-plan.md", "category": {"class": "stale", "subtype": "superseded"}, "signals": [{"name": "stale_name_location", "detail": "named 'old'"}], "op": "Move to archive/docs/old-plan.md.", "op_type": "deterministic", "confidence": 0.8, "exact_edit": { "kind": "move-to-archive", "anchor": {"start_line": 1, "end_line": 3}, "dest_path": "archive/docs/old-plan.md", }, }, { # insert-frontmatter → auto (no anchor, no sha256/generated_at) "path": "docs/setup.md", "category": {"class": "stale", "subtype": "completed-in-place"}, "signals": [{"name": "archive_to_live_ratio", "detail": "all done"}], "op": "Freeze with hygiene: frozen.", "op_type": "deterministic", "confidence": 0.85, "exact_edit": { "kind": "insert-frontmatter", "key": "hygiene", "value": "frozen", }, }, { # dedupe → auto "path": "docs/dupe.md", "category": {"class": "stale", "subtype": "duplicated"}, "signals": [{"name": "broken_reference", "detail": "dup span"}], "op": "Remove duplicate span (lines 2-4); canonical elsewhere.", "op_type": "deterministic", "confidence": 0.7, "exact_edit": { "kind": "dedupe", "anchor": {"start_line": 2, "end_line": 4}, "canonical_ref": "docs/canonical.md#section", }, }, { # replace-text → auto "path": "docs/links.md", "category": {"class": "stale", "subtype": "contradicted"}, "signals": [{"name": "broken_reference", "detail": "dead link"}], "op": "Fix the dead link.", "op_type": "deterministic", "confidence": 0.9, "exact_edit": { "kind": "replace-text", "anchor": {"start_line": 1, "end_line": 1}, "match": "missing.md", "replacement": "found.md", }, }, { # generative → confirm (no exact_edit; reducible_range) "path": "docs/big.md", "category": {"class": "bloat", "subtype": "distill"}, "signals": [{"name": "archive_to_live_ratio", "detail": "long narrative"}], "op": "Condense resolved-problem sections.", "op_type": "generative", "confidence": 0.6, "reducible_range": {"start_line": 1, "end_line": 50}, }, ] def _build(doc_tree: Path, scan: dict, proposals: list, clock=None): builder = ReportBuilder( project_root=doc_tree, clock=clock or MonotonicClock(_T0), estimator=default_estimator(), # heuristic fallback; deterministic ) return builder.build(scan, proposals) # =========================================================================== # expected_sha256 = whole-file hash # =========================================================================== class TestExpectedSha256: def test_matches_handcomputed_whole_file_hash(self, doc_tree, scan_artifact): result = _build(doc_tree, scan_artifact, _proposals()) entries = {e["path"]: e for e in result["machine_report"]["entries"]} expected = hashlib.sha256( (doc_tree / "CLAUDE.md").read_bytes() ).hexdigest() assert entries["CLAUDE.md"]["exact_edit"]["expected_sha256"] == expected def test_anchorless_insert_frontmatter_has_no_sha256(self, doc_tree, scan_artifact): result = _build(doc_tree, scan_artifact, _proposals()) entries = {e["path"]: e for e in result["machine_report"]["entries"]} ee = entries["docs/setup.md"]["exact_edit"] assert "expected_sha256" not in ee assert "generated_at" not in ee def test_generative_has_no_exact_edit(self, doc_tree, scan_artifact): result = _build(doc_tree, scan_artifact, _proposals()) entries = {e["path"]: e for e in result["machine_report"]["entries"]} assert "exact_edit" not in entries["docs/big.md"] # =========================================================================== # (is_destructive, is_reversible) + derived safety_tier per kind # =========================================================================== class TestKindCharacterization: @pytest.mark.parametrize( "path,is_destructive,is_reversible,tier", [ ("CLAUDE.md", True, False, "confirm"), # delete-range ("docs/old-plan.md", False, True, "auto"), # move-to-archive ("docs/setup.md", False, True, "auto"), # insert-frontmatter ("docs/dupe.md", False, True, "auto"), # dedupe ("docs/links.md", False, True, "auto"), # replace-text ("docs/big.md", False, True, "confirm"), # generative ], ) def test_booleans_and_tier(self, doc_tree, scan_artifact, path, is_destructive, is_reversible, tier): result = _build(doc_tree, scan_artifact, _proposals()) entries = {e["path"]: e for e in result["machine_report"]["entries"]} e = entries[path] assert e["is_destructive"] is is_destructive assert e["is_reversible"] is is_reversible assert e["safety_tier"] == tier def test_proposal_cannot_override_guardrail_fields(self, doc_tree, scan_artifact): """A lying proposal (is_destructive/safety_tier) is ignored; kind wins.""" proposals = _proposals() # Inject bogus fields onto the delete-range proposal. proposals[0]["is_destructive"] = False proposals[0]["is_reversible"] = True proposals[0]["safety_tier"] = "auto" result = _build(doc_tree, scan_artifact, proposals) e = {x["path"]: x for x in result["machine_report"]["entries"]}["CLAUDE.md"] assert e["is_destructive"] is True assert e["safety_tier"] == "confirm" # =========================================================================== # token_estimate # =========================================================================== class TestTokenEstimate: def test_raw_tokens_populated_weighting_null(self, doc_tree, scan_artifact): result = _build(doc_tree, scan_artifact, _proposals()) for e in result["machine_report"]["entries"]: te = e["token_estimate"] assert isinstance(te["raw_tokens"], int) assert te["raw_tokens"] >= 0 assert te["injection_frequency"] is None assert te["weighted_tokens"] is None def test_raw_tokens_from_estimator_on_span(self, doc_tree, scan_artifact): """delete-range counts only the anchored span (lines 5-9).""" result = _build(doc_tree, scan_artifact, _proposals()) e = {x["path"]: x for x in result["machine_report"]["entries"]}["CLAUDE.md"] lines = (doc_tree / "CLAUDE.md").read_text().splitlines() span = "\n".join(lines[4:9]) # lines 5..9 inclusive assert e["token_estimate"]["raw_tokens"] == default_estimator().estimate(span) # =========================================================================== # per-entry generated_at = injected hash instant (distinct from envelope) # =========================================================================== class TestGeneratedAt: def test_per_entry_generated_at_is_hash_instant(self, doc_tree, scan_artifact): clock = MonotonicClock(_T0, step_seconds=1) result = _build(doc_tree, scan_artifact, _proposals(), clock=clock) machine = result["machine_report"] # Each anchor-bearing entry's generated_at is one of the clock's instants # and is NOT equal to the envelope generated_at (proves distinctness). envelope = machine["generated_at"] stamps = [] for e in machine["entries"]: ee = e.get("exact_edit") if ee and "generated_at" in ee: stamps.append(ee["generated_at"]) assert ee["generated_at"] != envelope # All hash instants are distinct from each other (monotonic clock). assert len(stamps) == len(set(stamps)) # The envelope stamp is the last clock call (after all hashes). assert envelope == max(clock.calls).isoformat() # =========================================================================== # tool_version from plugin.json # =========================================================================== class TestEnvelope: def test_tool_version_from_plugin_json(self, doc_tree, scan_artifact): plugin_json = _SCRIPTS_DIR.parent / ".claude-plugin" / "plugin.json" expected = json.loads(plugin_json.read_text())["version"] result = _build(doc_tree, scan_artifact, _proposals()) assert result["machine_report"]["tool_version"] == expected def test_schema_version_and_scan_block(self, doc_tree, scan_artifact): result = _build(doc_tree, scan_artifact, _proposals()) m = result["machine_report"] assert m["schema_version"] == "1.0" assert m["scan"]["files_scanned"] == 7 assert m["scan"]["project_root"] == str(doc_tree) assert m["shortlist"] == scan_artifact["shortlist"] # verbatim # =========================================================================== # cleared files yield no entries # =========================================================================== class TestClearedFiles: def test_shortlisted_but_unproposed_file_has_no_entry(self, doc_tree, scan_artifact): result = _build(doc_tree, scan_artifact, _proposals()) paths = {e["path"] for e in result["machine_report"]["entries"]} assert "docs/clean.md" not in paths # but it stays in shortlist assert "docs/clean.md" in result["machine_report"]["shortlist"] def test_human_report_counts_cleared(self, doc_tree, scan_artifact): result = _build(doc_tree, scan_artifact, _proposals()) md = result["human_report"] # 7 shortlisted, 6 entries → 1 cleared assert "candidates: 6" in md assert "cleared: 1" in md assert "docs/clean.md" in md # =========================================================================== # malformed proposals are rejected before compute # =========================================================================== class TestMalformedRejection: def test_missing_path(self, doc_tree, scan_artifact): bad = [{"category": {"class": "stale", "subtype": "orphaned"}, "op": "x", "op_type": "deterministic"}] with pytest.raises(MalformedProposalError): _build(doc_tree, scan_artifact, bad) def test_unknown_kind(self, doc_tree, scan_artifact): bad = [{ "path": "CLAUDE.md", "category": {"class": "stale", "subtype": "orphaned"}, "op": "x", "op_type": "deterministic", "exact_edit": {"kind": "nuke-it", "anchor": {"start_line": 1, "end_line": 2}}, }] with pytest.raises(MalformedProposalError): _build(doc_tree, scan_artifact, bad) def test_deterministic_without_exact_edit(self, doc_tree, scan_artifact): bad = [{ "path": "CLAUDE.md", "category": {"class": "stale", "subtype": "orphaned"}, "op": "x", "op_type": "deterministic", }] with pytest.raises(MalformedProposalError): _build(doc_tree, scan_artifact, bad) def test_generative_with_exact_edit(self, doc_tree, scan_artifact): bad = [{ "path": "docs/big.md", "category": {"class": "bloat", "subtype": "distill"}, "op": "x", "op_type": "generative", "reducible_range": {"start_line": 1, "end_line": 2}, "exact_edit": {"kind": "delete-range", "anchor": {"start_line": 1, "end_line": 2}}, }] with pytest.raises(MalformedProposalError): _build(doc_tree, scan_artifact, bad) def test_missing_kind_specific_field(self, doc_tree, scan_artifact): bad = [{ "path": "docs/old-plan.md", "category": {"class": "stale", "subtype": "superseded"}, "op": "x", "op_type": "deterministic", "exact_edit": {"kind": "move-to-archive", "anchor": {"start_line": 1, "end_line": 2}}, # dest_path missing }] with pytest.raises(MalformedProposalError): _build(doc_tree, scan_artifact, bad) def test_bad_subtype(self, doc_tree, scan_artifact): bad = [{ "path": "CLAUDE.md", "category": {"class": "stale", "subtype": "not-a-subtype"}, "op": "x", "op_type": "generative", "reducible_range": {"start_line": 1, "end_line": 2}, }] with pytest.raises(MalformedProposalError): _build(doc_tree, scan_artifact, bad) def test_rejected_before_any_file_read(self, tmp_path, scan_artifact): """A nonexistent file path must not be read — malformed structure fails first.""" # path missing op_type entirely → rejected before any IO on the file. bad = [{"path": "CLAUDE.md", "category": {"class": "stale", "subtype": "orphaned"}, "op": "x"}] builder = ReportBuilder(project_root=tmp_path, clock=MonotonicClock(_T0)) with pytest.raises(MalformedProposalError): builder.build(scan_artifact, bad) # =========================================================================== # CRITICAL: round-trip through validate_report.py # =========================================================================== class FakeGitStateProvider: """Injected git-state provider — returns canned {tracked, clean} per rel_path, matching the report_builder.py convention of an injectable real-vs-fake seam (same shape as scanner.py's git_log_fn).""" def __init__(self, states: dict) -> None: self._states = states def state(self, root: Path, rel_path: str) -> dict: return self._states.get(rel_path, {"tracked": False, "clean": False}) def _lifecycle_delete_proposal(**lifecycle_overrides) -> dict: lifecycle = {"rule_ref": "autoresearch/*/**", "lifetime": "temporary"} lifecycle.update(lifecycle_overrides) return { "path": "docs/clean.md", "category": {"class": "stale", "subtype": "orphaned"}, "signals": [{"name": "temporary_expired", "detail": "past retain-recent window"}], "op": "Delete expired temporary entry.", "op_type": "deterministic", "confidence": 0.9, "exact_edit": { "kind": "delete", "anchor": {"start_line": 1, "end_line": 3}, "lifecycle": lifecycle, }, } class TestLifecycleDeleteKind: """Group 3 (task 3.1/3.3): delete/extract-then-delete assembly — git_state population (guardrail, never trusted from the proposal) and the lifecycle-aware safety_tier branch.""" def test_git_state_is_populated_from_provider_not_proposal(self, doc_tree, scan_artifact): proposal = _lifecycle_delete_proposal() # Proposal lies about git_state; report_builder must ignore it. proposal["exact_edit"]["lifecycle"]["git_state"] = {"tracked": False, "clean": False} provider = FakeGitStateProvider({"docs/clean.md": {"tracked": True, "clean": True}}) builder = ReportBuilder( project_root=doc_tree, clock=MonotonicClock(_T0), estimator=default_estimator(), git_state_provider=provider, ) result = builder.build(scan_artifact, [proposal]) entry = result["machine_report"]["entries"][0] assert entry["exact_edit"]["lifecycle"]["git_state"] == {"tracked": True, "clean": True} def test_scanner_proven_tracked_clean_yields_auto(self, doc_tree, scan_artifact): provider = FakeGitStateProvider({"docs/clean.md": {"tracked": True, "clean": True}}) builder = ReportBuilder( project_root=doc_tree, clock=MonotonicClock(_T0), estimator=default_estimator(), git_state_provider=provider, ) result = builder.build(scan_artifact, [_lifecycle_delete_proposal()]) entry = result["machine_report"]["entries"][0] assert entry["safety_tier"] == "auto" def test_dirty_worktree_forces_confirm(self, doc_tree, scan_artifact): provider = FakeGitStateProvider({"docs/clean.md": {"tracked": True, "clean": False}}) builder = ReportBuilder( project_root=doc_tree, clock=MonotonicClock(_T0), estimator=default_estimator(), git_state_provider=provider, ) result = builder.build(scan_artifact, [_lifecycle_delete_proposal()]) entry = result["machine_report"]["entries"][0] assert entry["safety_tier"] == "confirm" def test_untracked_forces_confirm(self, doc_tree, scan_artifact): provider = FakeGitStateProvider({}) # unknown path -> untracked builder = ReportBuilder( project_root=doc_tree, clock=MonotonicClock(_T0), estimator=default_estimator(), git_state_provider=provider, ) result = builder.build(scan_artifact, [_lifecycle_delete_proposal()]) entry = result["machine_report"]["entries"][0] assert entry["safety_tier"] == "confirm" def test_classifier_judged_served_when_always_confirm(self, doc_tree, scan_artifact): provider = FakeGitStateProvider({"docs/clean.md": {"tracked": True, "clean": True}}) proposal = _lifecycle_delete_proposal( lifetime="delete-once-served", served_when="the effort this plan describes has shipped", ) builder = ReportBuilder( project_root=doc_tree, clock=MonotonicClock(_T0), estimator=default_estimator(), git_state_provider=provider, ) result = builder.build(scan_artifact, [proposal]) entry = result["machine_report"]["entries"][0] assert entry["safety_tier"] == "confirm" def test_served_when_path_tracked_clean_yields_auto(self, doc_tree, scan_artifact): provider = FakeGitStateProvider({"docs/clean.md": {"tracked": True, "clean": True}}) proposal = _lifecycle_delete_proposal( lifetime="delete-once-served", served_when_path="openspec/changes/archive/{id}/", ) builder = ReportBuilder( project_root=doc_tree, clock=MonotonicClock(_T0), estimator=default_estimator(), git_state_provider=provider, ) result = builder.build(scan_artifact, [proposal]) entry = result["machine_report"]["entries"][0] assert entry["safety_tier"] == "auto" def test_delete_entry_is_destructive_true(self, doc_tree, scan_artifact): provider = FakeGitStateProvider({"docs/clean.md": {"tracked": True, "clean": True}}) builder = ReportBuilder( project_root=doc_tree, clock=MonotonicClock(_T0), estimator=default_estimator(), git_state_provider=provider, ) result = builder.build(scan_artifact, [_lifecycle_delete_proposal()]) entry = result["machine_report"]["entries"][0] assert entry["is_destructive"] is True def test_delete_entry_passes_validator(self, doc_tree, scan_artifact): provider = FakeGitStateProvider({"docs/clean.md": {"tracked": True, "clean": True}}) builder = ReportBuilder( project_root=doc_tree, clock=MonotonicClock(_T0), estimator=default_estimator(), git_state_provider=provider, ) result = builder.build(scan_artifact, [_lifecycle_delete_proposal()]) violations = ReportValidator(result["machine_report"]).validate() assert violations == [] def test_extract_then_delete_repo_durable_passes_validator(self, doc_tree, scan_artifact): provider = FakeGitStateProvider({"docs/clean.md": {"tracked": True, "clean": True}}) proposal = _lifecycle_delete_proposal() proposal["exact_edit"]["kind"] = "extract-then-delete" proposal["exact_edit"]["extraction_target"] = "repo-durable" proposal["exact_edit"]["extraction_dest"] = "CLAUDE.md" builder = ReportBuilder( project_root=doc_tree, clock=MonotonicClock(_T0), estimator=default_estimator(), git_state_provider=provider, ) result = builder.build(scan_artifact, [proposal]) violations = ReportValidator(result["machine_report"]).validate() assert violations == [] def test_missing_extraction_target_is_malformed_proposal(self, doc_tree, scan_artifact): proposal = _lifecycle_delete_proposal() proposal["exact_edit"]["kind"] = "extract-then-delete" # extraction_target omitted -> report_builder rejects before compute builder = ReportBuilder(project_root=doc_tree, clock=MonotonicClock(_T0)) with pytest.raises(MalformedProposalError): builder.build(scan_artifact, [proposal]) def test_whole_file_span_counted_for_delete(self, doc_tree, scan_artifact): provider = FakeGitStateProvider({"docs/clean.md": {"tracked": True, "clean": True}}) builder = ReportBuilder( project_root=doc_tree, clock=MonotonicClock(_T0), estimator=default_estimator(), git_state_provider=provider, ) result = builder.build(scan_artifact, [_lifecycle_delete_proposal()]) entry = result["machine_report"]["entries"][0] whole = default_estimator().estimate((doc_tree / "docs" / "clean.md").read_text()) assert entry["token_estimate"]["raw_tokens"] == whole class TestPromotionCandidates: """Group 3 (task 3.2): deterministic, model-free promotion_candidates.""" def _builder(self, doc_tree, conventions_path=None, **kw): return ReportBuilder( project_root=doc_tree, clock=MonotonicClock(_T0), estimator=default_estimator(), conventions_path=conventions_path, **kw ) def _write_conventions(self, tmp_path: Path) -> Path: path = tmp_path / "conventions.json" path.write_text(json.dumps([ {"name": "archive-bucket", "what_it_proves": "moved to archive/", "pitch": "Move it once."}, {"name": "status-frontmatter", "what_it_proves": "status: shipped", "pitch": "Stamp status once."}, ])) return path def test_classifier_judged_entry_yields_candidates(self, doc_tree, scan_artifact, tmp_path): conventions_path = self._write_conventions(tmp_path) provider = FakeGitStateProvider({"docs/clean.md": {"tracked": True, "clean": True}}) proposal = _lifecycle_delete_proposal( lifetime="delete-once-served", served_when="the effort this plan describes has shipped", ) builder = self._builder(doc_tree, conventions_path=conventions_path, git_state_provider=provider) result = builder.build(scan_artifact, [proposal]) candidates = result["machine_report"]["promotion_candidates"] names = {c["name"] for c in candidates} assert names == {"archive-bucket", "status-frontmatter"} assert all(c["path"] == "docs/clean.md" for c in candidates) assert all(c["pitch"] for c in candidates) def test_scanner_proven_served_when_path_yields_no_candidate(self, doc_tree, scan_artifact, tmp_path): conventions_path = self._write_conventions(tmp_path) provider = FakeGitStateProvider({"docs/clean.md": {"tracked": True, "clean": True}}) proposal = _lifecycle_delete_proposal( lifetime="delete-once-served", served_when_path="openspec/changes/archive/{id}/", ) builder = self._builder(doc_tree, conventions_path=conventions_path, git_state_provider=provider) result = builder.build(scan_artifact, [proposal]) assert result["machine_report"]["promotion_candidates"] == [] def test_no_lifecycle_entries_yields_empty_candidates(self, doc_tree, scan_artifact, tmp_path): conventions_path = self._write_conventions(tmp_path) builder = self._builder(doc_tree, conventions_path=conventions_path) result = builder.build(scan_artifact, _proposals()) assert result["machine_report"]["promotion_candidates"] == [] def test_promotion_candidates_always_present_even_empty(self, doc_tree, scan_artifact): result = _build(doc_tree, scan_artifact, []) assert result["machine_report"]["promotion_candidates"] == [] def test_missing_conventions_file_yields_empty_candidates_no_crash(self, doc_tree, scan_artifact, tmp_path): missing = tmp_path / "does-not-exist.json" provider = FakeGitStateProvider({"docs/clean.md": {"tracked": True, "clean": True}}) proposal = _lifecycle_delete_proposal( lifetime="delete-once-served", served_when="shipped", ) builder = self._builder(doc_tree, conventions_path=missing, git_state_provider=provider) result = builder.build(scan_artifact, [proposal]) assert result["machine_report"]["promotion_candidates"] == [] def test_shipped_conventions_json_has_exactly_two_entries(self): real_conventions = _SCRIPTS_DIR.parent / "conventions.json" data = json.loads(real_conventions.read_text()) assert isinstance(data, list) names = {c["name"] for c in data} assert names == {"archive-bucket", "status-frontmatter"} for c in data: assert c.get("name") assert c.get("what_it_proves") assert c.get("pitch") def test_default_conventions_path_is_the_shipped_catalog(self, doc_tree, scan_artifact): """No conventions_path override -> reads the real, shipped catalog.""" provider = FakeGitStateProvider({"docs/clean.md": {"tracked": True, "clean": True}}) proposal = _lifecycle_delete_proposal( lifetime="delete-once-served", served_when="shipped", ) builder = ReportBuilder( project_root=doc_tree, clock=MonotonicClock(_T0), estimator=default_estimator(), git_state_provider=provider, ) result = builder.build(scan_artifact, [proposal]) names = {c["name"] for c in result["machine_report"]["promotion_candidates"]} assert names == {"archive-bucket", "status-frontmatter"} class TestHumanReportLifecycleAndPromotionRendering: """Task 5.1: the human report renders lifecycle findings + the Promotion Candidates section (doc-check spec: 'both the machine report's promotion_candidates array and the human report show the candidate').""" def _builder(self, doc_tree, conventions_path=None, **kw): return ReportBuilder( project_root=doc_tree, clock=MonotonicClock(_T0), estimator=default_estimator(), conventions_path=conventions_path, **kw ) def _write_conventions(self, tmp_path: Path) -> Path: path = tmp_path / "conventions.json" path.write_text(json.dumps([ {"name": "archive-bucket", "what_it_proves": "moved to archive/", "pitch": "Move it once."}, ])) return path def test_promotion_candidates_section_present_when_empty(self, doc_tree, scan_artifact): result = _build(doc_tree, scan_artifact, []) assert "## Promotion Candidates" in result["human_report"] assert "(none)" in result["human_report"] def test_promotion_candidates_named_with_pitch_in_human_report( self, doc_tree, scan_artifact, tmp_path ): conventions_path = self._write_conventions(tmp_path) provider = FakeGitStateProvider({"docs/clean.md": {"tracked": True, "clean": True}}) proposal = _lifecycle_delete_proposal( lifetime="delete-once-served", served_when="the effort this plan describes has shipped", ) builder = self._builder(doc_tree, conventions_path=conventions_path, git_state_provider=provider) result = builder.build(scan_artifact, [proposal]) human = result["human_report"] assert "## Promotion Candidates" in human assert "archive-bucket" in human assert "Move it once." in human def test_lifecycle_entry_line_renders_rule_ref_and_lifetime( self, doc_tree, scan_artifact ): provider = FakeGitStateProvider({"docs/clean.md": {"tracked": True, "clean": True}}) proposal = _lifecycle_delete_proposal( lifetime="delete-once-served", served_when_path="openspec/changes/archive/{id}/", ) builder = ReportBuilder( project_root=doc_tree, clock=MonotonicClock(_T0), estimator=default_estimator(), git_state_provider=provider, ) result = builder.build(scan_artifact, [proposal]) human = result["human_report"] assert "lifecycle:" in human assert "delete-once-served" in human class TestValidatorRoundTrip: def test_assembled_report_passes_validator(self, doc_tree, scan_artifact): result = _build(doc_tree, scan_artifact, _proposals()) machine = result["machine_report"] violations = ReportValidator(machine).validate() assert violations == [], f"validator rejected report: {violations}" def test_empty_proposals_still_valid(self, doc_tree, scan_artifact): result = _build(doc_tree, scan_artifact, []) machine = result["machine_report"] assert machine["entries"] == [] violations = ReportValidator(machine).validate() assert violations == []