Files
readme-haness/tests/test_lean_harness.py

286 lines
10 KiB
Python

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설명입니다. <!-- claim-id: old -->\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<repo-id>를 확인하세요.\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