from __future__ import annotations import sys from pathlib import Path import pytest from helpers import FIXTURES, read_json, run_cli, write_json CANONICAL_SCRIPTS = ( Path(__file__).resolve().parents[1] / "skills" / "technical-doc-flow" / "scripts" ) sys.path.insert(0, str(CANONICAL_SCRIPTS)) from lint_document import ( # noqa: E402 markdown_inline_links, markdown_reference_links, parse_headings, reference_definitions, ) from markdown_structure import inline_code_spans # noqa: E402 def lint_custom( tmp_path: Path, document: str, *, logic: dict | None = None, ledger: dict | None = None, ): document_path = tmp_path / "document.md" logic_path = tmp_path / "logic.json" ledger_path = tmp_path / "ledger.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"), ) result = run_cli( "lint_document.py", "--document", document_path, "--logic-map", logic_path, "--term-ledger", ledger_path, "--reader-contract", FIXTURES / "good" / "reader-contract.json", "--output", output, ) return result, read_json(output) def test_multiline_labels_stop_at_markdown_block_boundaries() -> None: assert markdown_inline_links("[x\n## y](TODO)") == [] text = "[id]: #missing\n\n[x\n## y][id]\n" definitions = reference_definitions(text) assert definitions links = markdown_reference_links(text, definitions) assert all(link.start != text.index("[x") for link in links) @pytest.mark.parametrize("marker", ["01.", "001)"]) def test_leading_zero_one_list_marker_ends_multiline_inline_code(marker: str) -> None: assert inline_code_spans(f"text `a\n{marker} TODO\nc` tail") == [] def test_html_comment_block_ends_multiline_inline_code() -> None: assert inline_code_spans("text `a\n\nb` tail") == [] @pytest.mark.parametrize( "definition", [ '[id]: #missing "long\n title"', '[id]: #missing\n "long\n title"', '[id]:\n #missing "long\n title"', ], ) def test_multiline_reference_titles_are_fully_parsed(definition: str) -> None: text = f"{definition}\n\n[go][id]\n" definitions = reference_definitions(text) assert len(definitions) == 1 assert definitions[0].destination == "#missing" assert text[definitions[0].start : definitions[0].end].rstrip() == definition @pytest.mark.parametrize( "definition", ['[id]: <#missing>"title"', "[id]: #missing (a(b)"], ) def test_invalid_reference_title_grammar_does_not_create_definition( definition: str, ) -> None: assert reference_definitions(f"{definition}\n\n[go][id]\n") == [] def test_noninterrupting_ordered_marker_cannot_create_reference_definition() -> None: text = "paragraph\n2. [id]: #missing\n\n[go][id]\n" assert reference_definitions(text) == [] def test_reference_definition_inside_raw_html_does_not_resolve_link() -> None: text = "
\n[id]: #ghost\n
\n\n[go][id]\n" definitions = reference_definitions(text) assert definitions == [] assert markdown_reference_links(text, definitions) == [] def test_multiline_setext_heading_cannot_impersonate_logic_heading( tmp_path: Path, ) -> None: document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8") document = document.replace( "## 문제", "독자가 읽어야 할 흐름이다.\n문제\n---", ) result, report = lint_custom(tmp_path, document) assert result.returncode == 1 assert "DOC-L001" in {item["rule_id"] for item in report["findings"]} def test_noninterrupting_ordered_line_remains_part_of_setext_heading() -> None: headings = parse_headings("paragraph\n2. text\n---\n") assert [(heading.level, heading.text) for heading in headings] == [ (2, "paragraph\n2. text") ] @pytest.mark.parametrize( "wrapper", ["**{value}**", "[{value}](docs/x)", "`{value}`"], ) def test_core_claim_search_uses_reader_visible_formatting( tmp_path: Path, wrapper: str, ) -> None: document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8") logic = read_json(FIXTURES / "good" / "logic-map.json") core = logic["core_claim"] document = document.replace( core, core.replace("장애를", wrapper.format(value="장애를")), 1, ) result, report = lint_custom(tmp_path, document, logic=logic) assert result.returncode == 0, report["findings"] def test_core_claim_wholly_inside_inline_code_is_not_explanatory_prose( tmp_path: Path, ) -> None: document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8") logic = read_json(FIXTURES / "good" / "logic-map.json") core = logic["core_claim"] document = document.replace(core, f"`{core}`", 1) result, report = lint_custom(tmp_path, document, logic=logic) assert result.returncode == 1 assert "DOC-L002" in {item["rule_id"] for item in report["findings"]} @pytest.mark.parametrize("wrapper", ["**{value}**", "[{value}](docs/x)"]) def test_term_first_use_search_uses_reader_visible_formatting( tmp_path: Path, wrapper: str, ) -> None: document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8") ledger = read_json(FIXTURES / "good" / "term-ledger.json") first_use = ledger["terms"][0]["first_use"] document = document.replace( first_use, first_use.replace("한 번", wrapper.format(value="한 번")), 1, ) result, report = lint_custom(tmp_path, document, ledger=ledger) assert result.returncode == 0, report["findings"] @pytest.mark.parametrize( "raw_marker", ["[근거:C1]", ""], ) def test_raw_code_html_cannot_supply_claim_marker( tmp_path: Path, raw_marker: str, ) -> None: document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8") document = document.replace("", "") document += f"\n
\n{raw_marker}\n
\n" result, report = lint_custom(tmp_path, document) assert result.returncode == 1 assert "DOC-L003" in {item["rule_id"] for item in report["findings"]} def test_inline_markdown_inside_raw_div_remains_literal_reader_text( tmp_path: Path, ) -> None: document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8") document += "\n
\n`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_tag_shaped_script_data_cannot_supply_explicit_anchor(tmp_path: Path) -> None: document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8") document += ( "\n\n" "\n[이동](#ghost)\n" ) result, report = lint_custom(tmp_path, document) assert result.returncode == 1 assert any( item["rule_id"] == "DOC-M003" and item["context"] == "ghost" for item in report["findings"] ) def test_heading_slugs_preserve_escaped_underscores_and_resolve_references() -> None: headings = parse_headings( "## \\_foo\\_\n## foo\\_bar\n## [visible][id]\n\n[id]: /url\n" ) assert [heading.slug for heading in headings] == ["_foo_", "foo_bar", "visible"] @pytest.mark.parametrize( "table", [ "> 항목 | 설명\n> --- | ---\n> 하나 | {cell}\n", "- 항목 | 설명\n --- | ---\n 하나 | {cell}\n", ], ) def test_nested_gfm_tables_do_not_consume_prose_budgets( tmp_path: Path, table: str, ) -> None: document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8") document += "\n" + table.format(cell="긴 설명이다. " * 120) result, report = lint_custom(tmp_path, document) assert result.returncode == 0, report["findings"] assert not { item["rule_id"] for item in report["findings"] } & {"DOC-P001", "DOC-P002"}