diff --git a/doc_steward/cli.py b/doc_steward/cli.py index 27f40b11..0c64df93 100644 --- a/doc_steward/cli.py +++ b/doc_steward/cli.py @@ -23,6 +23,7 @@ from .artefact_index_validator import check_artefact_index from .dap_validator import check_dap_compliance from .doc_authority_manifest import check_doc_authority_manifest +from .drift_sweep_issue_creator import run as run_drift_sweep_issue_creator from .entrypoint_freshness_sweep import ( DEFAULT_LABELS as ENTRYPOINT_FRESHNESS_LABELS, ) @@ -316,6 +317,17 @@ def cmd_check_manifest(args: argparse.Namespace) -> int: return 0 +def cmd_drift_sweep_preview(args: argparse.Namespace) -> int: + """Run drift sweep preview (dry-run only, no GitHub mutation).""" + run_drift_sweep_issue_creator( + Path(args.repo_root).resolve(), + output_dir=args.output_dir, + print_json=args.json, + parent_link=args.parent_link, + ) + return 0 + + def main() -> int: parser = argparse.ArgumentParser( prog="doc_steward.cli", description="LifeOS Documentation Steward CLI" @@ -387,6 +399,17 @@ def main() -> int: ) p_entrypoint_sweep.set_defaults(func=cmd_entrypoint_freshness_sweep) + # drift-sweep-preview + p_drift = subparsers.add_parser( + "drift-sweep-preview", + help="Preview advisory documentation drift sweep issues (dry-run only, no GitHub mutation)", + ) + p_drift.add_argument("repo_root", help="Repository root directory") + p_drift.add_argument("--output-dir", default=None, help="Output dir for preview files") + p_drift.add_argument("--json", action="store_true", help="Print JSON preview to stdout") + p_drift.add_argument("--parent-link", default=None, help="URL to parent tracking issue") + p_drift.set_defaults(func=cmd_drift_sweep_preview) + # protocols-structure-check p_protocols = subparsers.add_parser( "protocols-structure-check", help="Validate docs/02_protocols/ structure" diff --git a/doc_steward/drift_sweep_issue_creator.py b/doc_steward/drift_sweep_issue_creator.py new file mode 100644 index 00000000..e97147c9 --- /dev/null +++ b/doc_steward/drift_sweep_issue_creator.py @@ -0,0 +1,266 @@ +""" +Self-contained drift sweep issue preview creator. + +Read-only dry-run module that detects advisory documentation drift via +freshness_validator.check_entrypoint_freshness() and produces structured +JSON previews and human-readable markdown summaries without any GitHub +mutation, sweep_lib dependency, or external state. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + +from .freshness_validator import check_entrypoint_freshness + +SWEEP_ID = "inventory-hygiene-sweep" +TARGET = "lifeos-doc-entrypoint" +CHECK_ID = "readme-entrypoint-freshness" +SCHEMA_VERSION = "drift-sweep-preview-v1" + + +def _fingerprint( + sweep_id: str, + target: str, + check_id: str, + evidence: str, + authority_class: str, +) -> str: + """Deterministic SHA-256 fingerprint for a drift class group.""" + raw = "|".join([sweep_id, target, check_id, evidence, authority_class]) + return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16] + + +def _normalize_evidence(findings: list[dict]) -> str: + """Stable normalized text for one drift-class group. + + Sorts findings by id, lowercases all evidence, collapses whitespace. + """ + parts = [] + for finding in sorted(findings, key=lambda f: str(f.get("id", ""))): + ev = str(finding.get("evidence", "")) + parts.append(" ".join(ev.split()).strip().lower()) + return " | ".join(parts) + + +def _group_findings(findings: list[dict]) -> dict[str, list[dict]]: + """Group findings by their 'id' key, preserving first-occurrence order.""" + groups: dict[str, list[dict]] = {} + for finding in findings: + fid = str(finding.get("id", "")) + if fid not in groups: + groups[fid] = [] + groups[fid].append(finding) + return groups + + +def _compute_fingerprints(groups: dict[str, list[dict]]) -> dict[str, str]: + """Compute per-class fingerprints for each drift class.""" + fingerprints: dict[str, str] = {} + for fid, findings in groups.items(): + evidence = _normalize_evidence(findings) + authority_class = str(findings[0].get("authority_class", "")) + fingerprints[fid] = _fingerprint( + SWEEP_ID, + TARGET, + CHECK_ID, + evidence, + authority_class, + ) + return fingerprints + + +def build_preview( + findings: list[dict], + parent_link: str | None = None, +) -> dict: + """Build structured JSON preview from detector findings.""" + groups = _group_findings(findings) + fingerprints = _compute_fingerprints(groups) + now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + classes = {} + for fid, fgroup in groups.items(): + classes[fid] = { + "count": len(fgroup), + "fingerprint": fingerprints.get(fid, ""), + } + + findings_by_class: dict[str, list[dict]] = {} + for fid, fgroup in groups.items(): + findings_by_class[fid] = [dict(f) for f in fgroup] + + return { + "sweep_metadata": { + "sweep_id": SWEEP_ID, + "target": TARGET, + "check_id": CHECK_ID, + "generation_timestamp": now, + "schema_version": SCHEMA_VERSION, + }, + "summary": { + "total_findings": len(findings), + "total_classes": len(groups), + "classes": classes, + }, + "findings_by_class": findings_by_class, + "fingerprints": fingerprints, + "parent_link": parent_link, + "receipt": { + "tool": "drift_sweep_issue_creator", + "version": "1", + "mutated": False, + "dry_run": True, + "generated_at": now, + }, + } + + +def _footer() -> str: + return ( + "---\n\n" + "_Generated by `drift_sweep_issue_creator`. " + "No files were mutated. This is a dry-run preview._" + ) + + +def render_markdown(preview: dict) -> str: + """Render a drift preview dict as human-readable markdown.""" + lines: list[str] = [] + sweep_md = preview.get("sweep_metadata", {}) + summary = preview.get("summary", {}) + + lines.append("# Advisory Documentation Drift Sweep Preview") + lines.append("") + lines.append(f"- **Sweep:** `{sweep_md.get('sweep_id')}`") + lines.append(f"- **Target:** `{sweep_md.get('target')}`") + lines.append(f"- **Check:** `{sweep_md.get('check_id')}`") + lines.append(f"- **Generated:** {sweep_md.get('generation_timestamp')}") + lines.append("") + + parent_link = preview.get("parent_link") + if parent_link: + lines.append(f"- **Parent issue:** {parent_link}") + lines.append("") + + total_findings = summary.get("total_findings", 0) + + if total_findings == 0: + lines.append("## No drift findings detected.") + lines.append("") + lines.append(_footer()) + return "\n".join(lines) + + total_classes = summary.get("total_classes", 0) + lines.append(f"**{total_findings} finding(s) across {total_classes} drift class(es)**") + lines.append("") + lines.append("| Drift Class | Count | Fingerprint |") + lines.append("|---|---|---|") + + classes = summary.get("classes", {}) + for fid, info in classes.items(): + lines.append(f"| `{fid}` | {info.get('count', 0)} | `{info.get('fingerprint', '')}` |") + lines.append("") + + findings_by_class = preview.get("findings_by_class", {}) + for fid in classes: + fgroup = findings_by_class.get(fid, []) + lines.append("---") + lines.append("") + fp = classes.get(fid, {}).get("fingerprint", "") + lines.append(f"## {fid} ({len(fgroup)} finding(s))") + lines.append("") + lines.append(f"- **Fingerprint:** `{fp}`") + + for finding in fgroup: + lines.append(f"- **Severity:** {finding.get('severity', 'N/A')}") + paths = finding.get("paths", []) + if paths: + lines.append(f"- **Paths:** {', '.join(str(p) for p in paths)}") + lines.append(f"- **Evidence:** {finding.get('evidence', 'N/A')}") + recovery = finding.get("recommended_recovery", "") + if recovery: + lines.append(f"- **Recovery:** {recovery}") + authority = finding.get("authority_class", "") + if authority: + lines.append(f"- **Authority:** {authority}") + lines.append("") + + lines.append(_footer()) + return "\n".join(lines) + + +def write_preview(preview: dict, output_dir: str | Path) -> list[Path]: + """Write JSON and MD preview files to output_dir. + + Creates output_dir if needed. Writes timestamped copies plus _latest files. + Returns list of written file paths. + """ + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + + now = datetime.now(timezone.utc) + timestamp = now.strftime("%Y%m%dT%H%M%SZ") + + json_path = output_path / f"drift_sweep_preview_{timestamp}.json" + md_path = output_path / f"drift_sweep_preview_{timestamp}.md" + + json_text = json.dumps(preview, indent=2, sort_keys=False) + "\n" + md_text = render_markdown(preview) + "\n" + + json_path.write_text(json_text, encoding="utf-8") + md_path.write_text(md_text, encoding="utf-8") + + latest_json = output_path / "drift_sweep_preview_latest.json" + latest_md = output_path / "drift_sweep_preview_latest.md" + + latest_json.write_text(json_text, encoding="utf-8") + latest_md.write_text(md_text, encoding="utf-8") + + return [json_path, md_path, latest_json, latest_md] + + +def run( + repo_root: str | Path, + output_dir: str | Path | None = None, + print_json: bool = False, + parent_link: str | None = None, +) -> dict: + """Orchestrate: detect -> build_preview -> optional write/print.""" + findings = check_entrypoint_freshness(repo_root) + preview = build_preview(findings, parent_link=parent_link) + + if output_dir is not None: + write_preview(preview, output_dir) + + if print_json: + print(json.dumps(preview, indent=2, sort_keys=False)) + + return preview + + +def main(argv: list[str] | None = None) -> int: + """Standalone CLI entry point.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("repo_root", help="Repository root directory") + parser.add_argument("--output-dir", default=None, help="Output dir for preview files") + parser.add_argument("--json", action="store_true", help="Print JSON preview to stdout") + parser.add_argument("--parent-link", default=None, help="URL to parent tracking issue") + args = parser.parse_args(argv) + + run( + Path(args.repo_root).resolve(), + output_dir=args.output_dir, + print_json=args.json, + parent_link=args.parent_link, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests_doc/test_drift_sweep_issue_creator.py b/tests_doc/test_drift_sweep_issue_creator.py new file mode 100644 index 00000000..8b08c0af --- /dev/null +++ b/tests_doc/test_drift_sweep_issue_creator.py @@ -0,0 +1,531 @@ +"""Tests for drift_sweep_issue_creator — dry-run preview builder for entrypoint freshness drift.""" + +from __future__ import annotations + +from pathlib import Path + +from doc_steward.drift_sweep_issue_creator import ( + CHECK_ID, + SCHEMA_VERSION, + SWEEP_ID, + TARGET, + _compute_fingerprints, + _group_findings, + _normalize_evidence, + build_preview, + main, + render_markdown, + write_preview, +) +from doc_steward.freshness_validator import check_entrypoint_freshness + +# --------------------------------------------------------------------------- +# Fixture helpers (local copy — avoids cross-test-module import coupling) +# --------------------------------------------------------------------------- + +CLEAN_README = """# LifeOS + +**Current Status**: Live COO operations. + +Repo canon wins on conflict. The strategic corpus and wiki are derived. + +1. [docs/INDEX.md](docs/INDEX.md) +2. [onboarding](docs/08_manuals/LifeOS_Operator_Onboarding.md) +3. [state](docs/11_admin/LIFEOS_STATE.md) +4. [architecture](docs/00_foundations/LifeOS%20Target%20Architecture%20v2.3c.md) +""" + +CLEAN_REGISTRY = """doc_groups: + - id: canonical-root-navigation + authority: canonical + paths: + - docs/INDEX.md + - id: derived-strategic-corpus + authority: derived + paths: + - docs/LifeOS_Strategic_Corpus.md +""" + + +def _write_entrypoint_fixture( + root: Path, readme: str | None = None, registry: str | None = None +) -> None: + (root / "docs" / "11_admin").mkdir(parents=True, exist_ok=True) + (root / "docs" / "08_manuals").mkdir(parents=True, exist_ok=True) + (root / "docs" / "00_foundations").mkdir(parents=True, exist_ok=True) + (root / "config" / "docs").mkdir(parents=True, exist_ok=True) + (root / "docs" / "INDEX.md").write_text("# Index\n", encoding="utf-8") + (root / "docs" / "LifeOS_Strategic_Corpus.md").write_text( + "# Derived corpus\n", encoding="utf-8" + ) + (root / "docs" / "11_admin" / "LIFEOS_STATE.md").write_text( + "# LifeOS State\n\n## COO Bootstrap Campaign\nLive COO operational.\n", + encoding="utf-8", + ) + (root / "README.md").write_text( + readme or CLEAN_README, + encoding="utf-8", + ) + (root / "config" / "docs" / "authority_registry.yaml").write_text( + registry or CLEAN_REGISTRY, + encoding="utf-8", + ) + + +def _find_by_id(findings: list[dict], fid: str) -> dict | None: + return next((f for f in findings if f.get("id") == fid), None) + + +# =================================================================== +# GROUP A: Six drift classes detected +# =================================================================== + + +def test_a1_detects_missing_required_file(tmp_path): + """entrypoint-required-file-missing: no fixture at all.""" + findings = check_entrypoint_freshness(tmp_path) + preview = build_preview(findings) + + assert preview["summary"]["total_findings"] == 1 + assert "entrypoint-required-file-missing" in preview["findings_by_class"] + f = preview["findings_by_class"]["entrypoint-required-file-missing"][0] + assert f["severity"] == "warning" + assert "README.md" in f["paths"] + assert f["authority_class"] == "canonical" + + +def test_a2_detects_missing_read_order_links(tmp_path): + _write_entrypoint_fixture( + tmp_path, + readme="# LifeOS\n\nRepo canon wins on conflict. The strategic corpus is derived.\n", + ) + findings = check_entrypoint_freshness(tmp_path) + preview = build_preview(findings) + + assert "entrypoint-read-order-missing-links" in preview["findings_by_class"] + f = preview["findings_by_class"]["entrypoint-read-order-missing-links"][0] + assert "docs/INDEX.md" in f["evidence"] + assert f["paths"] == ["README.md"] + + +def test_a3_detects_stale_phase4_status(tmp_path): + _write_entrypoint_fixture( + tmp_path, + readme="""# LifeOS + +**Current Status**: Phase 4 Preparation — Tier-3 Authorized. + +Repo canon wins on conflict. The strategic corpus is derived. + +[docs/INDEX.md](docs/INDEX.md) +[onboarding](docs/08_manuals/LifeOS_Operator_Onboarding.md) +[state](docs/11_admin/LIFEOS_STATE.md) +[architecture](docs/00_foundations/LifeOS%20Target%20Architecture%20v2.3c.md) +""", + ) + findings = check_entrypoint_freshness(tmp_path) + preview = build_preview(findings) + + assert "entrypoint-readme-status-contradicts-lifeos-state" in preview["findings_by_class"] + f = preview["findings_by_class"]["entrypoint-readme-status-contradicts-lifeos-state"][0] + assert "Phase 4" in f["evidence"] or "Tier-3" in f["evidence"] + + +def test_a4_detects_missing_derived_boundary(tmp_path): + _write_entrypoint_fixture( + tmp_path, + readme="""# LifeOS + +**Current Status**: Live COO operations. + +1. [docs/INDEX.md](docs/INDEX.md) +2. [onboarding](docs/08_manuals/LifeOS_Operator_Onboarding.md) +3. [state](docs/11_admin/LIFEOS_STATE.md) +4. [architecture](docs/00_foundations/LifeOS%20Target%20Architecture%20v2.3c.md) +""", + ) + findings = check_entrypoint_freshness(tmp_path) + preview = build_preview(findings) + + assert "entrypoint-derived-surface-boundary-missing" in preview["findings_by_class"] + f = preview["findings_by_class"]["entrypoint-derived-surface-boundary-missing"][0] + assert "derived" in f["evidence"].lower() + + +def test_a5_detects_index_registry_mismatch(tmp_path): + _write_entrypoint_fixture( + tmp_path, + registry="""doc_groups: + - id: wrong-root-navigation + authority: derived + paths: + - docs/INDEX.md +""", + ) + findings = check_entrypoint_freshness(tmp_path) + preview = build_preview(findings) + + assert "entrypoint-index-authority-registry-mismatch" in preview["findings_by_class"] + f = preview["findings_by_class"]["entrypoint-index-authority-registry-mismatch"][0] + assert "authority_registry" in f["evidence"] or "INDEX.md" in f["evidence"] + + +def test_a6_detects_corpus_registry_mismatch(tmp_path): + _write_entrypoint_fixture( + tmp_path, + registry="""doc_groups: + - id: canonical-root-navigation + authority: canonical + paths: + - docs/INDEX.md +""", + ) + findings = check_entrypoint_freshness(tmp_path) + preview = build_preview(findings) + + assert "entrypoint-corpus-authority-registry-mismatch" in preview["findings_by_class"] + + +# =================================================================== +# GROUP B: Clean state +# =================================================================== + + +def test_b1_clean_fixture_no_findings(tmp_path): + _write_entrypoint_fixture(tmp_path) + findings = check_entrypoint_freshness(tmp_path) + preview = build_preview(findings) + + assert preview["summary"]["total_findings"] == 0 + assert preview["summary"]["total_classes"] == 0 + assert preview["findings_by_class"] == {} + assert preview["fingerprints"] == {} + + +# =================================================================== +# GROUP C: Fingerprint stability +# =================================================================== + + +def test_c1_fingerprint_deterministic(tmp_path): + _write_entrypoint_fixture( + tmp_path, + readme="# LifeOS\n\nRepo canon wins on conflict. The strategic corpus is derived.\n", + ) + findings = check_entrypoint_freshness(tmp_path) + preview1 = build_preview(findings) + preview2 = build_preview(findings) + + assert preview1["fingerprints"] == preview2["fingerprints"] + + +def test_c2_fingerprint_differs_on_input_change(tmp_path): + _write_entrypoint_fixture( + tmp_path, + readme="# LifeOS\n\nRepo canon wins on conflict. The strategic corpus is derived.\n", + ) + findings_a = check_entrypoint_freshness(tmp_path) + fp_a = build_preview(findings_a)["fingerprints"] + + # Different README -> different findings -> different fingerprints + _write_entrypoint_fixture( + tmp_path, + readme="# LifeOS\n", + ) + findings_b = check_entrypoint_freshness(tmp_path) + fp_b = build_preview(findings_b)["fingerprints"] + + assert fp_a != fp_b + + +def test_c3_fingerprint_stable_across_runs(tmp_path): + _write_entrypoint_fixture( + tmp_path, + readme="# LifeOS\n\nRepo canon wins on conflict. The strategic corpus is derived.\n", + ) + findings = check_entrypoint_freshness(tmp_path) + preview = build_preview(findings) + + for _, fp in preview["fingerprints"].items(): + assert isinstance(fp, str) + assert len(fp) == 16 + assert all(c in "0123456789abcdef" for c in fp) + + +def test_c4_fingerprint_16_char_hex(tmp_path): + groups = _group_findings( + [{"id": "test-class", "evidence": "some evidence", "authority_class": "canonical"}] + ) + fps = _compute_fingerprints(groups) + fp = fps["test-class"] + + assert isinstance(fp, str) + assert len(fp) == 16 + assert all(c in "0123456789abcdef" for c in fp) + + +# =================================================================== +# GROUP D: JSON shape +# =================================================================== + + +def test_d1_required_top_level_keys(tmp_path): + _write_entrypoint_fixture( + tmp_path, + readme="# LifeOS\n\nRepo canon wins on conflict. The strategic corpus is derived.\n", + ) + findings = check_entrypoint_freshness(tmp_path) + preview = build_preview(findings) + + required = { + "sweep_metadata", + "summary", + "findings_by_class", + "fingerprints", + "parent_link", + "receipt", + } + assert required.issubset(preview.keys()) + + +def test_d2_metadata_types(tmp_path): + _write_entrypoint_fixture(tmp_path) + findings = check_entrypoint_freshness(tmp_path) + preview = build_preview(findings) + meta = preview["sweep_metadata"] + + assert meta["sweep_id"] == SWEEP_ID + assert meta["target"] == TARGET + assert meta["check_id"] == CHECK_ID + assert meta["schema_version"] == SCHEMA_VERSION + assert isinstance(meta["generation_timestamp"], str) + + +def test_d3_receipt_dry_run_true(tmp_path): + _write_entrypoint_fixture(tmp_path) + findings = check_entrypoint_freshness(tmp_path) + preview = build_preview(findings) + receipt = preview["receipt"] + + assert receipt["dry_run"] is True + assert receipt["mutated"] is False + assert receipt["tool"] == "drift_sweep_issue_creator" + + +def test_d4_summary_counts_match_findings(tmp_path): + _write_entrypoint_fixture( + tmp_path, + readme="# LifeOS\n\nRepo canon wins on conflict. The strategic corpus is derived.\n", + ) + findings = check_entrypoint_freshness(tmp_path) + preview = build_preview(findings) + summary = preview["summary"] + + total = 0 + for info in summary["classes"].values(): + total += info["count"] + assert total == summary["total_findings"] + assert summary["total_classes"] == len(preview["findings_by_class"]) + + +# =================================================================== +# GROUP E: Markdown rendering +# =================================================================== + + +def test_e1_render_with_findings(tmp_path): + _write_entrypoint_fixture( + tmp_path, + readme="# LifeOS\n\nRepo canon wins on conflict. The strategic corpus is derived.\n", + ) + findings = check_entrypoint_freshness(tmp_path) + preview = build_preview(findings) + md = render_markdown(preview) + + assert "# Advisory Documentation Drift Sweep Preview" in md + assert "finding(s) across" in md + assert "entrypoint-read-order-missing-links" in md + assert "No files were mutated" in md + + +def test_e2_render_empty_state(tmp_path): + _write_entrypoint_fixture(tmp_path) + findings = check_entrypoint_freshness(tmp_path) + preview = build_preview(findings) + md = render_markdown(preview) + + assert "# Advisory Documentation Drift Sweep Preview" in md + assert "No drift findings detected" in md + assert "| Drift Class | Count | Fingerprint |" not in md + assert "finding(s) across" not in md + + +def test_e3_render_with_parent_link(tmp_path): + _write_entrypoint_fixture( + tmp_path, + readme="# LifeOS\n\nRepo canon wins on conflict. The strategic corpus is derived.\n", + ) + findings = check_entrypoint_freshness(tmp_path) + preview = build_preview(findings, parent_link="https://github.com/example/123") + md = render_markdown(preview) + + assert "Parent issue" in md + assert "https://github.com/example/123" in md + + +def test_e4_render_without_parent_link(tmp_path): + _write_entrypoint_fixture(tmp_path) + findings = check_entrypoint_freshness(tmp_path) + preview = build_preview(findings) + md = render_markdown(preview) + + assert "Parent issue" not in md + + +# =================================================================== +# GROUP F: Safe repeated runs +# =================================================================== + + +def test_f1_same_fixture_same_preview(tmp_path): + _write_entrypoint_fixture( + tmp_path, + readme="# LifeOS\n\nRepo canon wins on conflict. The strategic corpus is derived.\n", + ) + findings = check_entrypoint_freshness(tmp_path) + preview1 = build_preview(findings) + preview2 = build_preview(findings) + + assert preview1["summary"] == preview2["summary"] + assert preview1["fingerprints"] == preview2["fingerprints"] + + +def test_f2_write_twice_no_error(tmp_path): + _write_entrypoint_fixture( + tmp_path, + readme="# LifeOS\n\nRepo canon wins on conflict. The strategic corpus is derived.\n", + ) + findings = check_entrypoint_freshness(tmp_path) + preview = build_preview(findings) + out = tmp_path / "out" + + write_preview(preview, out) # first write + write_preview(preview, out) # second write — no error + + +# =================================================================== +# GROUP G: Output directory +# =================================================================== + + +def test_g1_output_dir_auto_created(tmp_path): + _write_entrypoint_fixture(tmp_path) + findings = check_entrypoint_freshness(tmp_path) + preview = build_preview(findings) + + out = tmp_path / "nonexistent" / "subdir" + write_preview(preview, out) + + assert out.is_dir() + + +def test_g2_json_and_md_files_written(tmp_path): + _write_entrypoint_fixture(tmp_path) + findings = check_entrypoint_freshness(tmp_path) + preview = build_preview(findings) + + out = tmp_path / "out" + files = write_preview(preview, out) + + for f in files: + assert f.exists(), f"Expected {f} to exist" + assert any(f.suffix == ".json" for f in files) + assert any(f.suffix == ".md" for f in files) + + +def test_g3_latest_files_written(tmp_path): + _write_entrypoint_fixture(tmp_path) + findings = check_entrypoint_freshness(tmp_path) + preview = build_preview(findings) + + out = tmp_path / "out" + write_preview(preview, out) + + assert (out / "drift_sweep_preview_latest.json").exists() + assert (out / "drift_sweep_preview_latest.md").exists() + + +def test_g4_custom_output_dir(tmp_path): + _write_entrypoint_fixture(tmp_path) + findings = check_entrypoint_freshness(tmp_path) + preview = build_preview(findings) + + custom = tmp_path / "custom_output" + write_preview(preview, custom) + + files = list(custom.iterdir()) + assert len(files) >= 4 # timestamped json+md + latest json+md + + +# =================================================================== +# GROUP H: Parent link +# =================================================================== + + +def test_h1_parent_link_in_json_when_set(tmp_path): + _write_entrypoint_fixture(tmp_path) + findings = check_entrypoint_freshness(tmp_path) + preview = build_preview(findings, parent_link="https://github.com/example/456") + + assert preview["parent_link"] == "https://github.com/example/456" + + +def test_h2_parent_link_none_when_omitted(tmp_path): + _write_entrypoint_fixture(tmp_path) + findings = check_entrypoint_freshness(tmp_path) + preview = build_preview(findings) + + assert preview["parent_link"] is None + + +# =================================================================== +# GROUP I: CLI integration +# =================================================================== + + +def test_i1_main_exit_zero(tmp_path): + _write_entrypoint_fixture(tmp_path) + rc = main([str(tmp_path)]) + assert rc == 0 + + +def test_i2_main_exit_zero_with_findings(tmp_path): + # No fixture -> required file missing -> findings present + rc = main([str(tmp_path)]) + assert rc == 0 + + +# =================================================================== +# Internal helpers unit tests +# =================================================================== + + +def test_normalize_evidence_stable_order(): + findings = [ + {"id": "b-class", "evidence": "ZZZ"}, + {"id": "a-class", "evidence": "AAA"}, + ] + result = _normalize_evidence(findings) + assert result == "aaa | zzz" + + +def test_group_findings_preserves_order(): + findings = [ + {"id": "z-first", "evidence": "z"}, + {"id": "a-second", "evidence": "a"}, + {"id": "z-first", "evidence": "z again"}, + ] + groups = _group_findings(findings) + keys = list(groups.keys()) + assert keys == ["z-first", "a-second"] + assert len(groups["z-first"]) == 2