import json import pytest from readme_harness.claims import bind_claims, blocks, reader_markdown from readme_harness.core import apply_reviewed_readme, audit_readme, build_readme from readme_harness.facts import extract_repository_facts from readme_harness.quality import validate_reader_quality from readme_harness.review import candidate_hash def _repo(tmp_path): repo = tmp_path / "demo" repo.mkdir() (repo / "pyproject.toml").write_text( '[project]\nname = "demo"\nversion = "1.0.0"\n' '[project.scripts]\ndemo = "demo:main"\n', encoding="utf-8", ) return repo def test_facts_are_small_and_evidence_backed(tmp_path): facts = extract_repository_facts(_repo(tmp_path)) assert facts["project-name"] == "demo" assert facts["commands"][0]["command"] == "demo" assert facts["facts"][0]["evidence"][0]["path"] == "pyproject.toml" def test_reader_output_has_no_internal_markers_and_claims_are_external(): source = "# Demo\n\n설명입니다. \n\n## 실행\n\n`demo`를 실행합니다.\n" visible = reader_markdown(source) binding, errors = bind_claims(visible, [{"section": "실행", "block": 0, "fact-ids": ["F-1"]}]) assert "claim-id" not in visible assert not errors assert binding == [{ "section": "실행", "block": 0, "kind": "paragraph", "text-hash": blocks(visible)[1]["text-hash"], "fact-ids": ["F-1"], }] def test_reader_quality_rejects_placeholders_and_duplicate_blocks(): markdown = ( "# Demo\n\n## 빠른 시작\n\n를 확인하세요.\n\n" "이 문장은 독자에게 같은 내용을 반복해서 보여 주는 충분히 긴 문장입니다.\n\n" "## 입력과 결과\n\n이 문장은 독자에게 같은 내용을 반복해서 보여 주는 충분히 긴 문장입니다.\n" ) result = validate_reader_quality(markdown) assert not result.ok assert any("placeholder" in error for error in result.errors) assert any("duplicated" in error for error in result.errors) def test_build_writes_four_artifacts_and_never_overwrites_readme(tmp_path): repo = _repo(tmp_path) original = "# Old\n" (repo / "README.md").write_text(original, encoding="utf-8") draft = tmp_path / "draft.md" draft.write_text("# Demo\n\n프로젝트 설명입니다.\n\n## 실행\n\n`demo`를 실행합니다.\n", encoding="utf-8") facts = extract_repository_facts(repo) facts["claim-bindings"] = [{"section": "실행", "block": 0, "fact-ids": ["F-PROJECT-NAME"]}] result = build_readme(repo, draft, facts=facts) assert result.status == "REVIEW_REQUIRED" assert (repo / "README.md").read_text(encoding="utf-8") == original assert sorted(path.name for path in (repo / ".readme-harness").iterdir()) == [ "README.generated.md", "README.patch", "facts.json", "validation.json", ] def test_passing_review_is_fifth_artifact_and_explicit_apply(tmp_path): repo = _repo(tmp_path) (repo / "README.md").write_text("# Old\n", encoding="utf-8") draft_text = "# Demo\n\n프로젝트 설명입니다.\n" draft = tmp_path / "draft.md" draft.write_text(draft_text, encoding="utf-8") review = { "verdict": "PASS", "candidate-hash": candidate_hash(draft_text), "findings": [], } result = build_readme(repo, draft, review=review) assert result.status == "READY" assert (repo / ".readme-harness" / "review.json").is_file() assert (repo / "README.md").read_text(encoding="utf-8") == "# Old\n" apply_reviewed_readme(repo) assert (repo / "README.md").read_text(encoding="utf-8") == draft_text def test_audit_produces_no_candidate_or_patch(tmp_path): repo = _repo(tmp_path) (repo / "README.md").write_text("# Demo\n\n설명입니다.\n", encoding="utf-8") result = audit_readme(repo) assert result.status == "PASS" assert sorted(path.name for path in (repo / ".readme-harness").iterdir()) == [ "facts.json", "validation.json", ] def _ready_build(tmp_path): repo = _repo(tmp_path) original = "# Old\n" (repo / "README.md").write_text(original, encoding="utf-8") draft = tmp_path / "ready.md" generated = "# Demo\n\n프로젝트 설명입니다.\n" draft.write_text(generated, encoding="utf-8") review = {"verdict": "PASS", "candidate-hash": candidate_hash(generated), "findings": []} result = build_readme(repo, draft, review=review) assert result.status == "READY" return repo, original def test_generated_readme_tampering_blocks_apply(tmp_path): repo, _ = _ready_build(tmp_path) (repo / ".readme-harness" / "README.generated.md").write_text("# Tampered\n", encoding="utf-8") with pytest.raises(ValueError, match="generated"): apply_reviewed_readme(repo) def test_review_tampering_blocks_apply(tmp_path): repo, _ = _ready_build(tmp_path) review_path = repo / ".readme-harness" / "review.json" review = json.loads(review_path.read_text(encoding="utf-8")) review["findings"].append({"severity": "minor", "section": "opening", "message": "changed"}) review_path.write_text(json.dumps(review), encoding="utf-8") with pytest.raises(ValueError, match="review.json changed"): apply_reviewed_readme(repo) def test_target_readme_change_after_patch_blocks_apply(tmp_path): repo, _ = _ready_build(tmp_path) (repo / "README.md").write_text("# Human edit\n", encoding="utf-8") with pytest.raises(ValueError, match="target README changed"): apply_reviewed_readme(repo) def test_repository_change_after_validation_blocks_apply(tmp_path): repo, _ = _ready_build(tmp_path) (repo / "new-source.py").write_text("VALUE = 1\n", encoding="utf-8") with pytest.raises(ValueError, match="repository changed"): apply_reviewed_readme(repo) def test_apply_target_outside_repository_is_rejected(tmp_path): repo, _ = _ready_build(tmp_path) with pytest.raises(ValueError, match="inside the repository"): apply_reviewed_readme(repo, target="../outside.md") def test_apply_symlink_to_outside_repository_is_rejected(tmp_path): repo = _repo(tmp_path) outside = tmp_path / "outside.md" outside.write_text("outside\n", encoding="utf-8") (repo / "README.link").symlink_to(outside) draft = tmp_path / "draft.md" draft.write_text("# Demo\n", encoding="utf-8") with pytest.raises(ValueError, match="inside the repository"): build_readme(repo, draft, target_readme="README.link") def test_secret_in_generated_readme_fails_without_patch(tmp_path): repo = _repo(tmp_path) draft = tmp_path / "secret.md" draft.write_text("# Demo\n\nJWT_SECRET=supersecretvalue123\n", encoding="utf-8") result = build_readme(repo, draft) assert result.status == "FAILED" assert not (repo / ".readme-harness" / "README.patch").exists() @pytest.mark.parametrize("secret", [ "JWT_SECRET=supersecretvalue123", "postgres://user:hunter2@db:5432/app", "-----BEGIN RSA PRIVATE KEY-----", "AKIAABCDEFGHIJKLMNOP", ]) def test_supported_secret_patterns_all_fail(tmp_path, secret): repo = _repo(tmp_path) draft = tmp_path / "secret-pattern.md" draft.write_text(f"# Demo\n\n{secret}\n", encoding="utf-8") result = build_readme(repo, draft) assert result.status == "FAILED" assert any("secrets" in error for error in result.errors) def test_broken_relative_link_fails_without_patch(tmp_path): repo = _repo(tmp_path) draft = tmp_path / "broken-link.md" draft.write_text("# Demo\n\n[없는 문서](docs/missing.md)\n", encoding="utf-8") result = build_readme(repo, draft) assert result.status == "FAILED" assert any("path" in error for error in result.errors) assert not (repo / ".readme-harness" / "README.patch").exists() def test_unknown_command_fails_without_patch(tmp_path): repo = _repo(tmp_path) draft = tmp_path / "unknown-command.md" draft.write_text("# Demo\n\n```bash\ndemo-does-not-exist\n```\n", encoding="utf-8") result = build_readme(repo, draft) assert result.status == "FAILED" assert any("command" in error for error in result.errors) assert not (repo / ".readme-harness" / "README.patch").exists() @pytest.mark.parametrize("markdown", [ "# Demo\n\n```bash\ndemo\n", "# Demo\n\n### Skipped heading\n", "# Demo\n\n## Same\n\n## Same\n", "# Demo\n\n![](image.png)\n", ]) def test_markdown_safety_failures_block_patch(tmp_path, markdown): repo = _repo(tmp_path) if "image.png" in markdown: (repo / "image.png").write_bytes(b"image") draft = tmp_path / "bad-markdown.md" draft.write_text(markdown, encoding="utf-8") result = build_readme(repo, draft) assert result.status == "FAILED" assert any("markdown" in error for error in result.errors) assert not (repo / ".readme-harness" / "README.patch").exists() def test_repository_metadata_symlink_is_not_followed(tmp_path): repo = tmp_path / "linked" repo.mkdir() outside = tmp_path / "outside.toml" outside.write_text('[project]\nname = "outside-secret-name"\n', encoding="utf-8") (repo / "pyproject.toml").symlink_to(outside) facts = extract_repository_facts(repo) assert facts["project-name"] == "linked" assert "outside-secret-name" not in json.dumps(facts) def test_failed_quality_review_cannot_leave_or_apply_patch(tmp_path): repo = _repo(tmp_path) (repo / "README.md").write_text("# Old\n", encoding="utf-8") draft_text = "# Demo\n\n프로젝트 설명입니다.\n" draft = tmp_path / "draft.md" draft.write_text(draft_text, encoding="utf-8") review = { "verdict": "NEEDS_FIX", "candidate-hash": candidate_hash(draft_text), "findings": [{"severity": "major", "section": "opening", "message": "첫 행동이 없습니다."}], } result = build_readme(repo, draft, review=review) assert result.status == "NEEDS_REVISION" assert not (repo / ".readme-harness" / "README.patch").exists() with pytest.raises(ValueError, match="not READY"): apply_reviewed_readme(repo) def test_target_is_unchanged_until_explicit_apply(tmp_path): repo, original = _ready_build(tmp_path) assert (repo / "README.md").read_text(encoding="utf-8") == original apply_reviewed_readme(repo) assert (repo / "README.md").read_text(encoding="utf-8") != original