init: readme 작성 하네스 설계
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,4 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+2
@@ -0,0 +1,2 @@
|
||||
#Fri Jul 24 13:25:57 KST 2026
|
||||
gradle.version=9.2.0
|
||||
@@ -0,0 +1,5 @@
|
||||
# demo
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
Run `./gradlew doesNotExist` to start, then read [the docs](docs/missing.md).
|
||||
@@ -0,0 +1,7 @@
|
||||
plugins {
|
||||
java
|
||||
}
|
||||
|
||||
tasks.register("test") {
|
||||
description = "Run the tests"
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
#Fri Jul 24 13:25:55 KST 2026
|
||||
gradle.version=9.2.0
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
plugins {
|
||||
java
|
||||
}
|
||||
|
||||
group = "com.example.demo"
|
||||
version = "0.1.0"
|
||||
|
||||
tasks.register("bootRun") {
|
||||
description = "Run the application"
|
||||
}
|
||||
|
||||
tasks.register("test") {
|
||||
description = "Run the tests"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
rootProject.name = "demo"
|
||||
include(":core-domain")
|
||||
@@ -0,0 +1,5 @@
|
||||
public class App {
|
||||
public static void main(String[] args) {
|
||||
System.out.println("demo");
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
#Fri Jul 24 13:26:00 KST 2026
|
||||
gradle.version=8.9
|
||||
@@ -0,0 +1,11 @@
|
||||
# Order Status
|
||||
|
||||
Order Status는 주문 처리 상태를 확인하는 백엔드 서비스 예제입니다.
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
다음 명령으로 개발 서버를 시작합니다.
|
||||
|
||||
```bash
|
||||
./gradlew bootRun
|
||||
```
|
||||
@@ -0,0 +1 @@
|
||||
tasks.register("bootRun")
|
||||
@@ -0,0 +1 @@
|
||||
rootProject.name = "order-status"
|
||||
@@ -0,0 +1,13 @@
|
||||
# Slug Lite
|
||||
|
||||
Slug Lite는 제목 문자열을 URL 경로에 쓸 수 있는 짧은 식별자로 바꾸는 Python 라이브러리입니다.
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
패키지의 `slugify` 함수를 가져와 문자열을 전달합니다.
|
||||
|
||||
```python
|
||||
from slug_lite import slugify
|
||||
|
||||
slugify("Hello World")
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
[project]
|
||||
name = "slug-lite"
|
||||
version = "1.0.0"
|
||||
@@ -0,0 +1,11 @@
|
||||
# Weather Note
|
||||
|
||||
Weather Note는 오늘의 날씨 메모를 터미널에 저장하는 작은 명령줄 도구입니다.
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
다음 명령을 실행하면 새 메모 파일의 경로가 출력됩니다.
|
||||
|
||||
```bash
|
||||
weather-note
|
||||
```
|
||||
@@ -0,0 +1,6 @@
|
||||
[project]
|
||||
name = "weather-note"
|
||||
version = "1.0.0"
|
||||
|
||||
[project.scripts]
|
||||
weather-note = "weather_note:main"
|
||||
@@ -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
|
||||
@@ -0,0 +1,21 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from readme_harness.core import audit_readme
|
||||
|
||||
|
||||
QUALITY_ROOT = Path(__file__).parent / "quality"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fixture", ["python-cli", "backend-service", "library"])
|
||||
def test_small_quality_fixture(fixture, tmp_path):
|
||||
repo = QUALITY_ROOT / fixture
|
||||
readme = (repo / "README.md").read_text(encoding="utf-8")
|
||||
|
||||
result = audit_readme(repo, output_dir=tmp_path / fixture)
|
||||
|
||||
assert result.status == "PASS"
|
||||
assert len(readme) < 2_000
|
||||
assert "## 빠른 시작" in readme
|
||||
assert not any(term in readme for term in ("claim-id", "QUALITY_REVIEWED", "visual-plan", "run-id"))
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user