from __future__ import annotations import unittest from pathlib import Path from claridoc.lint import lint_document from claridoc.models import Brief, ProviderSpec, Severity, SourcePack from claridoc.prompts import drafting_prompt from claridoc.providers.base import ProviderRequest from claridoc.providers.mock import MockProvider from claridoc.structures import create_outline from tests.helpers import brief_dict, make_brief, make_sources class LintTests(unittest.TestCase): def test_mock_document_meets_structural_gate(self) -> None: brief = make_brief() sources = make_sources() outline = create_outline(brief, sources) provider = MockProvider(ProviderSpec(provider="mock")) response = provider.generate(ProviderRequest("draft", drafting_prompt(brief, outline, sources), Path.cwd())) report = lint_document(response.text, brief, outline, sources) material = [issue for issue in report.issues if issue.severity in {Severity.BLOCKER, Severity.ERROR}] self.assertEqual(material, []) self.assertGreaterEqual(report.score, 75) def test_unclosed_fence_and_destructive_command_are_blockers(self) -> None: brief = make_brief() sources = make_sources() outline = create_outline(brief, sources) text = "# Wrong\n\n```bash\nrm -rf /tmp/example\n" report = lint_document(text, brief, outline, sources) codes = {issue.code for issue in report.issues if issue.severity == Severity.BLOCKER} self.assertIn("MD001", codes) self.assertIn("SAFE001", codes) def test_unknown_source_marker_is_error(self) -> None: brief = make_brief() sources = make_sources() outline = create_outline(brief, sources) provider = MockProvider(ProviderSpec(provider="mock")) text = provider.generate(ProviderRequest("draft", drafting_prompt(brief, outline, sources), Path.cwd())).text report = lint_document(text + "\n\nUnsupported marker [S404].\n", brief, outline, sources) self.assertIn("EVD001", {issue.code for issue in report.issues}) def test_non_s_prefixed_source_id_is_recognized(self) -> None: brief = make_brief() sources = SourcePack.from_dict({ "sources": [{ "id": "RFC9110", "title": "HTTP Semantics", "url": "https://example.com/rfc9110", "facts": ["The example fact is bounded."], }] }) outline = create_outline(brief, sources) provider = MockProvider(ProviderSpec(provider="mock")) text = provider.generate(ProviderRequest("draft", drafting_prompt(brief, outline, sources), Path.cwd())).text report = lint_document(text, brief, outline, sources) codes = {issue.code for issue in report.issues} self.assertNotIn("EVD001", codes) self.assertNotIn("EVD003", codes) def test_required_h2_must_appear_exactly_once(self) -> None: brief = make_brief() sources = make_sources() outline = create_outline(brief, sources) provider = MockProvider(ProviderSpec(provider="mock")) text = provider.generate(ProviderRequest("draft", drafting_prompt(brief, outline, sources), Path.cwd())).text text += f"\n## {outline.sections[0].title}\n\nDuplicate section.\n" report = lint_document(text, brief, outline, sources) self.assertIn("STR009", {issue.code for issue in report.issues if issue.severity == Severity.ERROR}) def test_destructive_command_requires_all_safety_controls(self) -> None: brief = make_brief() sources = make_sources() outline = create_outline(brief, sources) warning_only = "# A precise technical document\n\nWarning: this is destructive.\n\n```bash\nrm -rf /tmp/example\n```\n" report = lint_document(warning_only, brief, outline, sources) self.assertIn("SAFE001", {issue.code for issue in report.issues}) controlled = ( "# A precise technical document\n\n" "Warning: this removes the test directory. Create a backup checkpoint first. " "Expected result: the directory is absent; verify with a read-only listing. " "Rollback by restoring the backup.\n\n" "```bash\nrm -rf /tmp/example\n```\n" ) controlled_report = lint_document(controlled, brief, outline, sources) self.assertNotIn("SAFE001", {issue.code for issue in controlled_report.issues}) def test_reader_facing_meta_and_internal_markers_are_rejected(self) -> None: brief = make_brief() sources = make_sources() outline = create_outline(brief, sources) provider = MockProvider(ProviderSpec(provider="mock")) text = provider.generate(ProviderRequest("draft", drafting_prompt(brief, outline, sources), Path.cwd())).text text += "\n제공된 근거 팩은 다음 사실을 확인 대상으로 제시한다. [S1]\n" report = lint_document(text, brief, outline, sources) codes = {issue.code for issue in report.issues} self.assertIn("META001", codes) self.assertIn("EVD007", codes) def test_access_date_boilerplate_is_rejected(self) -> None: brief = make_brief() sources = make_sources() outline = create_outline(brief, sources) provider = MockProvider(ProviderSpec(provider="mock")) text = provider.generate(ProviderRequest("draft", drafting_prompt(brief, outline, sources), Path.cwd())).text text += "\nThe example is checked 2026-07-23 as of this document.\n" report = lint_document(text, brief, outline, sources) self.assertIn("DATE001", {issue.code for issue in report.issues}) def test_choice_without_reason_is_rejected(self) -> None: brief = make_brief() sources = make_sources() outline = create_outline(brief, sources) provider = MockProvider(ProviderSpec(provider="mock")) text = provider.generate(ProviderRequest("draft", drafting_prompt(brief, outline, sources), Path.cwd())).text text += "\nWe intentionally selected Framework X.\n" report = lint_document(text, brief, outline, sources) self.assertIn("RAT001", {issue.code for issue in report.issues}) def test_formulaic_korean_ordinal_paragraphs_are_flagged(self) -> None: data = brief_dict() data["title"] = "기술적 선택을 설명하는 글" data["language"] = "ko-KR" data["reader_goal"] = "안전한 구현 방식을 선택한다" data["core_message"] = "기술 선택은 문제와 비용을 함께 설명해야 한다." data["constraints"]["style_profile"] = "woowahan_tech_blog_ko" brief = Brief.from_dict(data) sources = make_sources() outline = create_outline(brief, sources) provider = MockProvider(ProviderSpec(provider="mock")) text = provider.generate(ProviderRequest("draft", drafting_prompt(brief, outline, sources), Path.cwd())).text text += ( "\n첫 번째 제약은 모듈 소유권이 나뉜다는 점이다.\n\n" "두 번째 제약은 배포 시간을 바꿀 수 없다는 점이다.\n\n" "세 번째 제약은 운영 지표가 부족하다는 점이다.\n" ) report = lint_document(text, brief, outline, sources) self.assertIn("STYLE001", {issue.code for issue in report.issues}) self.assertEqual(report.metrics["formulaic_ordinal_opening_count"], 3) def test_numbered_procedure_is_not_formulaic_ordinal_prose(self) -> None: data = brief_dict() data["title"] = "기술적 선택을 설명하는 글" data["language"] = "ko-KR" data["reader_goal"] = "안전한 구현 방식을 선택한다" data["core_message"] = "기술 선택은 문제와 비용을 함께 설명해야 한다." data["constraints"]["style_profile"] = "woowahan_tech_blog_ko" brief = Brief.from_dict(data) sources = make_sources() outline = create_outline(brief, sources) provider = MockProvider(ProviderSpec(provider="mock")) text = provider.generate(ProviderRequest("draft", drafting_prompt(brief, outline, sources), Path.cwd())).text text += "\n1. 현재 상태를 확인한다.\n2. 변경한다.\n3. 결과를 검증한다.\n" report = lint_document(text, brief, outline, sources) self.assertNotIn("STYLE001", {issue.code for issue in report.issues}) def test_golden_application_core_example_has_no_material_lint_issue(self) -> None: root = Path(__file__).resolve().parents[1] from claridoc.corpus import build_query_from_brief, collect_sources from claridoc.utils import read_json brief = Brief.from_dict(read_json(root / "examples/briefs/application-core-spring-di-blog.json")) sources = collect_sources( root / "examples/corpus/llm-wiki-mini", build_query_from_brief(brief), ) outline = create_outline(brief, sources) text = (root / "examples/golden/application-core-spring-di-boundary.md").read_text(encoding="utf-8") report = lint_document(text, brief, outline, sources) material = [issue for issue in report.issues if issue.severity in {Severity.BLOCKER, Severity.ERROR}] self.assertEqual(material, []) self.assertNotIn("[S1]", text) self.assertNotIn("제공된 근거 팩", text) self.assertNotIn("2026-07-23 기준", text) self.assertNotIn("STYLE001", {issue.code for issue in report.issues}) self.assertNotIn("첫 번째 제약은", text) self.assertIn("수동 선언하는 반복", text) if __name__ == "__main__": unittest.main()