371 lines
17 KiB
Python
371 lines
17 KiB
Python
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):
|
|
@staticmethod
|
|
def _korean_experience_brief(document_type: str = "technical_blog") -> Brief:
|
|
data = brief_dict(document_type)
|
|
data["title"] = "기술적 선택을 설명하는 글"
|
|
data["language"] = "ko-KR"
|
|
data["reader_goal"] = "안전한 구현 방식을 선택합니다"
|
|
data["core_message"] = "기술 선택은 문제와 비용을 함께 설명해야 합니다."
|
|
data["constraints"]["style_profile"] = "auto"
|
|
return Brief.from_dict(data)
|
|
|
|
@staticmethod
|
|
def _experience_document(
|
|
brief: Brief,
|
|
*,
|
|
marked_sections: set[int] | None = None,
|
|
body_by_section: dict[int, str] | None = None,
|
|
extra_by_section: dict[int, str] | None = None,
|
|
) -> tuple[str, object]:
|
|
sources = make_sources()
|
|
outline = create_outline(brief, sources)
|
|
marked_sections = (
|
|
set(range(len(outline.sections)))
|
|
if marked_sections is None
|
|
else marked_sections
|
|
)
|
|
body_by_section = body_by_section or {}
|
|
extra_by_section = extra_by_section or {}
|
|
lines = [f"# {brief.title}", ""]
|
|
for index, section in enumerate(outline.sections):
|
|
lines.extend([f"## {section.title}", ""])
|
|
if index in body_by_section:
|
|
lines.append(body_by_section[index])
|
|
else:
|
|
subject = "저는 " if index in marked_sections else ""
|
|
lines.append(
|
|
f"{subject}이 절의 입력과 실제 동작을 확인했습니다. "
|
|
"현재 구현은 명시된 경계를 사용합니다. "
|
|
"대안과 비용, 검증 범위도 함께 설명합니다."
|
|
)
|
|
if index in extra_by_section:
|
|
lines.extend(["", extra_by_section[index]])
|
|
lines.append("")
|
|
return "\n".join(lines), outline
|
|
|
|
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_korean_experience_contract_blocks_plain_form_endings(self) -> None:
|
|
brief = self._korean_experience_brief()
|
|
sources = make_sources()
|
|
text, outline = self._experience_document(
|
|
brief,
|
|
extra_by_section={0: "현재 구현은 이 값을 사용한다."},
|
|
)
|
|
|
|
report = lint_document(text, brief, outline, sources)
|
|
|
|
issues = [issue for issue in report.issues if issue.code == "STYLE002"]
|
|
self.assertEqual(len(issues), 1)
|
|
self.assertEqual(issues[0].severity, Severity.BLOCKER)
|
|
self.assertEqual(report.metrics["plain_form_ending_count"], 1)
|
|
|
|
def test_korean_experience_contract_blocks_unpunctuated_plain_ending(self) -> None:
|
|
brief = self._korean_experience_brief()
|
|
sources = make_sources()
|
|
text, outline = self._experience_document(
|
|
brief,
|
|
extra_by_section={0: "현재 구현은 이 값을 사용한다"},
|
|
)
|
|
|
|
report = lint_document(text, brief, outline, sources)
|
|
|
|
self.assertIn("STYLE002", {issue.code for issue in report.issues})
|
|
self.assertEqual(report.metrics["plain_form_ending_count"], 1)
|
|
|
|
def test_korean_experience_contract_blocks_emphasized_plain_ending(self) -> None:
|
|
brief = self._korean_experience_brief()
|
|
sources = make_sources()
|
|
text, outline = self._experience_document(
|
|
brief,
|
|
extra_by_section={0: "**현재 구현은 이 값을 사용한다.**"},
|
|
)
|
|
|
|
report = lint_document(text, brief, outline, sources)
|
|
|
|
self.assertIn("STYLE002", {issue.code for issue in report.issues})
|
|
self.assertEqual(report.metrics["plain_form_ending_count"], 1)
|
|
|
|
def test_korean_style_lint_exempts_non_reader_prose(self) -> None:
|
|
brief = self._korean_experience_brief()
|
|
sources = make_sources()
|
|
text, outline = self._experience_document(
|
|
brief,
|
|
extra_by_section={
|
|
0: (
|
|
"### 현재 구현은 사용한다.\n\n"
|
|
"> 원문 인용은 현재 구현을 사용한다.\n\n"
|
|
"항목 | 설명\n"
|
|
"--- | ---\n"
|
|
"현재 값 | 현재 구현은 사용한다.\n\n"
|
|
"\n\n"
|
|
" 명령 출력은 현재 구현을 사용한다.\n\n"
|
|
"`현재 구현은 사용한다.`\n\n"
|
|
"직접 기록에는 “현재 구현은 사용한다.”라고 적혀 있습니다.\n\n"
|
|
"```text\n"
|
|
"현재 구현은 사용한다.\n"
|
|
"```"
|
|
)
|
|
},
|
|
)
|
|
|
|
report = lint_document(text, brief, outline, sources)
|
|
|
|
self.assertNotIn("STYLE002", {issue.code for issue in report.issues})
|
|
self.assertEqual(report.metrics["plain_form_ending_count"], 0)
|
|
|
|
def test_korean_style_lint_requires_first_person_opening(self) -> None:
|
|
brief = self._korean_experience_brief()
|
|
sources = make_sources()
|
|
text, outline = self._experience_document(
|
|
brief,
|
|
marked_sections=set(range(1, 8)),
|
|
)
|
|
|
|
report = lint_document(text, brief, outline, sources)
|
|
|
|
issues = [issue for issue in report.issues if issue.code == "STYLE003"]
|
|
self.assertEqual(len(issues), 1)
|
|
self.assertEqual(issues[0].severity, Severity.BLOCKER)
|
|
self.assertFalse(report.metrics["opening_has_first_person"])
|
|
|
|
def test_korean_style_lint_requires_major_section_coverage(self) -> None:
|
|
brief = self._korean_experience_brief()
|
|
sources = make_sources()
|
|
text, outline = self._experience_document(
|
|
brief,
|
|
marked_sections={0},
|
|
)
|
|
|
|
report = lint_document(text, brief, outline, sources)
|
|
|
|
self.assertIn("STYLE003", {issue.code for issue in report.issues})
|
|
self.assertEqual(report.metrics["experience_section_count"], 8)
|
|
self.assertEqual(report.metrics["marked_experience_section_count"], 1)
|
|
self.assertEqual(report.metrics["experience_section_coverage"], 0.125)
|
|
|
|
def test_korean_style_lint_ignores_non_prose_sections(self) -> None:
|
|
brief = self._korean_experience_brief()
|
|
sources = make_sources()
|
|
text, outline = self._experience_document(
|
|
brief,
|
|
marked_sections={0, 1},
|
|
body_by_section={
|
|
4: "```text\n현재 구현은 사용한다.\n```",
|
|
5: "항목 | 값\n--- | ---\n구현 | 현재 값",
|
|
6: "",
|
|
7: "> 원문 인용만 남아 있습니다.",
|
|
},
|
|
)
|
|
|
|
report = lint_document(text, brief, outline, sources)
|
|
|
|
self.assertNotIn("STYLE003", {issue.code for issue in report.issues})
|
|
self.assertEqual(report.metrics["experience_section_count"], 4)
|
|
self.assertEqual(report.metrics["marked_experience_section_count"], 2)
|
|
self.assertEqual(report.metrics["experience_section_coverage"], 0.5)
|
|
|
|
def test_korean_style_lint_accepts_compliant_experience_prose(self) -> None:
|
|
brief = self._korean_experience_brief("readme")
|
|
sources = make_sources()
|
|
text, outline = self._experience_document(
|
|
brief,
|
|
marked_sections={0, 2, 4, 6},
|
|
)
|
|
|
|
report = lint_document(text, brief, outline, sources)
|
|
|
|
codes = {issue.code for issue in report.issues}
|
|
self.assertNotIn("STYLE002", codes)
|
|
self.assertNotIn("STYLE003", codes)
|
|
self.assertEqual(
|
|
report.metrics["style_contract"],
|
|
"korean_first_person_experience_v1",
|
|
)
|
|
self.assertTrue(report.metrics["opening_has_first_person"])
|
|
self.assertEqual(report.metrics["first_person_marker_count"], 4)
|
|
self.assertEqual(report.metrics["experience_section_coverage"], 0.5)
|
|
|
|
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()
|