797 lines
28 KiB
Python
797 lines
28 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sys
|
|
import time
|
|
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))
|
|
import lint_document as lint_document_module # noqa: E402
|
|
from lint_document import ( # noqa: E402
|
|
bounded_change_rate,
|
|
github_slug,
|
|
markdown_inline_links,
|
|
mask_markdown_metadata,
|
|
parse_headings,
|
|
protected_items,
|
|
)
|
|
|
|
|
|
def lint_fixture(tmp_path: Path, name: str, *extra: object):
|
|
base = FIXTURES / name
|
|
output = tmp_path / f"{name}-lint.json"
|
|
result = run_cli(
|
|
"lint_document.py",
|
|
"--document",
|
|
base / "document.md",
|
|
"--logic-map",
|
|
base / "logic-map.json",
|
|
"--term-ledger",
|
|
base / "term-ledger.json",
|
|
"--reader-contract",
|
|
base / "reader-contract.json",
|
|
"--output",
|
|
output,
|
|
*extra,
|
|
)
|
|
return result, read_json(output)
|
|
|
|
|
|
def lint_main_args(output: Path, *, document: Path | None = None) -> list[str]:
|
|
base = FIXTURES / "good"
|
|
return [
|
|
"--document",
|
|
str(document or base / "document.md"),
|
|
"--logic-map",
|
|
str(base / "logic-map.json"),
|
|
"--term-ledger",
|
|
str(base / "term-ledger.json"),
|
|
"--reader-contract",
|
|
str(base / "reader-contract.json"),
|
|
"--output",
|
|
str(output),
|
|
]
|
|
|
|
|
|
def test_good_golden_passes_without_findings(tmp_path: Path) -> None:
|
|
result, report = lint_fixture(tmp_path, "good")
|
|
assert result.returncode == 0, result.stderr
|
|
assert report["verdict"] == "pass"
|
|
assert report["findings"] == []
|
|
rules_path = CANONICAL_SCRIPTS.parent / "config" / "quality-rules.json"
|
|
assert report["rules_sha256"] == hashlib.sha256(
|
|
rules_path.read_bytes()
|
|
).hexdigest()
|
|
|
|
|
|
def test_bad_golden_covers_all_declared_failure_modes(tmp_path: Path) -> None:
|
|
result, report = lint_fixture(tmp_path, "bad")
|
|
expected = set(json.loads((FIXTURES / "bad" / "expected-rule-ids.json").read_text()))
|
|
actual = {finding["rule_id"] for finding in report["findings"]}
|
|
assert result.returncode == 1
|
|
assert expected <= actual
|
|
assert len(expected) >= 8
|
|
|
|
|
|
def test_malformed_alias_returns_input_error_report(tmp_path: Path) -> None:
|
|
ledger = read_json(FIXTURES / "good" / "term-ledger.json")
|
|
ledger["terms"][0]["aliases"] = [123]
|
|
ledger_path = tmp_path / "ledger.json"
|
|
output = tmp_path / "lint.json"
|
|
write_json(ledger_path, ledger)
|
|
result = run_cli(
|
|
"lint_document.py",
|
|
"--document",
|
|
FIXTURES / "good" / "document.md",
|
|
"--logic-map",
|
|
FIXTURES / "good" / "logic-map.json",
|
|
"--term-ledger",
|
|
ledger_path,
|
|
"--reader-contract",
|
|
FIXTURES / "good" / "reader-contract.json",
|
|
"--output",
|
|
output,
|
|
)
|
|
assert result.returncode == 2
|
|
assert read_json(output)["verdict"] == "input_error"
|
|
|
|
|
|
def test_schema_invalid_contract_cannot_receive_a_pass_report(tmp_path: Path) -> None:
|
|
reader = read_json(FIXTURES / "good" / "reader-contract.json")
|
|
reader["document_kind"] = "garbage"
|
|
reader_path = tmp_path / "reader-contract.json"
|
|
output = tmp_path / "lint.json"
|
|
write_json(reader_path, reader)
|
|
result = run_cli(
|
|
"lint_document.py",
|
|
"--document",
|
|
FIXTURES / "good" / "document.md",
|
|
"--logic-map",
|
|
FIXTURES / "good" / "logic-map.json",
|
|
"--term-ledger",
|
|
FIXTURES / "good" / "term-ledger.json",
|
|
"--reader-contract",
|
|
reader_path,
|
|
"--output",
|
|
output,
|
|
)
|
|
assert result.returncode == 2
|
|
assert read_json(output)["verdict"] == "input_error"
|
|
assert "schema 위반" in read_json(output)["findings"][0]["message"]
|
|
|
|
|
|
def test_fence_with_trailing_text_is_not_a_closer(tmp_path: Path) -> None:
|
|
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
|
|
insertion = "```text\n```anything\n# 코드 안 가짜 제목\n```\n\n"
|
|
document = document.replace("## 문제", insertion + "## 문제")
|
|
path = tmp_path / "document.md"
|
|
path.write_text(document, encoding="utf-8")
|
|
output = tmp_path / "lint.json"
|
|
result = run_cli(
|
|
"lint_document.py",
|
|
"--document",
|
|
path,
|
|
"--logic-map",
|
|
FIXTURES / "good" / "logic-map.json",
|
|
"--term-ledger",
|
|
FIXTURES / "good" / "term-ledger.json",
|
|
"--reader-contract",
|
|
FIXTURES / "good" / "reader-contract.json",
|
|
"--output",
|
|
output,
|
|
)
|
|
assert result.returncode == 0, read_json(output)["findings"]
|
|
|
|
|
|
def test_setext_heading_is_supported_and_comment_heading_is_ignored(tmp_path: Path) -> None:
|
|
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
|
|
document = document.replace(
|
|
"# 캐시 실패를 줄이는 설명",
|
|
"캐시 실패를 줄이는 설명\n========================",
|
|
)
|
|
document = document.replace("## 문제", "<!--\n# 주석 속 가짜 제목\n-->\n\n## 문제")
|
|
path = tmp_path / "document.md"
|
|
path.write_text(document, encoding="utf-8")
|
|
output = tmp_path / "lint.json"
|
|
result = run_cli(
|
|
"lint_document.py",
|
|
"--document",
|
|
path,
|
|
"--logic-map",
|
|
FIXTURES / "good" / "logic-map.json",
|
|
"--term-ledger",
|
|
FIXTURES / "good" / "term-ledger.json",
|
|
"--reader-contract",
|
|
FIXTURES / "good" / "reader-contract.json",
|
|
"--output",
|
|
output,
|
|
)
|
|
assert result.returncode == 0, read_json(output)["findings"]
|
|
|
|
|
|
def test_list_items_are_not_counted_as_one_oversized_sentence_paragraph(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
|
|
document += "\n" + "\n".join(
|
|
f"{index}. 독자가 확인할 항목입니다." for index in range(1, 9)
|
|
)
|
|
path = tmp_path / "document.md"
|
|
path.write_text(document, encoding="utf-8")
|
|
output = tmp_path / "lint.json"
|
|
result = run_cli(
|
|
"lint_document.py",
|
|
"--document",
|
|
path,
|
|
"--logic-map",
|
|
FIXTURES / "good" / "logic-map.json",
|
|
"--term-ledger",
|
|
FIXTURES / "good" / "term-ledger.json",
|
|
"--reader-contract",
|
|
FIXTURES / "good" / "reader-contract.json",
|
|
"--output",
|
|
output,
|
|
)
|
|
assert result.returncode == 0
|
|
assert "DOC-P002" not in {item["rule_id"] for item in read_json(output)["findings"]}
|
|
|
|
|
|
def test_fidelity_uses_typed_exact_multisets(tmp_path: Path) -> None:
|
|
baseline = tmp_path / "baseline.md"
|
|
baseline.write_text(
|
|
'# 기준\n\n```text\nalpha\n```\n\n`foo` https://example.com/api 500ms "정확한 인용"\n',
|
|
encoding="utf-8",
|
|
)
|
|
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
|
|
document += '\n`foobar` https://example.com/api-v2 5000ms "정확한 인용문"\n'
|
|
document_path = tmp_path / "document.md"
|
|
document_path.write_text(document, encoding="utf-8")
|
|
output = tmp_path / "lint.json"
|
|
result = run_cli(
|
|
"lint_document.py",
|
|
"--document",
|
|
document_path,
|
|
"--logic-map",
|
|
FIXTURES / "good" / "logic-map.json",
|
|
"--term-ledger",
|
|
FIXTURES / "good" / "term-ledger.json",
|
|
"--reader-contract",
|
|
FIXTURES / "good" / "reader-contract.json",
|
|
"--baseline",
|
|
baseline,
|
|
"--output",
|
|
output,
|
|
)
|
|
report = read_json(output)
|
|
rule_ids = {item["rule_id"] for item in report["findings"]}
|
|
assert result.returncode == 1
|
|
assert {"DOC-F001", "DOC-F002", "DOC-F003", "DOC-F004", "DOC-F005"} <= rule_ids
|
|
assert report["fidelity"]["missing"] == 5
|
|
|
|
|
|
def test_fidelity_binds_values_to_nearby_meaning_instead_of_only_multisets(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
baseline = tmp_path / "baseline.md"
|
|
baseline.write_text(
|
|
"# 기준\n\n"
|
|
"A 코드 `alpha`, B 코드 `beta`.\n"
|
|
"A 링크 https://example.com/a, B 링크 https://example.com/b.\n"
|
|
"A 지연 10ms, B 지연 20ms.\n"
|
|
'A 문구 "첫 번째", B 문구 "두 번째".\n',
|
|
encoding="utf-8",
|
|
)
|
|
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
|
|
document += (
|
|
"\nA 코드 `beta`, B 코드 `alpha`.\n"
|
|
"A 링크 https://example.com/b, B 링크 https://example.com/a.\n"
|
|
"A 지연 20ms, B 지연 10ms.\n"
|
|
'A 문구 "두 번째", B 문구 "첫 번째".\n'
|
|
)
|
|
document_path = tmp_path / "document.md"
|
|
document_path.write_text(document, encoding="utf-8")
|
|
output = tmp_path / "lint.json"
|
|
result = run_cli(
|
|
"lint_document.py",
|
|
"--document",
|
|
document_path,
|
|
"--logic-map",
|
|
FIXTURES / "good" / "logic-map.json",
|
|
"--term-ledger",
|
|
FIXTURES / "good" / "term-ledger.json",
|
|
"--reader-contract",
|
|
FIXTURES / "good" / "reader-contract.json",
|
|
"--baseline",
|
|
baseline,
|
|
"--output",
|
|
output,
|
|
)
|
|
report = read_json(output)
|
|
rule_ids = {item["rule_id"] for item in report["findings"]}
|
|
assert result.returncode == 1
|
|
assert {"DOC-F002", "DOC-F003", "DOC-F004", "DOC-F005"} <= rule_ids
|
|
|
|
|
|
def test_number_units_are_protected_when_joined_to_korean_particles() -> None:
|
|
inventory = protected_items("약10ms를 기다리고 20초동안 관찰하며 30msfoo는 식별자로 둔다.")
|
|
assert inventory["number_unit"] == ["약10ms", "20초"]
|
|
|
|
|
|
def test_both_endpoint_unit_ranges_are_single_ordered_protected_values() -> None:
|
|
inventory = protected_items("지연 5ms-10ms, 비율 5%-10%, 비호환 5ms-10s.")
|
|
assert inventory["number_range"] == ["5ms-10ms", "5%-10%"]
|
|
assert inventory["number_unit"] == ["5ms", "10s"]
|
|
|
|
|
|
def test_reader_visible_multiline_and_entity_quotes_exclude_metadata_and_code() -> None:
|
|
inventory = protected_items(
|
|
'[링크](dest "metadata") `"inline"`\n'
|
|
'<pre>\n"raw code"\n</pre>\n'
|
|
'보이는 "첫 줄\n둘째 줄"과 '
|
|
'“굽은 인용”이다.\n'
|
|
)
|
|
assert inventory["quote"] == ['"첫 줄\n둘째 줄"', '“굽은 인용”']
|
|
|
|
|
|
@pytest.mark.parametrize("thematic_break", ["_ _ _", "* * *"])
|
|
def test_spaced_thematic_break_terminates_lazy_blockquote_inventory(
|
|
thematic_break: str,
|
|
) -> None:
|
|
inventory = protected_items(
|
|
f"> 첫 줄\n게으른 이어쓰기\n{thematic_break}\n분리된 본문\n"
|
|
)
|
|
assert inventory["quote"] == ["첫 줄\n게으른 이어쓰기"]
|
|
assert protected_items(
|
|
f'"첫 줄\n{thematic_break}\n분리된 둘째 줄"'
|
|
)["quote"] == []
|
|
|
|
|
|
def test_common_absolute_uri_schemes_are_protected() -> None:
|
|
uris = [
|
|
"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",
|
|
]
|
|
assert protected_items("\n".join(uris))["url"] == uris
|
|
|
|
|
|
def test_inline_link_parser_always_progresses_and_supports_multiline_title() -> None:
|
|
assert markdown_inline_links("[x](dest") == []
|
|
links = markdown_inline_links('[go](#missing "long\n title")')
|
|
assert len(links) == 1
|
|
assert links[0][2:4] == (5, 13)
|
|
assert mask_markdown_metadata("[x\n\ny](TODO)") == "[x\n\ny](TODO)"
|
|
|
|
|
|
def test_heading_entities_require_semicolon_and_slugs_are_globally_unique() -> None:
|
|
assert github_slug("A © B") == "a-copy-b"
|
|
assert github_slug("A © B") == "a-b"
|
|
assert [item.slug for item in parse_headings("## foo\n## foo-1\n## foo\n")] == [
|
|
"foo",
|
|
"foo-1",
|
|
"foo-2",
|
|
]
|
|
|
|
|
|
def test_multiline_inline_code_allows_non_interrupting_ordered_marker() -> None:
|
|
inventory = protected_items("text `a\n2. TODO\nc` tail")
|
|
assert inventory["inline_code"] == ["a 2. TODO c"]
|
|
|
|
|
|
def test_finalizer_change_rate_detects_nonlocal_reorder_and_is_linear_enough() -> None:
|
|
paragraphs = [f"문단 {index}에는 고유한 설명과 값 {index}가 있다." for index in range(5000)]
|
|
before = "\n\n".join(paragraphs)
|
|
after = "\n\n".join(reversed(paragraphs))
|
|
started = time.monotonic()
|
|
rate = bounded_change_rate(before, after)
|
|
elapsed = time.monotonic() - started
|
|
assert rate > 0.15
|
|
assert elapsed < 5.0
|
|
|
|
|
|
def test_finalizer_change_rate_allows_one_local_word_edit() -> None:
|
|
before = "이 문서는 캐시 장애의 원인과 검증 범위를 독자에게 차례로 설명한다."
|
|
after = "이 문서는 캐시 장애의 원인과 검증 한계를 독자에게 차례로 설명한다."
|
|
assert bounded_change_rate(before, after) <= 0.15
|
|
|
|
|
|
def test_finalizer_change_rate_detects_reorder_even_when_every_paragraph_is_edited() -> None:
|
|
paragraphs = [
|
|
f"문단 {index}은 캐시 동작 {index}와 고유한 검증 범위를 설명한다."
|
|
for index in range(120)
|
|
]
|
|
before = "\n\n".join(paragraphs)
|
|
after = "\n\n".join(f"{item} 보충" for item in reversed(paragraphs))
|
|
assert bounded_change_rate(before, after) > 0.15
|
|
|
|
|
|
def test_draft_baseline_hash_and_change_rate_are_reported(tmp_path: Path) -> None:
|
|
draft = tmp_path / "draft.md"
|
|
draft.write_text("# 완전히 다른 초안\n\n" + "다른 문장. " * 100, encoding="utf-8")
|
|
result, report = lint_fixture(tmp_path, "good", "--draft-baseline", draft)
|
|
assert result.returncode == 1
|
|
assert "FNL-001" in {item["rule_id"] for item in report["findings"]}
|
|
assert report["fidelity"]["draft_baseline"]["sha256"]
|
|
assert report["fidelity"]["finalization_change_rate"] > 0.15
|
|
|
|
|
|
def test_unregistered_candidate_is_a_default_hard_gate(tmp_path: Path) -> None:
|
|
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
|
|
document += "\n등록하지 않은 NewCacheMode 후보가 있다.\n"
|
|
path = tmp_path / "document.md"
|
|
path.write_text(document, encoding="utf-8")
|
|
output = tmp_path / "lint.json"
|
|
common = (
|
|
"--document",
|
|
path,
|
|
"--logic-map",
|
|
FIXTURES / "good" / "logic-map.json",
|
|
"--term-ledger",
|
|
FIXTURES / "good" / "term-ledger.json",
|
|
"--reader-contract",
|
|
FIXTURES / "good" / "reader-contract.json",
|
|
"--output",
|
|
output,
|
|
)
|
|
assert run_cli("lint_document.py", *common).returncode == 1
|
|
assert "DOC-T007" in {
|
|
item["rule_id"] for item in read_json(output)["findings"]
|
|
}
|
|
|
|
|
|
def test_fail_on_warning_changes_exit_code(tmp_path: Path) -> None:
|
|
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
|
|
document += "\n\n" + ("긴" * 901) + "\n"
|
|
path = tmp_path / "document.md"
|
|
path.write_text(document, encoding="utf-8")
|
|
output = tmp_path / "lint.json"
|
|
common = (
|
|
"--document",
|
|
path,
|
|
"--logic-map",
|
|
FIXTURES / "good" / "logic-map.json",
|
|
"--term-ledger",
|
|
FIXTURES / "good" / "term-ledger.json",
|
|
"--reader-contract",
|
|
FIXTURES / "good" / "reader-contract.json",
|
|
"--output",
|
|
output,
|
|
)
|
|
assert run_cli("lint_document.py", *common).returncode == 0
|
|
assert "DOC-P001" in {
|
|
item["rule_id"] for item in read_json(output)["findings"]
|
|
}
|
|
assert run_cli("lint_document.py", *common, "--fail-on", "warning").returncode == 1
|
|
|
|
|
|
def test_definition_window_threshold_is_actually_enforced(tmp_path: Path) -> None:
|
|
rules_path = (
|
|
Path(__file__).resolve().parents[1]
|
|
/ "skills"
|
|
/ "technical-doc-flow"
|
|
/ "config"
|
|
/ "quality-rules.json"
|
|
)
|
|
rules = read_json(rules_path)
|
|
rules["thresholds"]["term"]["definition_window_chars"] = 10
|
|
strict_rules = tmp_path / "quality-rules.json"
|
|
write_json(strict_rules, rules)
|
|
result, report = lint_fixture(tmp_path, "good", "--rules", strict_rules)
|
|
assert result.returncode == 1
|
|
assert "DOC-T001" in {item["rule_id"] for item in report["findings"]}
|
|
|
|
|
|
def test_definition_window_also_accepts_plain_definition_after_first_name(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
|
|
document = document.replace(
|
|
"같은 요청을 여러 번 처리해도 결과가 한 번 처리한 것과 같게 되는 성질을 멱등성이라고 한다.",
|
|
"멱등성이라는 이름을 쓴다. 같은 요청을 여러 번 처리해도 결과가 한 번 처리한 것과 같게 되는 성질을 뜻한다.",
|
|
)
|
|
ledger = read_json(FIXTURES / "good" / "term-ledger.json")
|
|
ledger["terms"][0]["first_use"] = "멱등성이라는 이름을 쓴다."
|
|
document_path = tmp_path / "document.md"
|
|
ledger_path = tmp_path / "term-ledger.json"
|
|
output = tmp_path / "lint.json"
|
|
document_path.write_text(document, encoding="utf-8")
|
|
write_json(ledger_path, ledger)
|
|
result = run_cli(
|
|
"lint_document.py",
|
|
"--document",
|
|
document_path,
|
|
"--logic-map",
|
|
FIXTURES / "good" / "logic-map.json",
|
|
"--term-ledger",
|
|
ledger_path,
|
|
"--reader-contract",
|
|
FIXTURES / "good" / "reader-contract.json",
|
|
"--output",
|
|
output,
|
|
)
|
|
assert result.returncode == 0, read_json(output)["findings"]
|
|
|
|
|
|
def test_hidden_html_comment_cannot_satisfy_term_first_use(tmp_path: Path) -> None:
|
|
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
|
|
first_use = (
|
|
"같은 요청을 여러 번 처리해도 결과가 한 번 처리한 것과 같게 되는 성질을 "
|
|
"멱등성이라고 한다."
|
|
)
|
|
document = document.replace(
|
|
first_use,
|
|
f"<!-- {first_use} -->\n\n멱등성을 사용한다.",
|
|
)
|
|
path = tmp_path / "document.md"
|
|
path.write_text(document, encoding="utf-8")
|
|
output = tmp_path / "lint.json"
|
|
result = run_cli(
|
|
"lint_document.py",
|
|
"--document",
|
|
path,
|
|
"--logic-map",
|
|
FIXTURES / "good" / "logic-map.json",
|
|
"--term-ledger",
|
|
FIXTURES / "good" / "term-ledger.json",
|
|
"--reader-contract",
|
|
FIXTURES / "good" / "reader-contract.json",
|
|
"--output",
|
|
output,
|
|
)
|
|
assert result.returncode == 1
|
|
assert "DOC-T001" in {
|
|
item["rule_id"] for item in read_json(output)["findings"]
|
|
}
|
|
|
|
|
|
def test_ledger_term_must_actually_appear_and_first_use_must_name_it(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
|
|
original = (
|
|
"같은 요청을 여러 번 처리해도 결과가 한 번 처리한 것과 같게 되는 성질을 "
|
|
"멱등성이라고 한다."
|
|
)
|
|
generic = "같은 요청을 여러 번 처리해도 결과가 한 번 처리한 것과 같게 되는 성질을 설명한다."
|
|
document = document.replace(original, generic).replace("멱등성", "같은 결과 성질")
|
|
ledger = read_json(FIXTURES / "good" / "term-ledger.json")
|
|
ledger["terms"][0]["first_use"] = generic
|
|
document_path = tmp_path / "document.md"
|
|
ledger_path = tmp_path / "term-ledger.json"
|
|
output = tmp_path / "lint.json"
|
|
document_path.write_text(document, encoding="utf-8")
|
|
write_json(ledger_path, ledger)
|
|
result = run_cli(
|
|
"lint_document.py",
|
|
"--document",
|
|
document_path,
|
|
"--logic-map",
|
|
FIXTURES / "good" / "logic-map.json",
|
|
"--term-ledger",
|
|
ledger_path,
|
|
"--reader-contract",
|
|
FIXTURES / "good" / "reader-contract.json",
|
|
"--output",
|
|
output,
|
|
)
|
|
assert result.returncode == 1
|
|
messages = [
|
|
item["message"]
|
|
for item in read_json(output)["findings"]
|
|
if item["rule_id"] == "DOC-T001"
|
|
]
|
|
assert any("실제로 등장하지" in message for message in messages)
|
|
assert any("first_use에 정식 용어" in message for message in messages)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"protected_name",
|
|
["document.md", "logic-map.json", "term-ledger.json", "reader-contract.json"],
|
|
)
|
|
def test_lint_output_cannot_overwrite_inputs(tmp_path: Path, protected_name: str) -> None:
|
|
base = FIXTURES / "good"
|
|
protected = tmp_path / protected_name
|
|
shutil.copyfile(base / protected_name, protected)
|
|
before = protected.read_bytes()
|
|
paths = {
|
|
"document.md": protected if protected_name == "document.md" else base / "document.md",
|
|
"logic-map.json": protected if protected_name == "logic-map.json" else base / "logic-map.json",
|
|
"term-ledger.json": protected if protected_name == "term-ledger.json" else base / "term-ledger.json",
|
|
"reader-contract.json": protected if protected_name == "reader-contract.json" else base / "reader-contract.json",
|
|
}
|
|
result = run_cli(
|
|
"lint_document.py",
|
|
"--document",
|
|
paths["document.md"],
|
|
"--logic-map",
|
|
paths["logic-map.json"],
|
|
"--term-ledger",
|
|
paths["term-ledger.json"],
|
|
"--reader-contract",
|
|
paths["reader-contract.json"],
|
|
"--output",
|
|
protected,
|
|
)
|
|
assert result.returncode == 2
|
|
assert protected.read_bytes() == before
|
|
|
|
|
|
def test_lint_existing_output_requires_same_tool_ownership(tmp_path: Path) -> None:
|
|
output = tmp_path / "report.json"
|
|
output.write_text('{"tool":"verify_run","sentinel":true}\n', encoding="utf-8")
|
|
before = output.read_bytes()
|
|
result = run_cli(
|
|
"lint_document.py",
|
|
"--document",
|
|
FIXTURES / "good" / "document.md",
|
|
"--logic-map",
|
|
FIXTURES / "good" / "logic-map.json",
|
|
"--term-ledger",
|
|
FIXTURES / "good" / "term-ledger.json",
|
|
"--reader-contract",
|
|
FIXTURES / "good" / "reader-contract.json",
|
|
"--output",
|
|
output,
|
|
)
|
|
assert result.returncode == 2
|
|
assert output.read_bytes() == before
|
|
|
|
|
|
def test_lint_malformed_same_tool_output_is_not_overwritten(tmp_path: Path) -> None:
|
|
output = tmp_path / "report.json"
|
|
output.write_text('{"tool":"lint_document","sentinel":true}\n', encoding="utf-8")
|
|
before = output.read_bytes()
|
|
result = run_cli(
|
|
"lint_document.py",
|
|
"--document",
|
|
FIXTURES / "good" / "document.md",
|
|
"--logic-map",
|
|
FIXTURES / "good" / "logic-map.json",
|
|
"--term-ledger",
|
|
FIXTURES / "good" / "term-ledger.json",
|
|
"--reader-contract",
|
|
FIXTURES / "good" / "reader-contract.json",
|
|
"--output",
|
|
output,
|
|
)
|
|
assert result.returncode == 2
|
|
assert output.read_bytes() == before
|
|
|
|
|
|
@pytest.mark.parametrize("raced_entry", ["file", "symlink", "hardlink"])
|
|
def test_lint_publish_rejects_entry_created_after_preflight(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, raced_entry: str
|
|
) -> None:
|
|
output = tmp_path / "report.json"
|
|
protected = tmp_path / "document.md"
|
|
shutil.copyfile(FIXTURES / "good" / "document.md", protected)
|
|
protected_before = protected.read_bytes()
|
|
original_publish = lint_document_module.publish_report_json
|
|
|
|
def publish_after_race(prepared, value):
|
|
if raced_entry == "file":
|
|
prepared.path.write_bytes(b"unrelated sentinel\n")
|
|
elif raced_entry == "symlink":
|
|
prepared.path.symlink_to(protected)
|
|
else:
|
|
os.link(protected, prepared.path)
|
|
return original_publish(prepared, value)
|
|
|
|
monkeypatch.setattr(
|
|
lint_document_module,
|
|
"publish_report_json",
|
|
publish_after_race,
|
|
)
|
|
|
|
result = lint_document_module.main(lint_main_args(output, document=protected))
|
|
|
|
assert result == 2
|
|
assert protected.read_bytes() == protected_before
|
|
if raced_entry == "file":
|
|
assert output.read_bytes() == b"unrelated sentinel\n"
|
|
elif raced_entry == "symlink":
|
|
assert output.is_symlink()
|
|
assert output.resolve() == protected.resolve()
|
|
else:
|
|
assert not output.is_symlink()
|
|
assert os.path.samefile(output, protected)
|
|
|
|
|
|
def test_lint_publish_rejects_owned_report_changed_after_preflight(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
output = tmp_path / "report.json"
|
|
assert lint_document_module.main(lint_main_args(output)) == 0
|
|
original_publish = lint_document_module.publish_report_json
|
|
changed = output.read_bytes() + b"\n"
|
|
original_inode = output.stat().st_ino
|
|
|
|
def publish_after_race(prepared, value):
|
|
prepared.path.write_bytes(changed)
|
|
assert prepared.path.stat().st_ino == original_inode
|
|
return original_publish(prepared, value)
|
|
|
|
monkeypatch.setattr(
|
|
lint_document_module,
|
|
"publish_report_json",
|
|
publish_after_race,
|
|
)
|
|
|
|
result = lint_document_module.main(lint_main_args(output))
|
|
|
|
assert result == 2
|
|
assert output.stat().st_ino == original_inode
|
|
assert output.read_bytes() == changed
|
|
|
|
|
|
def test_lint_can_conditionally_refresh_owned_report(tmp_path: Path) -> None:
|
|
output = tmp_path / "report.json"
|
|
|
|
assert lint_document_module.main(lint_main_args(output)) == 0
|
|
assert lint_document_module.main(lint_main_args(output)) == 0
|
|
|
|
assert read_json(output)["tool"] == "lint_document"
|
|
|
|
|
|
def test_fidelity_does_not_count_values_hidden_in_html_comments(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
baseline = tmp_path / "baseline.md"
|
|
baseline.write_text(
|
|
'# 기준\n\n`SecretToken987` https://example.com/unique-cache 731ms "고유 인용문"\n',
|
|
encoding="utf-8",
|
|
)
|
|
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
|
|
document += (
|
|
'\n<!-- `SecretToken987` https://example.com/unique-cache 731ms "고유 인용문" -->\n'
|
|
)
|
|
document_path = tmp_path / "document.md"
|
|
output = tmp_path / "lint.json"
|
|
document_path.write_text(document, encoding="utf-8")
|
|
result = run_cli(
|
|
"lint_document.py",
|
|
"--document",
|
|
document_path,
|
|
"--logic-map",
|
|
FIXTURES / "good" / "logic-map.json",
|
|
"--term-ledger",
|
|
FIXTURES / "good" / "term-ledger.json",
|
|
"--reader-contract",
|
|
FIXTURES / "good" / "reader-contract.json",
|
|
"--baseline",
|
|
baseline,
|
|
"--output",
|
|
output,
|
|
)
|
|
report = read_json(output)
|
|
assert result.returncode == 1
|
|
assert {"DOC-F002", "DOC-F003", "DOC-F004", "DOC-F005"} <= {
|
|
item["rule_id"] for item in report["findings"]
|
|
}
|
|
assert report["fidelity"]["missing"] == 4
|
|
|
|
|
|
def test_registered_multiword_term_suppresses_its_internal_candidate_only(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
document = (FIXTURES / "good" / "document.md").read_text(encoding="utf-8")
|
|
document += "\n중복 실행을 묶는 single-flight coordinator를 사용한다.\n"
|
|
ledger = read_json(FIXTURES / "good" / "term-ledger.json")
|
|
reader = read_json(FIXTURES / "good" / "reader-contract.json")
|
|
ledger["assumed_known"].append("single-flight coordinator")
|
|
reader["assumed_known"].append("single-flight coordinator")
|
|
document_path = tmp_path / "document.md"
|
|
ledger_path = tmp_path / "term-ledger.json"
|
|
reader_path = tmp_path / "reader-contract.json"
|
|
output = tmp_path / "lint.json"
|
|
document_path.write_text(document, encoding="utf-8")
|
|
write_json(ledger_path, ledger)
|
|
write_json(reader_path, reader)
|
|
result = run_cli(
|
|
"lint_document.py",
|
|
"--document",
|
|
document_path,
|
|
"--logic-map",
|
|
FIXTURES / "good" / "logic-map.json",
|
|
"--term-ledger",
|
|
ledger_path,
|
|
"--reader-contract",
|
|
reader_path,
|
|
"--output",
|
|
output,
|
|
)
|
|
candidates = {
|
|
item["context"]
|
|
for item in read_json(output)["findings"]
|
|
if item["rule_id"] == "DOC-T007"
|
|
}
|
|
assert result.returncode == 0
|
|
assert "single-flight" not in candidates
|
|
|
|
|
|
def test_registered_prefix_does_not_hide_longer_unregistered_term(tmp_path: Path) -> None:
|
|
result, report = lint_fixture(tmp_path, "bad")
|
|
assert result.returncode == 1
|
|
candidates = {
|
|
item["context"]
|
|
for item in report["findings"]
|
|
if item["rule_id"] == "DOC-T007"
|
|
}
|
|
assert "HTTPX" in candidates
|