252 lines
8.1 KiB
Python
252 lines
8.1 KiB
Python
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<!-- TODO -->\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 = "<div>\n[id]: #ghost\n</div>\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]", "<!-- claim: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("<!-- claim:C1 -->", "")
|
|
document += f"\n<pre>\n{raw_marker}\n</pre>\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<div>\n`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_tag_shaped_script_data_cannot_supply_explicit_anchor(tmp_path: Path) -> None:
|
|
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
|
|
document += (
|
|
"\n<script>\nconst x = '<a id=\"ghost\">';\n</script>\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"}
|
|
|