init: readme 작성 하네스 설계
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
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
|
||||
Reference in New Issue
Block a user