Files
document-haness/tests/test_hardening_regressions.py
T

1230 lines
46 KiB
Python

from __future__ import annotations
import shutil
from pathlib import Path
import pytest
from helpers import (
FIXTURES,
init_run,
install_good_contracts,
read_json,
run_cli,
write_evidence,
write_json,
write_reviews,
)
def transition(run_dir: Path, status: str) -> None:
result = run_cli(
"update_run.py",
"--run-dir",
run_dir,
"--status",
status,
"--reason",
"hardening regression",
)
assert result.returncode == 0, result.stderr
def lint_run(
run_dir: Path,
*,
document: str = "final.md",
draft_baseline: bool = True,
original_baseline: bool = False,
fail_on_warning: bool = False,
) -> int:
arguments: list[object] = [
"lint_document.py",
"--document",
run_dir / document,
"--logic-map",
run_dir / "04_logic_map.json",
"--term-ledger",
run_dir / "05_term_ledger.json",
"--reader-contract",
run_dir / "02_reader_contract.json",
"--output",
run_dir / "08_lint.json",
]
if draft_baseline:
arguments.extend(["--draft-baseline", run_dir / "07_draft.md"])
if original_baseline:
manifest = read_json(run_dir / "00_run.json")
arguments.extend(["--baseline", manifest["inputs"]["draft"]["resolved_path"]])
if fail_on_warning:
arguments.extend(["--fail-on", "warning"])
return run_cli(*arguments).returncode
def make_light_complete(
tmp_path: Path,
*,
mode: str = "write",
audience: str | None = None,
) -> Path:
run_dir = init_run(
tmp_path,
route="light",
mode=mode,
with_draft=mode == "revise",
audience=audience,
)
install_good_contracts(run_dir)
shutil.copyfile(FIXTURES / "good" / "document.md", run_dir / "final.md")
assert lint_run(
run_dir,
original_baseline=mode == "revise",
) == 0
for status in ("planned", "drafted", "finalized"):
transition(run_dir, status)
return run_dir
def make_standard_complete(tmp_path: Path, *, route: str = "standard") -> Path:
run_dir = init_run(tmp_path, route=route)
install_good_contracts(run_dir)
write_evidence(run_dir)
write_reviews(run_dir)
shutil.copyfile(FIXTURES / "good" / "document.md", run_dir / "final.md")
assert lint_run(run_dir, fail_on_warning=route == "deep") == 0
for status in ("evidence_ready", "planned", "drafted", "reviewed", "finalized"):
transition(run_dir, status)
return run_dir
def lint_custom(
tmp_path: Path,
document: str,
*,
logic: dict | None = None,
ledger: dict | None = None,
reader: dict | None = None,
extra: tuple[object, ...] = (),
):
document_path = tmp_path / "document.md"
logic_path = tmp_path / "logic.json"
ledger_path = tmp_path / "ledger.json"
reader_path = tmp_path / "reader.json"
output = tmp_path / "lint.json"
document_path.write_text(document, encoding="utf-8")
write_json(logic_path, logic or read_json(FIXTURES / "good" / "logic-map.json"))
write_json(ledger_path, ledger or read_json(FIXTURES / "good" / "term-ledger.json"))
write_json(reader_path, reader or read_json(FIXTURES / "good" / "reader-contract.json"))
result = run_cli(
"lint_document.py",
"--document",
document_path,
"--logic-map",
logic_path,
"--term-ledger",
ledger_path,
"--reader-contract",
reader_path,
"--output",
output,
*extra,
)
return result, read_json(output)
def test_unclosed_html_comment_is_a_hard_error_and_hides_following_prose(
tmp_path: Path,
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document = document.replace("<!-- claim:C1 -->", "[근거: C1]")
document = document.replace("<!-- claim:C2 -->", "[가정: C2]")
document = document.replace("\n\n캐시 장애", "\n\n<!--\n캐시 장애", 1)
result, report = lint_custom(tmp_path, document)
assert result.returncode == 1
rule_ids = {finding["rule_id"] for finding in report["findings"]}
assert "DOC-M004" in rule_ids
assert "DOC-L002" in rule_ids
def test_fence_hidden_inside_html_comment_does_not_satisfy_fidelity(
tmp_path: Path,
) -> None:
baseline = tmp_path / "baseline.md"
baseline.write_text("# 기준\n\n```text\nunique-fenced-value\n```\n", encoding="utf-8")
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += "\n<!--\n```text\nunique-fenced-value\n```\n-->\n"
result, report = lint_custom(tmp_path, document, extra=("--baseline", baseline))
assert result.returncode == 1
assert report["fidelity"]["by_type"]["fenced_code"] == {
"total": 1,
"preserved": 0,
"missing": 1,
}
assert "DOC-F001" in {finding["rule_id"] for finding in report["findings"]}
def test_inline_comment_token_is_visible_code_not_an_html_comment(
tmp_path: Path,
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += "\n주석 시작 토큰은 `<!--`이다.\n"
result, report = lint_custom(tmp_path, document)
assert result.returncode == 0, report["findings"]
def test_multiline_inline_code_comment_token_is_not_an_html_comment(
tmp_path: Path,
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += "\n리터럴 주석 구문은 `<!--\n` 입니다.\n"
result, report = lint_custom(tmp_path, document)
assert result.returncode == 0, report["findings"]
def test_fence_backticks_do_not_pair_with_later_inline_code(tmp_path: Path) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += "\n~~~text\n`\n~~~\n<!-- TODO hidden -->\n나중 `멱등성`을 쓴다.\n"
result, report = lint_custom(tmp_path, document)
assert result.returncode == 0, report["findings"]
def test_inline_code_delimiters_do_not_pair_across_paragraphs(tmp_path: Path) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += (
"\n앞 문단에 닫히지 않은 ` 기호가 있다.\n\n"
"<!-- TODO hidden -->\n\n다음 문단은 `멱등성`을 쓴다.\n"
)
result, report = lint_custom(tmp_path, document)
assert result.returncode == 0, report["findings"]
def test_inline_code_comment_marker_cannot_satisfy_claim_contract(tmp_path: Path) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document = document.replace(
"<!-- claim:C1 -->",
"`<!-- claim:C1 -->`",
1,
)
result, report = lint_custom(tmp_path, document)
assert result.returncode == 1
assert any(
finding["rule_id"] == "DOC-L003" and "C1" in finding["message"]
for finding in report["findings"]
)
def test_heading_can_explain_literal_html_comment_token_in_inline_code(
tmp_path: Path,
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document = document.replace("## 문제", "## 문제 `<!--` 토큰", 1)
logic = read_json(FIXTURES / "good" / "logic-map.json")
logic["sections"][0]["heading"] = "문제 `<!--` 토큰"
result, report = lint_custom(tmp_path, document, logic=logic)
assert result.returncode == 0, report["findings"]
@pytest.mark.parametrize("heading", ["문제 C#", " ## 문제"])
def test_commonmark_atx_heading_edges_match_logic_map(
tmp_path: Path, heading: str
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
logic = read_json(FIXTURES / "good" / "logic-map.json")
if heading == "문제 C#":
document = document.replace("## 문제", "## 문제 C#", 1)
logic["sections"][0]["heading"] = "문제 C#"
else:
document = document.replace("## 문제", heading, 1)
result, report = lint_custom(tmp_path, document, logic=logic)
assert result.returncode == 0, report["findings"]
def test_heading_anchor_uses_rendered_link_label_not_destination(tmp_path: Path) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document = document.replace("## 문제", "## [문제](https://example.com)", 1)
document = document.replace(
"문제가 생긴다.", "문제가 생긴다. [문제 절](#문제)로 돌아갈 수 있다.", 1
)
logic = read_json(FIXTURES / "good" / "logic-map.json")
logic["sections"][0]["heading"] = "[문제](https://example.com)"
result, report = lint_custom(tmp_path, document, logic=logic)
assert result.returncode == 0, report["findings"]
def test_pipe_table_rows_do_not_share_the_prose_paragraph_budget(tmp_path: Path) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
rows = "\n".join(
f"| 항목 {index}. | 설명 {index}. |" for index in range(1, 13)
)
document += f"\n\n| 항목 | 설명 |\n| --- | --- |\n{rows}\n"
result, report = lint_custom(tmp_path, document)
assert result.returncode == 0, report["findings"]
def test_inline_markdown_examples_do_not_create_links_anchors_or_placeholders(
tmp_path: Path,
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += (
"\n문법 예시는 `[링크](#없는-anchor)`와 `<a id=\"가짜\">`, `TODO`다.\n"
)
result, report = lint_custom(tmp_path, document)
assert result.returncode == 0, report["findings"]
def test_claim_marker_hidden_in_html_attribute_is_not_reader_visible(
tmp_path: Path,
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document = document.replace(
"<!-- claim:C1 -->", '<span title="[근거: C1]"></span>'
).replace("<!-- claim:C2 -->", '<span title="[가정: C2]"></span>')
result, report = lint_custom(tmp_path, document)
assert result.returncode == 1
assert "DOC-L003" in {finding["rule_id"] for finding in report["findings"]}
@pytest.mark.parametrize(
("before", "after", "kind"),
[
("`kubectl get pods -n prod`", "`kubectl delete pods -n prod`", "inline_code"),
("2026-07-23", "2027-07-23", "date"),
("1.2.3", "9.2.3", "version"),
("재시도 3회", "재시도 8회", "number_unit"),
("포트 8080", "포트 9090", "number"),
],
)
def test_accuracy_sensitive_inline_and_numeric_values_are_protected(
tmp_path: Path, before: str, after: str, kind: str
) -> None:
baseline = tmp_path / "baseline.md"
baseline.write_text(f"# 기준\n\n고유 설정은 {before}이다.\n", encoding="utf-8")
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += f"\n고유 설정은 {after}이다.\n"
result, report = lint_custom(tmp_path, document, extra=("--baseline", baseline))
assert result.returncode == 1
assert report["fidelity"]["by_type"][kind]["missing"] == 1
def test_ordered_unit_range_reversal_is_a_fidelity_error(tmp_path: Path) -> None:
baseline = tmp_path / "baseline.md"
baseline.write_text(
"# 기준\n\n지연 범위는 5ms-10ms이고 오류율은 5%-10%다.\n",
encoding="utf-8",
)
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += "\n지연 범위는 10ms-5ms이고 오류율은 10%-5%다.\n"
result, report = lint_custom(tmp_path, document, extra=("--baseline", baseline))
assert result.returncode == 1
assert "DOC-F004" in {item["rule_id"] for item in report["findings"]}
assert report["fidelity"]["by_type"]["number_range"] == {
"total": 2,
"preserved": 0,
"missing": 2,
}
def test_entity_multiline_quote_content_is_fidelity_protected(tmp_path: Path) -> None:
baseline = tmp_path / "baseline.md"
baseline.write_text(
"# 기준\n\n운영 원칙은 &quot;첫 줄\n둘째 줄&#34;이다.\n",
encoding="utf-8",
)
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += '\n운영 원칙은 "첫 줄\n바뀐 둘째 줄"이다.\n'
result, report = lint_custom(tmp_path, document, extra=("--baseline", baseline))
assert result.returncode == 1
assert "DOC-F005" in {item["rule_id"] for item in report["findings"]}
assert report["fidelity"]["by_type"]["quote"]["missing"] == 1
def test_blockquote_fidelity_context_does_not_absorb_outside_prose(
tmp_path: Path,
) -> None:
baseline = tmp_path / "baseline.md"
baseline.write_text(
"> # 인용 제목\n바깥 설명이다.\n",
encoding="utf-8",
)
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += "\n> # 인용 제목\n완전히 달라진 바깥 문단이다.\n"
_, report = lint_custom(tmp_path, document, extra=("--baseline", baseline))
assert report["fidelity"]["by_type"]["quote"] == {
"total": 1,
"preserved": 1,
"missing": 0,
}
assert "DOC-F005" not in {item["rule_id"] for item in report["findings"]}
def test_double_quote_inside_blockquote_does_not_bind_to_next_paragraph(
tmp_path: Path,
) -> None:
baseline = tmp_path / "baseline.md"
baseline.write_text(
'> 원칙은 "고정 인용"이다.\n\n첫 바깥 문단이다.\n',
encoding="utf-8",
)
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += '\n> 원칙은 "고정 인용"이다.\n\n완전히 달라진 바깥 문단이다.\n'
_, report = lint_custom(tmp_path, document, extra=("--baseline", baseline))
assert report["fidelity"]["by_type"]["quote"] == {
"total": 2,
"preserved": 2,
"missing": 0,
}
assert "DOC-F005" not in {item["rule_id"] for item in report["findings"]}
def test_common_absolute_uris_are_fidelity_protected_and_term_masked(
tmp_path: Path,
) -> None:
before = [
"http://ApiHost.test/HttpToken",
"https://ApiHost.test/HttpsToken",
"ftp://FileHost.test/FtpToken",
"ftps://FileHost.test/FtpsToken",
"file:///tmp/FileToken",
"mailto:UserToken@example.com",
"ssh://GitHost.test/SshToken",
"git://GitHost.test/GitToken",
]
after = [value.replace("Token", "ChangedToken") for value in before]
baseline = tmp_path / "baseline.md"
baseline.write_text(
"# 기준\n\n" + "\n\n".join(f"고유 위치는 {value}다." for value in before) + "\n",
encoding="utf-8",
)
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += "\n" + "\n\n".join(f"고유 위치는 {value}다." for value in after) + "\n"
result, report = lint_custom(tmp_path, document, extra=("--baseline", baseline))
assert result.returncode == 1
rule_ids = {item["rule_id"] for item in report["findings"]}
assert "DOC-F003" in rule_ids
assert "DOC-T007" not in rule_ids
assert report["fidelity"]["by_type"]["url"]["missing"] == len(before)
@pytest.mark.parametrize(
("baseline_lines", "final_lines", "expected_missing"),
[
(["재시도 허용 횟수는 5회"], ["만료 확인 횟수는 5회"], 1),
(
["재시도 허용 횟수는 5회", "백오프 허용 횟수는 5회"],
["재시도 허용 횟수는 5회", "만료 확인 횟수는 5회"],
1,
),
(["재시도 허용 횟수는 5회"], ["재시도 허용 횟수는 정확히 5회"], 0),
],
ids=["single-semantic-move", "duplicate-one-move", "bounded-wording-edit"],
)
def test_fidelity_context_binding_is_occurrence_safe_and_bounded(
tmp_path: Path,
baseline_lines: list[str],
final_lines: list[str],
expected_missing: int,
) -> None:
baseline = tmp_path / "baseline.md"
baseline.write_text(
"# 기준\n\n" + "\n\n".join(baseline_lines) + "\n",
encoding="utf-8",
)
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += "\n" + "\n\n".join(final_lines) + "\n"
_, report = lint_custom(tmp_path, document, extra=("--baseline", baseline))
assert report["fidelity"]["by_type"]["number_unit"]["missing"] == expected_missing
fidelity_rule_ids = {
item["rule_id"] for item in report["findings"] if item["rule_id"] == "DOC-F004"
}
assert ("DOC-F004" in fidelity_rule_ids) is bool(expected_missing)
def test_term_can_be_introduced_in_h2_heading_with_definition_below(
tmp_path: Path,
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document = document.replace("다음 [원리](#원리)는", "[다음 절](#원리-멱등성)은")
document = document.replace("## 원리\n", "## 원리: 멱등성\n")
document = document.replace(
"같은 요청을 여러 번 처리해도 결과가 한 번 처리한 것과 같게 되는 성질을 멱등성이라고 한다.",
"같은 요청을 여러 번 처리해도 결과가 한 번 처리한 것과 같게 되는 성질이다.",
)
logic = read_json(FIXTURES / "good" / "logic-map.json")
logic["sections"][1]["heading"] = "원리: 멱등성"
ledger = read_json(FIXTURES / "good" / "term-ledger.json")
ledger["terms"][0]["first_use"] = "원리: 멱등성"
result, report = lint_custom(tmp_path, document, logic=logic, ledger=ledger)
assert result.returncode == 0, report["findings"]
def test_code_styled_term_can_be_a_valid_first_use(tmp_path: Path) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
original = (
"데이터가 자동으로 사라지기까지의 시간인 유효 기간(time to live, TTL)을 "
"함께 정하면 오래된 값이 남는 시간을 제한할 수 있다."
)
styled = original.replace("유효 기간", "`유효 기간`")
document = document.replace(original, styled)
ledger = read_json(FIXTURES / "good" / "term-ledger.json")
ledger["terms"][1]["first_use"] = styled
result, report = lint_custom(tmp_path, document, ledger=ledger)
assert result.returncode == 0, report["findings"]
@pytest.mark.parametrize("kind", ["cross_term_name", "wrong_first_section"])
def test_term_ledger_rejects_ambiguous_ownership_and_section_binding(
tmp_path: Path, kind: str
) -> None:
ledger = read_json(FIXTURES / "good" / "term-ledger.json")
if kind == "cross_term_name":
ledger["terms"][1]["aliases"].append("멱등성")
else:
ledger["terms"][0]["first_section"] = "limits"
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
result, report = lint_custom(tmp_path, document, ledger=ledger)
assert result.returncode == 2
assert report["verdict"] == "input_error"
def test_visible_bracket_claim_markers_are_usable_without_term_noise(
tmp_path: Path,
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document = document.replace("<!-- claim:C1 -->", "[근거: C1]")
document = document.replace("<!-- claim:C2 -->", "[가정: C2]")
result, report = lint_custom(tmp_path, document)
assert result.returncode == 0, report["findings"]
@pytest.mark.parametrize("marker", ["<!-- claim:C999 -->", "<!-- claim:C1 -->"])
def test_unknown_or_cross_section_claim_marker_is_rejected(
tmp_path: Path, marker: str
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += f"\n{marker}\n"
result, report = lint_custom(tmp_path, document)
assert result.returncode == 1
assert "DOC-L003" in {finding["rule_id"] for finding in report["findings"]}
def test_nonroot_logic_section_cannot_be_orphaned(tmp_path: Path) -> None:
logic = read_json(FIXTURES / "good" / "logic-map.json")
logic["sections"][1]["depends_on"] = []
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
result, report = lint_custom(tmp_path, document, logic=logic)
assert result.returncode == 1
assert "DOC-L004" in {finding["rule_id"] for finding in report["findings"]}
def test_must_explain_cannot_be_discharged_as_assumed_known(tmp_path: Path) -> None:
reader = read_json(FIXTURES / "good" / "reader-contract.json")
reader["assumed_known"].append("멱등성")
ledger = read_json(FIXTURES / "good" / "term-ledger.json")
ledger["assumed_known"].append("멱등성")
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
result, report = lint_custom(tmp_path, document, ledger=ledger, reader=reader)
assert result.returncode == 2
assert report["verdict"] == "input_error"
def test_large_utf8_source_forces_auto_deep_and_records_all_input_metrics(
tmp_path: Path,
) -> None:
brief = tmp_path / "brief.md"
source = tmp_path / "large-source.md"
brief.write_text("짧은 요청\n", encoding="utf-8")
source.write_text("# 근거\n\n" + ("가" * 13000), encoding="utf-8")
result = run_cli(
"init_run.py",
"--brief",
brief,
"--source",
source,
"--kind",
"explanation",
"--route",
"auto",
"--workspace",
tmp_path / "runs",
"--date",
"2026-07-23",
)
assert result.returncode == 0, result.stderr
manifest = read_json(Path(result.stdout.split("\t", 1)[0]) / "00_run.json")
assert manifest["route_hint"] == "deep"
assert manifest["route_metrics"]["total_chars"] > 12000
assert manifest["route_metrics"]["total_headings"] == 1
def test_stale_upstream_hash_invalidates_both_review_summaries(tmp_path: Path) -> None:
run_dir = make_standard_complete(tmp_path)
evidence = read_json(run_dir / "03_evidence_map.json")
evidence["claims"][0]["statement"] += " 변경"
write_json(run_dir / "03_evidence_map.json", evidence)
result = run_cli("verify_run.py", "--run-dir", run_dir)
assert result.returncode == 1
report = read_json(run_dir / "09_final_report.json")
assert report["summary"]["reviews"] == []
assert report["document_verdict"] == "not_evaluated"
assert any(
check["id"].startswith("review-inputs-") and check["status"] == "fail"
for check in report["checks"]
)
def test_requested_audience_must_match_reader_contract(tmp_path: Path) -> None:
run_dir = init_run(tmp_path, route="light", audience="완전 초보 운영자")
install_good_contracts(run_dir)
result = run_cli(
"update_run.py",
"--run-dir",
run_dir,
"--status",
"planned",
"--reason",
"audience contract checkpoint",
)
assert result.returncode == 2
assert "audience" in result.stderr
assert read_json(run_dir / "00_run.json")["status"] == "initialized"
def test_pinned_runtime_contract_hash_cannot_be_forged(tmp_path: Path) -> None:
run_dir = make_light_complete(tmp_path)
manifest = read_json(run_dir / "00_run.json")
manifest["contract_sha256"] = "0" * 64
write_json(run_dir / "00_run.json", manifest)
result = run_cli("verify_run.py", "--run-dir", run_dir)
assert result.returncode == 1
report = read_json(run_dir / "09_final_report.json")
assert report["summary"]["lint"] is None
assert any(
check["id"] == "contract-sha256" and check["status"] == "fail"
for check in report["checks"]
)
def test_light_reviewed_history_requires_current_two_pass_reviews(tmp_path: Path) -> None:
run_dir = make_light_complete(tmp_path)
manifest_path = run_dir / "00_run.json"
manifest = read_json(manifest_path)
final_entry = manifest["history"][-1]
final_entry["from"] = "reviewed"
manifest["history"].insert(
-1,
{
"at": final_entry["at"],
"from": "drafted",
"to": "reviewed",
"reason": "forged reviewed checkpoint",
"error": None,
},
)
write_json(manifest_path, manifest)
result = run_cli("verify_run.py", "--run-dir", run_dir)
assert result.returncode == 1
assert any(
check["id"] == "review-stage-consistency" and check["status"] == "fail"
for check in read_json(run_dir / "09_final_report.json")["checks"]
)
def test_final_semantic_change_after_reviews_is_rejected(tmp_path: Path) -> None:
run_dir = init_run(tmp_path, route="standard")
install_good_contracts(run_dir)
write_evidence(run_dir)
write_reviews(run_dir)
final_text = (run_dir / "07_draft.md").read_text(encoding="utf-8").replace(
"증명하지는 않는다", "증명한다"
)
(run_dir / "final.md").write_text(final_text, encoding="utf-8")
assert lint_run(run_dir) == 1
assert "FNL-001" in {
finding["rule_id"]
for finding in read_json(run_dir / "08_lint.json")["findings"]
}
@pytest.mark.parametrize(
("mode", "route", "draft_baseline", "original_baseline", "fail_on_warning"),
[
("write", "light", False, False, False),
("revise", "light", True, False, False),
("write", "deep", True, False, False),
],
)
def test_final_checkpoint_rejects_wrong_lint_invocation_for_mode_or_route(
tmp_path: Path,
mode: str,
route: str,
draft_baseline: bool,
original_baseline: bool,
fail_on_warning: bool,
) -> None:
run_dir = init_run(
tmp_path,
route=route,
mode=mode,
with_draft=mode == "revise",
)
install_good_contracts(run_dir)
if route != "light":
write_evidence(run_dir)
write_reviews(run_dir)
shutil.copyfile(FIXTURES / "good" / "document.md", run_dir / "final.md")
assert lint_run(
run_dir,
draft_baseline=draft_baseline,
original_baseline=original_baseline,
fail_on_warning=fail_on_warning,
) == 0
statuses = ["planned", "drafted"]
if route != "light":
statuses = ["evidence_ready", "planned", "drafted", "reviewed"]
for status in statuses:
transition(run_dir, status)
result = run_cli(
"update_run.py",
"--run-dir",
run_dir,
"--status",
"finalized",
"--reason",
"invalid lint invocation checkpoint",
)
assert result.returncode == 2
assert "lint" in result.stderr or "deep route" in result.stderr
def test_review_checkpoint_rejects_draft_baseline(tmp_path: Path) -> None:
run_dir = init_run(tmp_path, route="light", mode="review", with_draft=True)
install_good_contracts(run_dir)
write_reviews(run_dir)
assert lint_run(run_dir, document="07_draft.md", draft_baseline=True) == 0
transition(run_dir, "planned")
result = run_cli(
"update_run.py",
"--run-dir",
run_dir,
"--status",
"reviewed",
"--reason",
"invalid review lint invocation checkpoint",
)
assert result.returncode == 2
assert "draft_baseline" in result.stderr
def test_relative_draft_baseline_is_resolved_and_verifies(tmp_path: Path) -> None:
run_dir = init_run(tmp_path, route="light")
install_good_contracts(run_dir)
shutil.copyfile(FIXTURES / "good" / "document.md", run_dir / "final.md")
result = run_cli(
"lint_document.py",
"--document",
"final.md",
"--logic-map",
"04_logic_map.json",
"--term-ledger",
"05_term_ledger.json",
"--reader-contract",
"02_reader_contract.json",
"--draft-baseline",
"07_draft.md",
"--output",
"08_lint.json",
cwd=run_dir,
)
assert result.returncode == 0, result.stderr
assert Path(
read_json(run_dir / "08_lint.json")["fidelity"]["draft_baseline"]["path"]
).is_absolute()
for status in ("planned", "drafted", "finalized"):
transition(run_dir, status)
assert run_cli("verify_run.py", "--run-dir", run_dir).returncode == 0
def test_crlf_contract_and_document_hashes_verify_as_raw_bytes(tmp_path: Path) -> None:
run_dir = init_run(tmp_path, route="light")
install_good_contracts(run_dir)
for name in ("02_reader_contract.json", "04_logic_map.json", "05_term_ledger.json"):
path = run_dir / name
path.write_bytes(path.read_bytes().replace(b"\n", b"\r\n"))
draft = run_dir / "07_draft.md"
draft.write_bytes(draft.read_bytes().replace(b"\n", b"\r\n"))
shutil.copyfile(draft, run_dir / "final.md")
assert lint_run(run_dir) == 0
for status in ("planned", "drafted", "finalized"):
transition(run_dir, status)
assert run_cli("verify_run.py", "--run-dir", run_dir).returncode == 0
def test_evidence_cycle_is_rejected_at_checkpoint(tmp_path: Path) -> None:
run_dir = init_run(tmp_path, route="standard")
install_good_contracts(run_dir)
write_evidence(run_dir)
evidence = read_json(run_dir / "03_evidence_map.json")
for claim, premise in zip(evidence["claims"], ("C2", "C1"), strict=True):
claim["status"] = "derived"
claim["source_ids"] = []
claim["source_locations"] = []
claim["premise_ids"] = [premise]
claim.pop("label", None)
write_json(run_dir / "03_evidence_map.json", evidence)
result = run_cli(
"update_run.py",
"--run-dir",
run_dir,
"--status",
"evidence_ready",
"--reason",
"invalid evidence cycle checkpoint",
)
assert result.returncode == 2
assert "cycle" in result.stderr
@pytest.mark.parametrize(
"defect",
["blank_boundary", "missing_recommendation_label", "missing_source_locator"],
)
def test_evidence_checkpoint_requires_semantic_strings_and_labels(
tmp_path: Path, defect: str
) -> None:
run_dir = init_run(tmp_path, route="standard")
install_good_contracts(run_dir)
write_evidence(run_dir)
evidence = read_json(run_dir / "03_evidence_map.json")
if defect == "blank_boundary":
evidence["claims"][0]["does_not_support"] = [" "]
elif defect == "missing_recommendation_label":
evidence["claims"][1]["load_bearing"] = False
evidence["claims"][1].pop("label")
else:
evidence["claims"][0].pop("source_locations")
write_json(run_dir / "03_evidence_map.json", evidence)
result = run_cli(
"update_run.py",
"--run-dir",
run_dir,
"--status",
"evidence_ready",
"--reason",
"invalid evidence schema checkpoint",
)
assert result.returncode == 2
assert "03_evidence_map.json" in result.stderr
def test_unknown_run_manifest_field_is_rejected(tmp_path: Path) -> None:
run_dir = make_light_complete(tmp_path)
manifest = read_json(run_dir / "00_run.json")
manifest["unsupported_output_request"] = ["pdf"]
write_json(run_dir / "00_run.json", manifest)
result = run_cli("verify_run.py", "--run-dir", run_dir)
assert result.returncode == 2
assert read_json(run_dir / "09_final_report.json")["verdict"] == "input_error"
def test_review_finding_reader_facing_fields_cannot_be_whitespace(tmp_path: Path) -> None:
run_dir = make_standard_complete(tmp_path)
review = read_json(run_dir / "08_logic_review.json")
review["findings"] = [
{
"id": "R-SPACE",
"severity": "low",
"location": " ",
"reader_impact": "읽기 흐름이 약하다.",
"suggestion": "연결 문장을 보완한다.",
}
]
write_json(run_dir / "08_logic_review.json", review)
result = run_cli("verify_run.py", "--run-dir", run_dir)
assert result.returncode == 1
assert any(
check["id"] == "schema-08_logic_review.json" and check["status"] == "fail"
for check in read_json(run_dir / "09_final_report.json")["checks"]
)
def test_review_mode_rejects_dangling_symlink_in_omitted_final_slot(
tmp_path: Path,
) -> None:
run_dir = init_run(tmp_path, route="light", mode="review", with_draft=True)
install_good_contracts(run_dir)
write_reviews(run_dir)
assert lint_run(run_dir, document="07_draft.md", draft_baseline=False) == 0
for status in ("planned", "reviewed"):
transition(run_dir, status)
(run_dir / "final.md").symlink_to("missing-final-target.md")
result = run_cli("verify_run.py", "--run-dir", run_dir)
assert result.returncode == 1
failed = {
check["id"]
for check in read_json(run_dir / "09_final_report.json")["checks"]
if check["status"] == "fail"
}
assert "review-no-final" in failed
assert "omissions-valid" in failed
assert "artifact-final.md" in failed
def test_thematic_break_and_blockquote_start_separate_prose_budgets(
tmp_path: Path,
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
four_sentences = "첫 문장이다. 둘째 문장이다. 셋째 문장이다. 넷째 문장이다."
document += f"\n{four_sentences}\n***\n{four_sentences}\n"
document += f"\n{four_sentences}\n> {four_sentences}\n"
result, report = lint_custom(tmp_path, document)
assert result.returncode == 0, report["findings"]
assert "DOC-P002" not in {item["rule_id"] for item in report["findings"]}
def test_reference_metadata_is_not_reader_visible_logic_or_placeholder(
tmp_path: Path,
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
core = read_json(FIXTURES / "good" / "logic-map.json")["core_claim"]
document = document.replace(core, "앞에서는 문제 범위만 먼저 알린다.", 1)
document += f'\n[{core}]: /ignored "TODO title"\n'
result, report = lint_custom(tmp_path, document)
assert result.returncode == 1
rule_ids = {item["rule_id"] for item in report["findings"]}
assert "DOC-L002" in rule_ids
assert "DOC-M001" not in rule_ids
def test_reference_style_internal_link_checks_resolved_destination(
tmp_path: Path,
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += "\n[깨진 절][missing]\n\n[missing]: #does-not-exist\n"
result, report = lint_custom(tmp_path, document)
assert result.returncode == 1
assert "DOC-M003" in {item["rule_id"] for item in report["findings"]}
@pytest.mark.parametrize(
"anchor",
['<div id="details"></div>', "<a id=details></a>"],
)
def test_internal_link_accepts_html_id_on_any_element_and_unquoted_value(
tmp_path: Path,
anchor: str,
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += f"\n[자세히](#details)\n\n{anchor}\n"
result, report = lint_custom(tmp_path, document)
assert result.returncode == 0, report["findings"]
def test_internal_anchor_case_is_exact_and_escaped_link_is_literal(
tmp_path: Path,
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += "\n<div id=details></div>\n\n[대문자](#DETAILS)\n"
document += "\\[리터럴](#does-not-exist)\n"
result, report = lint_custom(tmp_path, document)
assert result.returncode == 1
broken = [item for item in report["findings"] if item["rule_id"] == "DOC-M003"]
assert [item["context"] for item in broken] == ["DETAILS"]
def test_comment_token_inside_html_attribute_or_link_destination_is_metadata(
tmp_path: Path,
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += (
'\n<span title="<!--">보이는 설명</span>\n'
"[외부 링크](https://example.test/<!--/path)\n"
)
result, report = lint_custom(tmp_path, document)
assert result.returncode == 0, report["findings"]
assert "DOC-M004" not in {item["rule_id"] for item in report["findings"]}
@pytest.mark.parametrize(
"container",
["> ```\n> code\n", "- ```\n code\n"],
)
def test_container_fence_without_matching_closer_is_a_hard_error(
tmp_path: Path,
container: str,
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += "\n" + container
result, report = lint_custom(tmp_path, document)
assert result.returncode == 1
assert "DOC-M002" in {item["rule_id"] for item in report["findings"]}
def test_core_claim_must_appear_in_configured_front_paragraphs(tmp_path: Path) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
core = read_json(FIXTURES / "good" / "logic-map.json")["core_claim"]
document = document.replace(core, "이 문서는 문제를 순서대로 살펴본다.", 1)
document += f"\n\n{core}\n"
result, report = lint_custom(tmp_path, document)
assert result.returncode == 1
assert "DOC-L002" in {item["rule_id"] for item in report["findings"]}
@pytest.mark.parametrize(
"candidate",
[
"TRANSACTION_MANAGER",
"SPRING_APPLICATION_NAME",
"VERYLONGACRONYM",
"idempotency",
"backpressure",
"throughput",
"sharding",
],
)
def test_unregistered_uppercase_and_lowercase_english_jargon_is_blocked(
tmp_path: Path,
candidate: str,
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += f"\n등록하지 않은 {candidate} 후보를 쓴다.\n"
ledger = read_json(FIXTURES / "good" / "term-ledger.json")
if candidate == "idempotency":
ledger["terms"][0].pop("english")
result, report = lint_custom(tmp_path, document, ledger=ledger)
assert result.returncode == 1
contexts = {
item["context"]
for item in report["findings"]
if item["rule_id"] == "DOC-T007"
}
assert candidate in contexts
def test_ordinary_lowercase_english_prose_is_not_blanket_flagged(
tmp_path: Path,
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += "\nA simple system works with clear words.\n"
result, report = lint_custom(tmp_path, document)
assert result.returncode == 0, report["findings"]
assert "DOC-T007" not in {item["rule_id"] for item in report["findings"]}
@pytest.mark.parametrize("boundary", ["***", "---"])
def test_inline_code_delimiters_do_not_cross_markdown_block_boundaries(
tmp_path: Path,
boundary: str,
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += f"\n문단 `open\n{boundary}\nTODO`\n"
result, report = lint_custom(tmp_path, document)
assert result.returncode == 1
assert "DOC-M001" in {item["rule_id"] for item in report["findings"]}
def test_type7_html_tag_cannot_hide_heading_when_paragraph_is_open(
tmp_path: Path,
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
before = "다음 [원리](#원리)는 중복 계산과 오래된 데이터라는 두 문제를 나눠 다룬다.\n\n## 원리"
after = "다음 [원리](#원리)는 중복 계산과 오래된 데이터라는 두 문제를 나눠 다룬다.\n<span>\n</span>\n## 원리"
document = document.replace(before, after, 1)
result, report = lint_custom(tmp_path, document)
assert result.returncode == 0, report["findings"]
def test_reference_definition_cannot_interrupt_an_open_paragraph(
tmp_path: Path,
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += "\n보이는 문장\n[ref]: TODO\n"
result, report = lint_custom(tmp_path, document)
assert result.returncode == 1
assert "DOC-M001" in {item["rule_id"] for item in report["findings"]}
@pytest.mark.parametrize("definition", ["> [ref]: #does-not-exist", "- [ref]: #does-not-exist"])
def test_container_reference_definition_resolves_internal_link(
tmp_path: Path,
definition: str,
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += f"\n{definition}\n\n[깨진 절][ref]\n"
result, report = lint_custom(tmp_path, document)
assert result.returncode == 1
assert "DOC-M003" in {item["rule_id"] for item in report["findings"]}
def test_claim_marker_inside_html_attribute_cannot_satisfy_evidence_link(
tmp_path: Path,
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document = document.replace(
"<!-- claim:C1 -->",
'<span title="<!-- claim:C1 -->"></span>',
)
result, report = lint_custom(tmp_path, document)
assert result.returncode == 1
assert any(
item["rule_id"] == "DOC-L003" and "C1" in item["message"]
for item in report["findings"]
)
def test_raw_html_internal_href_is_checked(tmp_path: Path) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += '\n<a href="#does-not-exist">이동</a>\n'
result, report = lint_custom(tmp_path, document)
assert result.returncode == 1
assert "DOC-M003" in {item["rule_id"] for item in report["findings"]}
def test_hex_binary_and_scientific_parameters_are_fidelity_protected(
tmp_path: Path,
) -> None:
baseline = tmp_path / "baseline.md"
baseline.write_text(
"# 기준\n\nmask 0xFF, flags 0b1010, delay 1e-6 s.\n",
encoding="utf-8",
)
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += "\nmask 0x00, flags 0b0000, delay 9e-6 s.\n"
result, report = lint_custom(
tmp_path,
document,
extra=("--baseline", baseline),
)
assert result.returncode == 1
rule_ids = {item["rule_id"] for item in report["findings"]}
assert "DOC-F004" in rule_ids
assert report["fidelity"]["by_type"]["number"]["missing"] == 2
assert report["fidelity"]["by_type"]["number_unit"]["missing"] == 1
def test_punctuation_bearing_allowlist_entry_suppresses_internal_candidate(
tmp_path: Path,
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += "\nUTF-8 문서다.\n"
result, report = lint_custom(tmp_path, document)
assert result.returncode == 0, report["findings"]
assert "DOC-T007" not in {item["rule_id"] for item in report["findings"]}
def test_core_claim_inside_raw_pre_block_is_not_explanatory_prose(
tmp_path: Path,
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
core = read_json(FIXTURES / "good" / "logic-map.json")["core_claim"]
document = document.replace(core, f'<pre data-note="code">\n{core}\n</pre>', 1)
result, report = lint_custom(tmp_path, document)
assert result.returncode == 1
assert "DOC-L002" in {item["rule_id"] for item in report["findings"]}
def test_term_first_use_inside_inline_code_is_not_an_explanation(
tmp_path: Path,
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
first_use = read_json(FIXTURES / "good" / "term-ledger.json")["terms"][0][
"first_use"
]
document = document.replace(first_use, f"`{first_use}`", 1)
result, report = lint_custom(tmp_path, document)
assert result.returncode == 1
assert "DOC-T001" in {item["rule_id"] for item in report["findings"]}
def test_reference_destination_on_next_indented_line_is_resolved(
tmp_path: Path,
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += "\n[깨진 절]:\n #없는절\n\n[이동][깨진 절]\n"
result, report = lint_custom(tmp_path, document)
assert result.returncode == 1
assert "DOC-M003" in {item["rule_id"] for item in report["findings"]}
def test_four_space_indented_setext_underlines_are_not_headings(
tmp_path: Path,
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document = document.replace(
"# 캐시 실패를 줄이는 설명", "캐시 실패를 줄이는 설명\n ===="
)
for heading in ("문제", "원리", "검증과 한계"):
document = document.replace(f"## {heading}", f"{heading}\n ----")
result, report = lint_custom(tmp_path, document)
assert result.returncode == 1
rule_ids = {item["rule_id"] for item in report["findings"]}
assert "DOC-H002" in rule_ids
assert "DOC-L001" in rule_ids
def test_core_claim_inside_blockquoted_pre_block_is_not_prose(
tmp_path: Path,
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
core = read_json(FIXTURES / "good" / "logic-map.json")["core_claim"]
document = document.replace(core, f"> <pre>\n> {core}\n> </pre>", 1)
result, report = lint_custom(tmp_path, document)
assert result.returncode == 1
assert "DOC-L002" in {item["rule_id"] for item in report["findings"]}
def test_markdown_image_destination_is_fidelity_protected_and_metadata_masked(
tmp_path: Path,
) -> None:
baseline = tmp_path / "baseline.md"
baseline.write_text("# 기준\n\n![구조도](자산/현재.svg)\n", encoding="utf-8")
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += "\n![구조도](자산/누락.svg)\n"
result, report = lint_custom(
tmp_path,
document,
extra=("--baseline", baseline),
)
assert result.returncode == 1
assert "DOC-F003" in {item["rule_id"] for item in report["findings"]}
assert report["fidelity"]["by_type"]["link_destination"]["missing"] == 1
def test_placeholder_inside_raw_pre_code_is_ignored_like_fenced_code(
tmp_path: Path,
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += '\n<pre><code>\nTODO = "literal example"\n</code></pre>\n'
result, report = lint_custom(tmp_path, document)
assert result.returncode == 0, report["findings"]
assert "DOC-M001" not in {item["rule_id"] for item in report["findings"]}
def test_email_domain_is_not_an_unregistered_dotted_identifier(
tmp_path: Path,
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += "\n문의: ops@example.com\n"
result, report = lint_custom(tmp_path, document)
assert result.returncode == 0, report["findings"]
assert "DOC-T007" not in {item["rule_id"] for item in report["findings"]}
def test_markdown_syntax_inside_raw_div_remains_reader_visible_literal_text(
tmp_path: Path,
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += "\n<div>\n[x](TODO)\n</div>\n"
result, report = lint_custom(tmp_path, document)
assert result.returncode == 1
assert "DOC-M001" in {item["rule_id"] for item in report["findings"]}
def test_four_space_indented_backticks_do_not_hide_following_reader_prose(
tmp_path: Path,
) -> None:
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
document += "\n ```\nTODO reader prose\n ```\n"
result, report = lint_custom(tmp_path, document)
assert result.returncode == 1
assert "DOC-M001" in {item["rule_id"] for item in report["findings"]}