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("", "[근거: C1]") document = document.replace("", "[가정: C2]") document = document.replace("\n\n캐시 장애", "\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나중 `멱등성`을 쓴다.\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" "\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( "", "``", 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("## 문제", "## 문제 `", '' ).replace("", '') 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운영 원칙은 "첫 줄\n둘째 줄"이다.\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("", "[근거: C1]") document = document.replace("", "[가정: C2]") result, report = lint_custom(tmp_path, document) assert result.returncode == 0, report["findings"] @pytest.mark.parametrize("marker", ["", ""]) 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", ['
', ""], ) 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
\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보이는 설명\n' "[외부 링크](https://example.test/", '', ) 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이동\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'
\n{core}\n
', 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">
\n> {core}\n> 
", 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
\nTODO = "literal example"\n
\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
\n[x](TODO)\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"]} 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"]}