286 lines
10 KiB
Python
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\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
|