3526 lines
134 KiB
Python
3526 lines
134 KiB
Python
#!/usr/bin/env python3
|
||
"""Lint Markdown, logic, terminology, and fidelity contracts."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import bisect
|
||
import hashlib
|
||
import html
|
||
import re
|
||
import sys
|
||
import unicodedata
|
||
from collections import Counter, defaultdict
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import Any, Iterable
|
||
from urllib.parse import unquote
|
||
|
||
from harness_common import (
|
||
DEFAULT_RULES_PATH,
|
||
InputError,
|
||
decode_utf8,
|
||
load_json_text,
|
||
load_rules,
|
||
prepare_report_output,
|
||
publish_report_json,
|
||
rule_index,
|
||
schema_version,
|
||
sha256_file,
|
||
snapshot_file,
|
||
utc_now,
|
||
validate_with_schema,
|
||
)
|
||
from markdown_structure import (
|
||
advance_html_block,
|
||
closing_fence,
|
||
crosses_paragraph_boundary,
|
||
fence_container_continues,
|
||
indented_code_container,
|
||
indentation_columns,
|
||
inline_code_spans,
|
||
inside_any_span,
|
||
is_thematic_break,
|
||
mask_closed_fence_candidates,
|
||
list_continuation_indent,
|
||
opening_fence,
|
||
ordered_list_interrupts_paragraph,
|
||
strip_blockquotes,
|
||
)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Heading:
|
||
level: int
|
||
text: str
|
||
line: int
|
||
start: int
|
||
end: int
|
||
slug: str
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class TextSpan:
|
||
start: int
|
||
end: int
|
||
line: int
|
||
text: str
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class FencedBlock:
|
||
start: int
|
||
end: int
|
||
line: int
|
||
text: str
|
||
closed: bool
|
||
kind: str = "fenced_code"
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ProtectedOccurrence:
|
||
value: str
|
||
context: str
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ReferenceDefinition:
|
||
identifier: str
|
||
destination: str
|
||
start: int
|
||
end: int
|
||
target_start: int
|
||
target_end: int
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ReferenceLink:
|
||
start: int
|
||
end: int
|
||
metadata_start: int
|
||
metadata_end: int
|
||
raw_label: str
|
||
identifier: str
|
||
|
||
|
||
ABSOLUTE_URI_PATTERN = re.compile(
|
||
r"(?<![A-Za-z0-9+.-])(?:(?:https?|ftps?|file|ssh|git)://|mailto:)"
|
||
r"[^\s<>\"'\])}]+",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
def absolute_uri_matches(text: str) -> list[tuple[int, int, str]]:
|
||
"""Return common reader-visible absolute URIs without trailing prose punctuation."""
|
||
|
||
matches: list[tuple[int, int, str]] = []
|
||
for match in ABSOLUTE_URI_PATTERN.finditer(text):
|
||
value = match.group(0).rstrip(".,;:")
|
||
if value:
|
||
matches.append((match.start(), match.start() + len(value), value))
|
||
return matches
|
||
|
||
|
||
REQUIRED_LOGIC_TOP = (
|
||
"schema_version",
|
||
"title",
|
||
"document_kind",
|
||
"core_claim",
|
||
"sections",
|
||
"closure",
|
||
)
|
||
REQUIRED_LOGIC_SECTION = (
|
||
"id",
|
||
"heading",
|
||
"role",
|
||
"depends_on",
|
||
"reader_state_before",
|
||
"question",
|
||
"answer_plain",
|
||
"claim_ids",
|
||
"new_terms",
|
||
"transition_to",
|
||
"reader_state_after",
|
||
)
|
||
REQUIRED_TERM = (
|
||
"id",
|
||
"canonical",
|
||
"plain_definition",
|
||
"why_needed",
|
||
"aliases",
|
||
"first_section",
|
||
"first_use",
|
||
)
|
||
REQUIRED_READER = (
|
||
"schema_version",
|
||
"document_kind",
|
||
"primary_audience",
|
||
"purpose",
|
||
"reader_question",
|
||
"reader_outcome",
|
||
"prerequisites",
|
||
"assumed_known",
|
||
"must_explain",
|
||
"non_goals",
|
||
)
|
||
|
||
|
||
def line_starts(text: str) -> list[int]:
|
||
starts = [0]
|
||
starts.extend(match.end() for match in re.finditer("\n", text))
|
||
return starts
|
||
|
||
|
||
def location(starts: list[int], offset: int) -> tuple[int, int]:
|
||
index = bisect.bisect_right(starts, max(0, offset)) - 1
|
||
return index + 1, offset - starts[index] + 1
|
||
|
||
|
||
def is_backslash_escaped(text: str, position: int) -> bool:
|
||
count = 0
|
||
position -= 1
|
||
while position >= 0 and text[position] == "\\":
|
||
count += 1
|
||
position -= 1
|
||
return count % 2 == 1
|
||
|
||
|
||
def html_tag_spans(text: str) -> list[tuple[int, int]]:
|
||
"""Return real inline/block HTML tag spans, excluding comments/autolinks."""
|
||
|
||
spans: list[tuple[int, int]] = []
|
||
cursor = 0
|
||
while cursor < len(text):
|
||
start = text.find("<", cursor)
|
||
if start < 0:
|
||
break
|
||
name = re.match(r"</?([A-Za-z][A-Za-z0-9-]*)", text[start:])
|
||
if name is None:
|
||
cursor = start + 1
|
||
continue
|
||
prefix_end = start + name.end()
|
||
if prefix_end >= len(text) or text[prefix_end] not in " \t\r\n/>":
|
||
cursor = start + 1
|
||
continue
|
||
quote: str | None = None
|
||
end = prefix_end
|
||
while end < len(text):
|
||
character = text[end]
|
||
if quote is not None:
|
||
if character == quote:
|
||
quote = None
|
||
elif character in "\"'":
|
||
quote = character
|
||
elif character == ">":
|
||
spans.append((start, end + 1))
|
||
end += 1
|
||
break
|
||
end += 1
|
||
cursor = max(start + 1, end)
|
||
return spans
|
||
|
||
|
||
def html_tag_name_and_attributes(raw: str) -> tuple[str, dict[str, str]] | None:
|
||
"""Parse the tag name and explicit attribute values from one HTML tag."""
|
||
|
||
tag = re.match(r"<(?P<closing>/)?(?P<name>[A-Za-z][A-Za-z0-9-]*)", raw)
|
||
if tag is None or tag.group("closing"):
|
||
return None
|
||
attributes: dict[str, str] = {}
|
||
cursor = tag.end()
|
||
while cursor < len(raw):
|
||
while cursor < len(raw) and raw[cursor].isspace():
|
||
cursor += 1
|
||
if cursor >= len(raw) or raw[cursor] in "/>":
|
||
break
|
||
name = re.match(r"[A-Za-z_:][A-Za-z0-9_.:-]*", raw[cursor:])
|
||
if name is None:
|
||
cursor += 1
|
||
continue
|
||
attribute_name = name.group(0).casefold()
|
||
cursor += name.end()
|
||
while cursor < len(raw) and raw[cursor].isspace():
|
||
cursor += 1
|
||
value = ""
|
||
if cursor < len(raw) and raw[cursor] == "=":
|
||
cursor += 1
|
||
while cursor < len(raw) and raw[cursor].isspace():
|
||
cursor += 1
|
||
if cursor < len(raw) and raw[cursor] in "\"'":
|
||
quote = raw[cursor]
|
||
cursor += 1
|
||
start = cursor
|
||
while cursor < len(raw) and raw[cursor] != quote:
|
||
cursor += 1
|
||
value = raw[start:cursor]
|
||
if cursor < len(raw):
|
||
cursor += 1
|
||
else:
|
||
start = cursor
|
||
while cursor < len(raw) and raw[cursor] not in " \t\r\n\"'=<>`":
|
||
cursor += 1
|
||
value = raw[start:cursor]
|
||
attributes.setdefault(attribute_name, value)
|
||
return tag.group("name").casefold(), attributes
|
||
|
||
|
||
def normalize_reference_identifier(value: str) -> str:
|
||
value = re.sub(r"\\([!\"#$%&'()*+,./:;<=>?@\[\\\]^_`{|}~-])", r"\1", value)
|
||
decoded, _ = decode_html_entities_with_offsets(value)
|
||
return " ".join(decoded.split()).casefold()
|
||
|
||
|
||
def reference_definitions(text: str) -> list[ReferenceDefinition]:
|
||
"""Parse CommonMark-style link definitions without exposing their metadata."""
|
||
|
||
definitions: list[ReferenceDefinition] = []
|
||
lines = text.splitlines(keepends=True)
|
||
offsets: list[int] = []
|
||
offset = 0
|
||
for line in lines:
|
||
offsets.append(offset)
|
||
offset += len(line)
|
||
|
||
title_patterns = (
|
||
re.compile(r'^"(?:\\.|[^"\r\n])*"$'),
|
||
re.compile(r"^'(?:\\.|[^'\r\n])*'$"),
|
||
re.compile(r"^\((?:\\.|[^()\r\n])*\)$"),
|
||
)
|
||
|
||
def container_content(content: str) -> tuple[str, int, int, bool]:
|
||
rest, quote_depth = strip_blockquotes(content)
|
||
prefix = len(content) - len(rest)
|
||
item = re.match(
|
||
r"^(?P<leading>[ \t]*)(?:[-+*]|[0-9]{1,9}[.)])(?P<gap>[ \t]+)",
|
||
rest,
|
||
)
|
||
if item:
|
||
prefix += item.end()
|
||
rest = rest[item.end() :]
|
||
return rest, prefix, quote_depth, item is not None
|
||
|
||
def parse_header(content: str) -> tuple[str, int] | None:
|
||
leading = len(content) - len(content.lstrip(" "))
|
||
if leading > 3:
|
||
return None
|
||
cursor = leading
|
||
if cursor >= len(content) or content[cursor] != "[":
|
||
return None
|
||
label_start = cursor + 1
|
||
cursor = label_start
|
||
while cursor < len(content):
|
||
if content[cursor] == "]" and not is_backslash_escaped(content, cursor):
|
||
break
|
||
cursor += 1
|
||
if cursor >= len(content) or not content[label_start:cursor]:
|
||
return None
|
||
label = content[label_start:cursor]
|
||
cursor += 1
|
||
if cursor >= len(content) or content[cursor] != ":":
|
||
return None
|
||
cursor += 1
|
||
while cursor < len(content) and content[cursor] in " \t":
|
||
cursor += 1
|
||
return label, cursor
|
||
|
||
def parse_destination_prefix(
|
||
content: str, cursor: int
|
||
) -> tuple[int, int, int] | None:
|
||
if cursor >= len(content):
|
||
return None
|
||
if content[cursor] == "<":
|
||
target_start = cursor + 1
|
||
target_end = target_start
|
||
while target_end < len(content) and content[target_end] != ">":
|
||
if content[target_end] in "<>" or (
|
||
content[target_end] == "\\" and target_end + 1 >= len(content)
|
||
):
|
||
return None
|
||
target_end += 2 if content[target_end] == "\\" else 1
|
||
if target_end >= len(content) or content[target_end] != ">":
|
||
return None
|
||
tail = target_end + 1
|
||
else:
|
||
target_start = cursor
|
||
depth = 0
|
||
while cursor < len(content) and not content[cursor].isspace():
|
||
if content[cursor] == "\\" and cursor + 1 < len(content):
|
||
cursor += 2
|
||
continue
|
||
if content[cursor] == "(":
|
||
depth += 1
|
||
elif content[cursor] == ")":
|
||
if depth == 0:
|
||
return None
|
||
depth -= 1
|
||
cursor += 1
|
||
if depth:
|
||
return None
|
||
target_end = cursor
|
||
tail = cursor
|
||
return target_start, target_end, tail
|
||
|
||
def parse_destination(
|
||
content: str, cursor: int
|
||
) -> tuple[int, int, bool] | None:
|
||
prefix = parse_destination_prefix(content, cursor)
|
||
if prefix is None:
|
||
return None
|
||
target_start, target_end, tail = prefix
|
||
raw_rest = content[tail:]
|
||
rest = raw_rest.strip()
|
||
if rest and (not raw_rest or raw_rest[0] not in " \t\r\n"):
|
||
return None
|
||
if rest and not any(pattern.fullmatch(rest) for pattern in title_patterns):
|
||
return None
|
||
return target_start, target_end, not rest
|
||
|
||
def parse_line(content: str) -> tuple[str, int, int, bool] | None:
|
||
header = parse_header(content)
|
||
if header is None:
|
||
return None
|
||
label, cursor = header
|
||
destination = parse_destination(content, cursor)
|
||
if destination is None:
|
||
return None
|
||
target_start, target_end, title_may_follow = destination
|
||
return label, target_start, target_end, title_may_follow
|
||
|
||
def consume_multiline_title(
|
||
start_index: int,
|
||
start_content: str,
|
||
opener_position: int,
|
||
quote_depth: int,
|
||
) -> int | None:
|
||
opener = start_content[opener_position]
|
||
closer = ")" if opener == "(" else opener
|
||
line_index = start_index
|
||
content = start_content
|
||
cursor = opener_position + 1
|
||
while True:
|
||
while cursor < len(content):
|
||
if content[cursor] == "\\" and cursor + 1 < len(content):
|
||
cursor += 2
|
||
continue
|
||
if opener == "(" and content[cursor] == "(":
|
||
return None
|
||
if content[cursor] == closer:
|
||
return line_index if not content[cursor + 1 :].strip() else None
|
||
cursor += 1
|
||
line_index += 1
|
||
if line_index >= len(lines):
|
||
return None
|
||
continuation_raw = lines[line_index].rstrip("\r\n")
|
||
(
|
||
content,
|
||
_,
|
||
continuation_quote_depth,
|
||
continuation_list_item,
|
||
) = container_content(continuation_raw)
|
||
if (
|
||
not content.strip()
|
||
or continuation_quote_depth != quote_depth
|
||
or continuation_list_item
|
||
):
|
||
return None
|
||
cursor = 0
|
||
|
||
paragraph_open = False
|
||
active_quote_depth = 0
|
||
skip_through = -1
|
||
for index, line in enumerate(lines):
|
||
if index <= skip_through:
|
||
continue
|
||
raw_content = line.rstrip("\r\n")
|
||
content, prefix, quote_depth, list_item = container_content(raw_content)
|
||
if not raw_content.strip():
|
||
paragraph_open = False
|
||
active_quote_depth = 0
|
||
continue
|
||
list_interrupt = ordered_list_interrupts_paragraph(
|
||
strip_blockquotes(raw_content)[0]
|
||
)
|
||
if quote_depth != active_quote_depth or (
|
||
list_item and (not paragraph_open or list_interrupt)
|
||
):
|
||
paragraph_open = False
|
||
active_quote_depth = quote_depth
|
||
parsed = None if paragraph_open else parse_line(content)
|
||
target_line_index = index
|
||
target_prefix = prefix
|
||
definition_end_index = index
|
||
header = None if paragraph_open else parse_header(content)
|
||
if parsed is None and header is not None:
|
||
destination_prefix = parse_destination_prefix(content, header[1])
|
||
if destination_prefix is not None:
|
||
target_start, target_end, tail = destination_prefix
|
||
raw_rest = content[tail:]
|
||
title_position = tail + (len(raw_rest) - len(raw_rest.lstrip()))
|
||
if (
|
||
raw_rest
|
||
and raw_rest[0].isspace()
|
||
and title_position < len(content)
|
||
and content[title_position] in "\"'("
|
||
):
|
||
title_end = consume_multiline_title(
|
||
index, content, title_position, quote_depth
|
||
)
|
||
if title_end is not None:
|
||
definition_end_index = title_end
|
||
parsed = (header[0], target_start, target_end, False)
|
||
if parsed is None and not paragraph_open:
|
||
if header is not None and header[1] == len(content) and index + 1 < len(lines):
|
||
next_raw = lines[index + 1].rstrip("\r\n")
|
||
(
|
||
next_content,
|
||
next_prefix,
|
||
next_quote_depth,
|
||
next_list_item,
|
||
) = container_content(next_raw)
|
||
next_leading = len(next_content) - len(next_content.lstrip(" "))
|
||
next_destination_prefix = (
|
||
parse_destination_prefix(next_content, next_leading)
|
||
if 1 <= next_leading <= 3
|
||
and next_quote_depth == quote_depth
|
||
and not next_list_item
|
||
else None
|
||
)
|
||
if next_destination_prefix is not None:
|
||
target_start, target_end, tail = next_destination_prefix
|
||
raw_rest = next_content[tail:]
|
||
title_position = tail + (
|
||
len(raw_rest) - len(raw_rest.lstrip())
|
||
)
|
||
title_end: int | None = None
|
||
if not raw_rest.strip():
|
||
parsed = (header[0], target_start, target_end, True)
|
||
elif (
|
||
raw_rest[0].isspace()
|
||
and title_position < len(next_content)
|
||
and next_content[title_position] in "\"'("
|
||
):
|
||
title_end = consume_multiline_title(
|
||
index + 1,
|
||
next_content,
|
||
title_position,
|
||
quote_depth,
|
||
)
|
||
if title_end is not None:
|
||
parsed = (header[0], target_start, target_end, False)
|
||
target_line_index = index + 1
|
||
target_prefix = next_prefix
|
||
definition_end_index = (
|
||
title_end if title_end is not None else index + 1
|
||
)
|
||
if parsed is not None:
|
||
label, target_start, target_end, title_may_follow = parsed
|
||
end_index = definition_end_index
|
||
if title_may_follow and end_index + 1 < len(lines):
|
||
next_raw = lines[end_index + 1].rstrip("\r\n")
|
||
next_content, _, next_quote_depth, next_list_item = container_content(
|
||
next_raw
|
||
)
|
||
next_leading = len(next_content) - len(next_content.lstrip(" "))
|
||
title_end = None
|
||
if (
|
||
next_quote_depth == quote_depth
|
||
and not next_list_item
|
||
and next_leading <= 3
|
||
and next_leading < len(next_content)
|
||
and next_content[next_leading] in "\"'("
|
||
):
|
||
title_end = consume_multiline_title(
|
||
end_index + 1,
|
||
next_content,
|
||
next_leading,
|
||
quote_depth,
|
||
)
|
||
if title_end is not None:
|
||
end_index = title_end
|
||
absolute_start = offsets[index]
|
||
absolute_target_start = (
|
||
offsets[target_line_index] + target_prefix + target_start
|
||
)
|
||
absolute_target_end = (
|
||
offsets[target_line_index] + target_prefix + target_end
|
||
)
|
||
absolute_end = offsets[end_index] + len(lines[end_index])
|
||
destination = text[absolute_target_start:absolute_target_end]
|
||
destination = re.sub(r"\\(.)", r"\1", destination)
|
||
definitions.append(
|
||
ReferenceDefinition(
|
||
normalize_reference_identifier(label),
|
||
decode_html_entities_with_offsets(destination)[0],
|
||
absolute_start,
|
||
absolute_end,
|
||
absolute_target_start,
|
||
absolute_target_end,
|
||
)
|
||
)
|
||
skip_through = end_index
|
||
paragraph_open = False
|
||
continue
|
||
|
||
structural = bool(
|
||
re.match(
|
||
r"^[ ]{0,3}(?:#{1,6}(?:[ \t]+|$)|(?:=+|-+)[ \t]*$|"
|
||
r"(?:(?:\*[ \t]*){3,}|(?:_[ \t]*){3,}|(?:-[ \t]*){3,}))",
|
||
content,
|
||
)
|
||
or opening_fence(content + "\n") is not None
|
||
or advance_html_block(content + "\n", None, paragraph_open=paragraph_open)[0]
|
||
)
|
||
paragraph_open = bool(content.strip()) and not structural
|
||
raw_html_structure = mask_raw_html_blocks(text)
|
||
return [
|
||
item
|
||
for item in definitions
|
||
if not (
|
||
item.start < len(text)
|
||
and not text[item.start].isspace()
|
||
and raw_html_structure[item.start] == " "
|
||
)
|
||
]
|
||
|
||
|
||
def scan_markdown_visibility(
|
||
text: str,
|
||
) -> tuple[str, str, list[FencedBlock], list[int]]:
|
||
"""Return fence-masked, reader-visible text and structural defects.
|
||
|
||
Markdown comments and fenced blocks are parsed in one pass so a fence-shaped
|
||
line inside an HTML comment is not treated as code, while literal ``<!--``
|
||
inside a real fenced block remains code. All masks preserve byte-independent
|
||
character offsets by replacing non-newline characters with spaces.
|
||
"""
|
||
|
||
lines = text.splitlines(keepends=True)
|
||
fence_masked = list(text)
|
||
reader_masked = list(text)
|
||
blocks: list[FencedBlock] = []
|
||
unclosed_comments: list[int] = []
|
||
offset = 0
|
||
open_start: int | None = None
|
||
open_line = 0
|
||
marker_char = ""
|
||
marker_size = 0
|
||
fence_quote_depth = 0
|
||
fence_close_mode = "top"
|
||
fence_list_indent = 0
|
||
comment_start: int | None = None
|
||
html_state: tuple[str, str] | None = None
|
||
indented_start: int | None = None
|
||
indented_line = 0
|
||
paragraph_open = False
|
||
list_context_indent = 0
|
||
inline_source = mask_closed_fence_candidates(text)
|
||
raw_html_structure = mask_raw_html_blocks(inline_source)
|
||
all_inline_spans = [
|
||
span
|
||
for span in inline_code_spans(inline_source)
|
||
if not (
|
||
span[0] < len(inline_source)
|
||
and not inline_source[span[0]].isspace()
|
||
and raw_html_structure[span[0]] == " "
|
||
)
|
||
]
|
||
non_comment_spans = list(all_inline_spans)
|
||
non_comment_spans.extend((start, end, "") for start, end in html_tag_spans(text))
|
||
non_comment_spans.extend(
|
||
(target_start, link_end, "")
|
||
for link_start, link_end, target_start, _, _ in markdown_inline_links(
|
||
text, include_images=True
|
||
)
|
||
if not (
|
||
link_start < len(text)
|
||
and not text[link_start].isspace()
|
||
and raw_html_structure[link_start] == " "
|
||
)
|
||
)
|
||
definitions = reference_definitions(text)
|
||
non_comment_spans.extend((item.start, item.end, "") for item in definitions)
|
||
|
||
def mask_range(target: list[str], start: int, end: int) -> None:
|
||
for index in range(start, end):
|
||
if target[index] not in "\r\n":
|
||
target[index] = " "
|
||
|
||
for number, line in enumerate(lines, start=1):
|
||
content = line.rstrip("\r\n")
|
||
is_blank = not content.strip()
|
||
indented_info = indented_code_container(line, list_context_indent)
|
||
is_indented_code = indented_info is not None
|
||
if open_start is not None:
|
||
if closing_fence(
|
||
line,
|
||
marker_char,
|
||
marker_size,
|
||
fence_quote_depth,
|
||
fence_close_mode,
|
||
fence_list_indent,
|
||
):
|
||
end = offset + len(line)
|
||
blocks.append(FencedBlock(open_start, end, open_line, text[open_start:end], True))
|
||
mask_range(fence_masked, open_start, end)
|
||
mask_range(reader_masked, open_start, end)
|
||
open_start = None
|
||
marker_char = ""
|
||
marker_size = 0
|
||
fence_quote_depth = 0
|
||
fence_close_mode = "top"
|
||
fence_list_indent = 0
|
||
paragraph_open = False
|
||
offset += len(line)
|
||
continue
|
||
if fence_container_continues(
|
||
line,
|
||
fence_quote_depth,
|
||
fence_close_mode,
|
||
fence_list_indent,
|
||
):
|
||
offset += len(line)
|
||
continue
|
||
blocks.append(
|
||
FencedBlock(
|
||
open_start,
|
||
offset,
|
||
open_line,
|
||
text[open_start:offset],
|
||
False,
|
||
)
|
||
)
|
||
mask_range(fence_masked, open_start, offset)
|
||
mask_range(reader_masked, open_start, offset)
|
||
open_start = None
|
||
marker_char = ""
|
||
marker_size = 0
|
||
fence_quote_depth = 0
|
||
fence_close_mode = "top"
|
||
fence_list_indent = 0
|
||
|
||
if indented_start is not None:
|
||
if is_indented_code or is_blank:
|
||
paragraph_open = False
|
||
offset += len(line)
|
||
continue
|
||
blocks.append(
|
||
FencedBlock(
|
||
indented_start,
|
||
offset,
|
||
indented_line,
|
||
text[indented_start:offset],
|
||
True,
|
||
"indented_code",
|
||
)
|
||
)
|
||
mask_range(fence_masked, indented_start, offset)
|
||
mask_range(reader_masked, indented_start, offset)
|
||
indented_start = None
|
||
indented_line = 0
|
||
|
||
if comment_start is None:
|
||
html_line, html_state = advance_html_block(
|
||
line, html_state, paragraph_open=paragraph_open
|
||
)
|
||
else:
|
||
html_line = True
|
||
if comment_start is None and not html_line:
|
||
opening = opening_fence(line, list_context_indent)
|
||
if opening:
|
||
(
|
||
marker_char,
|
||
marker_size,
|
||
fence_quote_depth,
|
||
fence_close_mode,
|
||
fence_list_indent,
|
||
) = opening
|
||
open_start = offset
|
||
open_line = number
|
||
paragraph_open = False
|
||
offset += len(line)
|
||
continue
|
||
if not paragraph_open and is_indented_code:
|
||
indented_start = offset
|
||
indented_line = number
|
||
paragraph_open = False
|
||
offset += len(line)
|
||
continue
|
||
|
||
cursor = offset
|
||
line_end = offset + len(line)
|
||
while cursor < line_end:
|
||
if comment_start is None:
|
||
opening = text.find("<!--", cursor, line_end)
|
||
if opening < 0:
|
||
break
|
||
if inside_any_span(opening, non_comment_spans):
|
||
cursor = opening + 4
|
||
continue
|
||
comment_start = opening
|
||
cursor = opening + 4
|
||
closing = text.find("-->", cursor, line_end)
|
||
if closing < 0:
|
||
break
|
||
comment_end = closing + 3
|
||
mask_range(reader_masked, comment_start, comment_end)
|
||
comment_start = None
|
||
cursor = comment_end
|
||
list_context_indent = list_continuation_indent(line, list_context_indent)
|
||
paragraph_open = bool(content.strip()) and not (
|
||
html_line
|
||
or comment_start is not None
|
||
or re.match(
|
||
r"^[ ]{0,3}(?:#{1,6}(?:[ \t]+|$)|(?:=+|-+)[ \t]*$|"
|
||
r"(?:(?:\*[ \t]*){3,}|(?:_[ \t]*){3,}|(?:-[ \t]*){3,}))",
|
||
content,
|
||
)
|
||
)
|
||
offset += len(line)
|
||
if indented_start is not None:
|
||
blocks.append(
|
||
FencedBlock(
|
||
indented_start,
|
||
len(text),
|
||
indented_line,
|
||
text[indented_start:],
|
||
True,
|
||
"indented_code",
|
||
)
|
||
)
|
||
mask_range(fence_masked, indented_start, len(text))
|
||
mask_range(reader_masked, indented_start, len(text))
|
||
if open_start is not None:
|
||
blocks.append(
|
||
FencedBlock(
|
||
open_start,
|
||
len(text),
|
||
open_line,
|
||
text[open_start:],
|
||
False,
|
||
)
|
||
)
|
||
mask_range(fence_masked, open_start, len(text))
|
||
mask_range(reader_masked, open_start, len(text))
|
||
if comment_start is not None:
|
||
unclosed_comments.append(comment_start)
|
||
mask_range(reader_masked, comment_start, len(text))
|
||
return (
|
||
"".join(fence_masked),
|
||
"".join(reader_masked),
|
||
blocks,
|
||
unclosed_comments,
|
||
)
|
||
|
||
|
||
def mask_fenced_blocks(text: str) -> tuple[str, list[FencedBlock]]:
|
||
fence_masked, _, blocks, _ = scan_markdown_visibility(text)
|
||
return fence_masked, blocks
|
||
|
||
|
||
def mask_raw_html_blocks(text: str) -> str:
|
||
"""Mask raw HTML block source for Markdown structural parsing only."""
|
||
|
||
masked = list(text)
|
||
state: tuple[str, str] | None = None
|
||
paragraph_open = False
|
||
offset = 0
|
||
for line in text.splitlines(keepends=True):
|
||
html_line, state = advance_html_block(
|
||
line, state, paragraph_open=paragraph_open
|
||
)
|
||
if html_line:
|
||
for index in range(offset, offset + len(line)):
|
||
if masked[index] not in "\r\n":
|
||
masked[index] = " "
|
||
paragraph_open = False
|
||
else:
|
||
content = line.rstrip("\r\n")
|
||
paragraph_open = bool(content.strip()) and not re.match(
|
||
r"^[ ]{0,3}(?:#{1,6}(?:[ \t]+|$)|(?:=+|-+)[ \t]*$|"
|
||
r"(?:(?:\*[ \t]*){3,}|(?:_[ \t]*){3,}|(?:-[ \t]*){3,}))",
|
||
content,
|
||
)
|
||
offset += len(line)
|
||
return "".join(masked)
|
||
|
||
|
||
def mask_raw_code_html_blocks(text: str) -> str:
|
||
"""Mask raw HTML containers whose contents are code/data, not prose."""
|
||
|
||
masked = list(text)
|
||
active_tag: str | None = None
|
||
offset = 0
|
||
opener = re.compile(
|
||
r"^[ ]{0,3}<(?P<tag>pre|script|style|textarea)(?:[ \t>]|$)",
|
||
re.IGNORECASE,
|
||
)
|
||
for line in text.splitlines(keepends=True):
|
||
content = line.rstrip("\r\n")
|
||
structural, _ = strip_blockquotes(content)
|
||
list_item = re.match(
|
||
r"^[ \t]*(?:[-+*]|[0-9]{1,9}[.)])[ \t]+",
|
||
structural,
|
||
)
|
||
if list_item:
|
||
structural = structural[list_item.end() :]
|
||
match = opener.match(structural) if active_tag is None else None
|
||
if match is not None:
|
||
active_tag = match.group("tag").casefold()
|
||
if active_tag is not None:
|
||
for index in range(offset, offset + len(line)):
|
||
if masked[index] not in "\r\n":
|
||
masked[index] = " "
|
||
if re.search(
|
||
rf"</{re.escape(active_tag)}[ \t]*>",
|
||
structural,
|
||
re.IGNORECASE,
|
||
):
|
||
active_tag = None
|
||
offset += len(line)
|
||
return "".join(masked)
|
||
|
||
|
||
def github_slug(value: str) -> str:
|
||
# GitHub derives an anchor from the rendered heading label, not from link
|
||
# destinations or HTML attributes that happen to occur in the source.
|
||
value = rendered_markdown_label(value)
|
||
value = unicodedata.normalize("NFKC", value).strip().lower()
|
||
value = re.sub(r"<[^>]+>", "", value)
|
||
value = value.replace("`", "")
|
||
value = re.sub(r"[^\w\- ]+", "", value, flags=re.UNICODE)
|
||
value = re.sub(r"\s+", "-", value).strip("-")
|
||
return value
|
||
|
||
|
||
def markdown_inline_links(
|
||
text: str,
|
||
*,
|
||
include_images: bool = False,
|
||
) -> list[tuple[int, int, int, int, str]]:
|
||
"""Return link/open-close and balanced destination spans (images excluded)."""
|
||
|
||
links: list[tuple[int, int, int, int, str]] = []
|
||
position = 0
|
||
|
||
def escaped(index: int) -> bool:
|
||
count = 0
|
||
index -= 1
|
||
while index >= 0 and text[index] == "\\":
|
||
count += 1
|
||
index -= 1
|
||
return count % 2 == 1
|
||
|
||
def skip_space(index: int) -> int | None:
|
||
newlines = 0
|
||
while index < len(text) and text[index] in " \t\r\n":
|
||
if text[index] == "\n":
|
||
newlines += 1
|
||
if newlines > 1:
|
||
return None
|
||
index += 1
|
||
return index
|
||
|
||
while position < len(text):
|
||
label_start = text.find("[", position)
|
||
if label_start < 0:
|
||
break
|
||
is_image = (
|
||
label_start > 0
|
||
and text[label_start - 1] == "!"
|
||
and not escaped(label_start - 1)
|
||
)
|
||
if escaped(label_start) or (is_image and not include_images):
|
||
position = label_start + 1
|
||
continue
|
||
depth = 1
|
||
cursor = label_start + 1
|
||
while cursor < len(text) and depth:
|
||
if text[cursor] == "\\" and cursor + 1 < len(text):
|
||
cursor += 2
|
||
continue
|
||
if text[cursor] == "[":
|
||
depth += 1
|
||
elif text[cursor] == "]":
|
||
depth -= 1
|
||
cursor += 1
|
||
if depth or cursor >= len(text) or text[cursor] != "(":
|
||
position = label_start + 1
|
||
continue
|
||
label_end = cursor - 1
|
||
raw_label = text[label_start + 1 : label_end]
|
||
if crosses_paragraph_boundary(text, label_start + 1, label_end):
|
||
position = label_start + 1
|
||
continue
|
||
cursor = skip_space(cursor + 1)
|
||
if cursor is None or cursor >= len(text):
|
||
position = label_start + 1
|
||
continue
|
||
if text[cursor] == "<":
|
||
destination_start = cursor + 1
|
||
destination_end = destination_start
|
||
while destination_end < len(text) and text[destination_end] not in ">\r\n":
|
||
if text[destination_end] == "\\" and destination_end + 1 < len(text):
|
||
destination_end += 2
|
||
else:
|
||
destination_end += 1
|
||
if destination_end >= len(text) or text[destination_end] != ">":
|
||
position = label_start + 1
|
||
continue
|
||
tail = destination_end + 1
|
||
else:
|
||
destination_start = cursor
|
||
depth = 0
|
||
while cursor < len(text):
|
||
character = text[cursor]
|
||
if character == "\\" and cursor + 1 < len(text):
|
||
cursor += 2
|
||
continue
|
||
if character == "(":
|
||
depth += 1
|
||
elif character == ")":
|
||
if depth == 0:
|
||
destination_end = cursor
|
||
links.append(
|
||
(
|
||
label_start,
|
||
cursor + 1,
|
||
destination_start,
|
||
destination_end,
|
||
raw_label,
|
||
)
|
||
)
|
||
position = cursor + 1
|
||
break
|
||
depth -= 1
|
||
elif character in " \t\r\n" and depth == 0:
|
||
destination_end = cursor
|
||
tail = cursor
|
||
break
|
||
cursor += 1
|
||
else:
|
||
position = label_start + 1
|
||
continue
|
||
if links and links[-1][0] == label_start:
|
||
continue
|
||
if cursor >= len(text):
|
||
position = label_start + 1
|
||
continue
|
||
|
||
unspaced_tail = tail
|
||
spaced_tail = skip_space(tail)
|
||
if spaced_tail is None:
|
||
position = label_start + 1
|
||
continue
|
||
tail = spaced_tail
|
||
if tail < len(text) and text[tail] in "\"'(":
|
||
if tail == unspaced_tail:
|
||
position = label_start + 1
|
||
continue
|
||
opener = text[tail]
|
||
quote = ")" if opener == "(" else opener
|
||
tail += 1
|
||
title_start = tail
|
||
while tail < len(text) and text[tail] != quote:
|
||
if text[tail] == "\\" and tail + 1 < len(text):
|
||
tail += 2
|
||
continue
|
||
if opener == "(" and text[tail] == "(":
|
||
break
|
||
if re.match(r"\r?\n[ \t]*\r?\n", text[tail:]):
|
||
break
|
||
tail += 1
|
||
if tail >= len(text) or text[tail] != quote:
|
||
position = label_start + 1
|
||
continue
|
||
if re.search(r"\r?\n[ \t]*\r?\n", text[title_start:tail]):
|
||
position = label_start + 1
|
||
continue
|
||
tail += 1
|
||
while tail < len(text) and text[tail] in " \t":
|
||
tail += 1
|
||
if tail < len(text) and text[tail] == ")":
|
||
links.append(
|
||
(
|
||
label_start,
|
||
tail + 1,
|
||
destination_start,
|
||
destination_end,
|
||
raw_label,
|
||
)
|
||
)
|
||
position = tail + 1
|
||
else:
|
||
position = label_start + 1
|
||
return links
|
||
|
||
|
||
def markdown_reference_links(
|
||
text: str,
|
||
definitions: Iterable[ReferenceDefinition] | None = None,
|
||
*,
|
||
include_images: bool = False,
|
||
) -> list[ReferenceLink]:
|
||
"""Return full, collapsed, and resolvable shortcut reference links."""
|
||
|
||
parsed_definitions = list(definitions or reference_definitions(text))
|
||
known = {item.identifier for item in parsed_definitions}
|
||
definition_spans = [(item.start, item.end) for item in parsed_definitions]
|
||
links: list[ReferenceLink] = []
|
||
position = 0
|
||
while position < len(text):
|
||
label_start = text.find("[", position)
|
||
if label_start < 0:
|
||
break
|
||
is_image = (
|
||
label_start > 0
|
||
and text[label_start - 1] == "!"
|
||
and not is_backslash_escaped(text, label_start - 1)
|
||
)
|
||
if is_backslash_escaped(text, label_start) or (
|
||
is_image and not include_images
|
||
):
|
||
position = label_start + 1
|
||
continue
|
||
if any(start <= label_start < end for start, end in definition_spans):
|
||
position = label_start + 1
|
||
continue
|
||
depth = 1
|
||
cursor = label_start + 1
|
||
while cursor < len(text) and depth:
|
||
if text[cursor] == "\\" and cursor + 1 < len(text):
|
||
cursor += 2
|
||
continue
|
||
if text[cursor] == "[":
|
||
depth += 1
|
||
elif text[cursor] == "]":
|
||
depth -= 1
|
||
cursor += 1
|
||
if depth:
|
||
break
|
||
label_end = cursor - 1
|
||
raw_label = text[label_start + 1 : label_end]
|
||
if crosses_paragraph_boundary(text, label_start + 1, label_end):
|
||
position = label_start + 1
|
||
continue
|
||
if cursor < len(text) and text[cursor] == "(":
|
||
position = cursor + 1
|
||
continue
|
||
|
||
if cursor < len(text) and text[cursor] == "[":
|
||
identifier_start = cursor + 1
|
||
identifier_end = identifier_start
|
||
while identifier_end < len(text):
|
||
if text[identifier_end] == "]" and not is_backslash_escaped(
|
||
text, identifier_end
|
||
):
|
||
break
|
||
if text[identifier_end] in "\r\n":
|
||
break
|
||
identifier_end += 1
|
||
if identifier_end >= len(text) or text[identifier_end] != "]":
|
||
position = label_start + 1
|
||
continue
|
||
raw_identifier = text[identifier_start:identifier_end] or raw_label
|
||
identifier = normalize_reference_identifier(raw_identifier)
|
||
if identifier not in known:
|
||
position = identifier_end + 1
|
||
continue
|
||
links.append(
|
||
ReferenceLink(
|
||
label_start,
|
||
identifier_end + 1,
|
||
cursor,
|
||
identifier_end + 1,
|
||
raw_label,
|
||
identifier,
|
||
)
|
||
)
|
||
position = identifier_end + 1
|
||
continue
|
||
|
||
identifier = normalize_reference_identifier(raw_label)
|
||
if identifier in known:
|
||
links.append(
|
||
ReferenceLink(
|
||
label_start,
|
||
label_end + 1,
|
||
label_end + 1,
|
||
label_end + 1,
|
||
raw_label,
|
||
identifier,
|
||
)
|
||
)
|
||
position = label_end + 1
|
||
return links
|
||
|
||
|
||
def mask_markdown_metadata(text: str) -> str:
|
||
"""Mask non-rendered destinations/tags while preserving visible labels/code."""
|
||
|
||
masked = list(text)
|
||
inline_spans = inline_code_spans(text)
|
||
raw_html_structure = mask_raw_html_blocks(text)
|
||
|
||
def inside_raw_html_block(position: int) -> bool:
|
||
return (
|
||
0 <= position < len(text)
|
||
and not text[position].isspace()
|
||
and raw_html_structure[position] == " "
|
||
)
|
||
|
||
for link_start, link_end, target_start, _, _ in markdown_inline_links(
|
||
text, include_images=True
|
||
):
|
||
if inside_any_span(link_start, inline_spans) or inside_raw_html_block(
|
||
link_start
|
||
):
|
||
continue
|
||
metadata_start = text.rfind("(", link_start, target_start)
|
||
for index in range(metadata_start, link_end):
|
||
if masked[index] not in "\r\n":
|
||
masked[index] = " "
|
||
definitions = reference_definitions(text)
|
||
for definition in definitions:
|
||
if inside_any_span(definition.start, inline_spans) or inside_raw_html_block(
|
||
definition.start
|
||
):
|
||
continue
|
||
for index in range(definition.start, definition.end):
|
||
if masked[index] not in "\r\n":
|
||
masked[index] = " "
|
||
for link in markdown_reference_links(text, definitions, include_images=True):
|
||
if inside_any_span(link.start, inline_spans) or inside_raw_html_block(
|
||
link.start
|
||
):
|
||
continue
|
||
for index in range(link.metadata_start, link.metadata_end):
|
||
if masked[index] not in "\r\n":
|
||
masked[index] = " "
|
||
for start, end in html_tag_spans(text):
|
||
if inside_any_span(start, inline_spans):
|
||
continue
|
||
for index in range(start, end):
|
||
if masked[index] not in "\r\n":
|
||
masked[index] = " "
|
||
return "".join(masked)
|
||
|
||
|
||
def rendered_search_projection(
|
||
text: str,
|
||
*,
|
||
include_inline_code_text: bool = False,
|
||
) -> tuple[str, list[int]]:
|
||
"""Return reader-visible searchable prose plus source-offset mapping."""
|
||
|
||
projected = mask_markdown_metadata(text)
|
||
removed: set[int] = set()
|
||
inline_spans = inline_code_spans(text)
|
||
|
||
def remove_label_delimiters(start: int, close: int, image: bool) -> None:
|
||
removed.add(start)
|
||
removed.add(close)
|
||
if image and start > 0:
|
||
removed.add(start - 1)
|
||
|
||
for link_start, _, target_start, _, _ in markdown_inline_links(
|
||
text, include_images=True
|
||
):
|
||
if inside_any_span(link_start, inline_spans):
|
||
continue
|
||
close = text.rfind("]", link_start, target_start)
|
||
if close > link_start:
|
||
remove_label_delimiters(
|
||
link_start,
|
||
close,
|
||
link_start > 0
|
||
and text[link_start - 1] == "!"
|
||
and not is_backslash_escaped(text, link_start - 1),
|
||
)
|
||
definitions = reference_definitions(text)
|
||
for link in markdown_reference_links(text, definitions, include_images=True):
|
||
if inside_any_span(link.start, inline_spans):
|
||
continue
|
||
close = text.find("]", link.start + 1, link.end)
|
||
if close > link.start:
|
||
remove_label_delimiters(
|
||
link.start,
|
||
close,
|
||
link.start > 0
|
||
and text[link.start - 1] == "!"
|
||
and not is_backslash_escaped(text, link.start - 1),
|
||
)
|
||
|
||
for marker in ("***", "___", "**", "__", "~~", "*", "_"):
|
||
pattern = re.compile(
|
||
rf"{re.escape(marker)}(?=\S)"
|
||
rf"(?P<body>(?:(?!\r?\n[ \t]*\r?\n).)+?)"
|
||
rf"(?<=\S){re.escape(marker)}",
|
||
re.DOTALL,
|
||
)
|
||
for match in pattern.finditer(text):
|
||
close_start = match.end() - len(marker)
|
||
if is_backslash_escaped(text, match.start()) or is_backslash_escaped(
|
||
text, close_start
|
||
):
|
||
continue
|
||
if inside_any_span(match.start(), inline_spans) or crosses_paragraph_boundary(
|
||
text, match.start() + len(marker), close_start
|
||
):
|
||
continue
|
||
if marker.startswith("_") and (
|
||
(match.start() > 0 and text[match.start() - 1].isalnum())
|
||
or (match.end() < len(text) and text[match.end()].isalnum())
|
||
):
|
||
continue
|
||
removed.update(range(match.start(), match.start() + len(marker)))
|
||
removed.update(range(close_start, match.end()))
|
||
|
||
if include_inline_code_text:
|
||
for start, end, _ in inline_spans:
|
||
opening = re.match(r"`+", text[start:end])
|
||
closing = re.search(r"`+$", text[start:end])
|
||
if opening is None or closing is None:
|
||
continue
|
||
removed.update(range(start, start + opening.end()))
|
||
removed.update(range(start + closing.start(), end))
|
||
|
||
stripped_chars: list[str] = []
|
||
stripped_offsets: list[int] = []
|
||
for index, character in enumerate(projected):
|
||
if index not in removed:
|
||
stripped_chars.append(character)
|
||
stripped_offsets.append(index)
|
||
decoded, decoded_offsets = decode_html_entities_with_offsets(
|
||
"".join(stripped_chars)
|
||
)
|
||
return decoded, [stripped_offsets[index] for index in decoded_offsets]
|
||
|
||
|
||
def resolve_reference_labels(text: str, known: set[str]) -> str:
|
||
"""Replace resolvable reference syntax with its reader-visible label."""
|
||
|
||
def full(match: re.Match[str]) -> str:
|
||
bracket = match.start() + (1 if match.group(0).startswith("!") else 0)
|
||
if is_backslash_escaped(text, bracket):
|
||
return match.group(0)
|
||
label = match.group("label")
|
||
identifier = match.group("identifier") or label
|
||
return label if normalize_reference_identifier(identifier) in known else match.group(0)
|
||
|
||
text = re.sub(
|
||
r"!?\[(?P<label>[^\]\r\n]+)\]\[(?P<identifier>[^\]\r\n]*)\]",
|
||
full,
|
||
text,
|
||
)
|
||
|
||
def shortcut(match: re.Match[str]) -> str:
|
||
bracket = match.start() + (1 if match.group(0).startswith("!") else 0)
|
||
if is_backslash_escaped(text, bracket):
|
||
return match.group(0)
|
||
label = match.group("label")
|
||
return label if normalize_reference_identifier(label) in known else match.group(0)
|
||
|
||
return re.sub(r"!?\[(?P<label>[^\]\r\n]+)\]", shortcut, text)
|
||
|
||
|
||
def rendered_markdown_label(text: str) -> str:
|
||
"""Return a stable approximation of text a reader sees in a heading/label."""
|
||
|
||
value, _ = decode_html_entities_with_offsets(mask_markdown_metadata(text))
|
||
escaped: list[str] = []
|
||
|
||
def protect_escape(match: re.Match[str]) -> str:
|
||
escaped.append(match.group(1))
|
||
return chr(0xE000 + len(escaped) - 1)
|
||
|
||
value = re.sub(
|
||
r"\\([!\"#$%&'()*+,./:;<=>?@\[\\\]^_`{|}~-])",
|
||
protect_escape,
|
||
value,
|
||
)
|
||
value = re.sub(r"!\[([^\]\r\n]*)\]", r"\1", value)
|
||
value = re.sub(r"\[([^\]\r\n]+)\]", r"\1", value)
|
||
value = re.sub(r"(?<!\w)_{1,3}(?=\S)", "", value)
|
||
value = re.sub(r"(?<=\S)_{1,3}(?!\w)", "", value)
|
||
value = re.sub(r"[`*~]", "", value)
|
||
for index, character in enumerate(escaped):
|
||
value = value.replace(chr(0xE000 + index), character)
|
||
return " ".join(value.split())
|
||
|
||
|
||
def decode_html_entities_with_offsets(text: str) -> tuple[str, list[int]]:
|
||
"""Decode reader-visible entities and map decoded indices to source offsets."""
|
||
|
||
output: list[str] = []
|
||
offsets: list[int] = []
|
||
cursor = 0
|
||
pattern = re.compile(r"&(?:#[xX][0-9A-Fa-f]+|#[0-9]+|[A-Za-z][A-Za-z0-9]+);")
|
||
for match in pattern.finditer(text):
|
||
output.append(text[cursor : match.start()])
|
||
offsets.extend(range(cursor, match.start()))
|
||
decoded = html.unescape(match.group(0))
|
||
if decoded == match.group(0):
|
||
output.append(match.group(0))
|
||
offsets.extend(range(match.start(), match.end()))
|
||
else:
|
||
output.append(decoded)
|
||
offsets.extend([match.start()] * len(decoded))
|
||
cursor = match.end()
|
||
output.append(text[cursor:])
|
||
offsets.extend(range(cursor, len(text)))
|
||
return "".join(output), offsets
|
||
|
||
|
||
def mask_inline_code(text: str) -> str:
|
||
masked = list(text)
|
||
raw_html_structure = mask_raw_html_blocks(text)
|
||
for start, end, _ in inline_code_spans(text):
|
||
if (
|
||
start < len(text)
|
||
and not text[start].isspace()
|
||
and raw_html_structure[start] == " "
|
||
):
|
||
continue
|
||
for index in range(start, end):
|
||
if masked[index] not in "\r\n":
|
||
masked[index] = " "
|
||
return "".join(masked)
|
||
|
||
|
||
def parse_headings(visible: str) -> list[Heading]:
|
||
starts = line_starts(visible)
|
||
structural = visible
|
||
nested_lines: set[int] = set()
|
||
list_indent = 0
|
||
line_offset = 0
|
||
for line in structural.splitlines(keepends=True):
|
||
content = line.rstrip("\r\n")
|
||
rest, quote_depth = strip_blockquotes(content)
|
||
leading = re.match(r"^[ \t]*", rest)
|
||
columns = indentation_columns(leading.group(0) if leading else "")
|
||
if quote_depth or (list_indent and columns >= list_indent):
|
||
nested_lines.add(line_offset)
|
||
list_indent = list_continuation_indent(line, list_indent)
|
||
line_offset += len(line)
|
||
raw_headings: list[tuple[int, int, int, str]] = []
|
||
atx_spans: list[tuple[int, int]] = []
|
||
pattern = re.compile(
|
||
r"^[ ]{0,3}(#{1,6})[ \t]+(.+?)(?:[ \t]+#+[ \t]*)?$",
|
||
re.MULTILINE,
|
||
)
|
||
for match in pattern.finditer(structural):
|
||
if match.start() in nested_lines:
|
||
continue
|
||
title = match.group(2).strip()
|
||
raw_headings.append((match.start(), match.end(), len(match.group(1)), title))
|
||
atx_spans.append(match.span())
|
||
structural_lines = structural.splitlines(keepends=True)
|
||
structural_offsets: list[int] = []
|
||
structural_offset = 0
|
||
for line in structural_lines:
|
||
structural_offsets.append(structural_offset)
|
||
structural_offset += len(line)
|
||
setext_underline = re.compile(r"^[ ]{0,3}(?P<underline>=+|-+)[ \t]*$")
|
||
prior_block = re.compile(
|
||
r"^[ ]{0,3}(?:#{1,6}(?:[ \t]+|$)|>|[-+*][ \t]+|"
|
||
r"`{3,}|~{3,}|"
|
||
r"(?:(?:\*[ \t]*){3,}|(?:_[ \t]*){3,}))"
|
||
)
|
||
for index, line in enumerate(structural_lines):
|
||
content = line.rstrip("\r\n")
|
||
underline = setext_underline.fullmatch(content)
|
||
if underline is None or index == 0:
|
||
continue
|
||
underline_start = structural_offsets[index]
|
||
if underline_start in nested_lines:
|
||
continue
|
||
title_indexes: list[int] = []
|
||
previous = index - 1
|
||
while previous >= 0:
|
||
previous_content = structural_lines[previous].rstrip("\r\n")
|
||
previous_start = structural_offsets[previous]
|
||
ordered_marker = re.match(
|
||
r"^[ ]{0,3}[0-9]{1,9}[.)][ \t]+(?=\S)",
|
||
previous_content,
|
||
)
|
||
ordered_starts_block = bool(
|
||
ordered_marker
|
||
and (
|
||
ordered_list_interrupts_paragraph(previous_content)
|
||
or previous == 0
|
||
or not structural_lines[previous - 1].strip()
|
||
or prior_block.match(
|
||
structural_lines[previous - 1].rstrip("\r\n")
|
||
)
|
||
or re.match(
|
||
r"^[ ]{0,3}[0-9]{1,9}[.)][ \t]+(?=\S)",
|
||
structural_lines[previous - 1].rstrip("\r\n"),
|
||
)
|
||
)
|
||
)
|
||
if (
|
||
not previous_content.strip()
|
||
or previous_start in nested_lines
|
||
or prior_block.match(previous_content)
|
||
or ordered_starts_block
|
||
or setext_underline.fullmatch(previous_content)
|
||
or len(previous_content) - len(previous_content.lstrip(" ")) > 3
|
||
):
|
||
break
|
||
title_indexes.append(previous)
|
||
previous -= 1
|
||
if not title_indexes:
|
||
continue
|
||
title_indexes.reverse()
|
||
heading_start = structural_offsets[title_indexes[0]]
|
||
if any(start <= heading_start < end for start, end in atx_spans):
|
||
continue
|
||
title = "\n".join(
|
||
structural_lines[item].rstrip("\r\n").strip()
|
||
for item in title_indexes
|
||
)
|
||
raw_headings.append(
|
||
(
|
||
heading_start,
|
||
underline_start + len(line.rstrip("\r\n")),
|
||
1 if underline.group("underline").startswith("=") else 2,
|
||
title,
|
||
)
|
||
)
|
||
|
||
headings: list[Heading] = []
|
||
used_slugs: set[str] = set()
|
||
known_references = {
|
||
item.identifier for item in reference_definitions(structural)
|
||
}
|
||
for start, end, level, title in sorted(raw_headings):
|
||
title = resolve_reference_labels(title, known_references)
|
||
base = github_slug(title)
|
||
slug = base
|
||
suffix = 1
|
||
while slug in used_slugs:
|
||
slug = f"{base}-{suffix}"
|
||
suffix += 1
|
||
used_slugs.add(slug)
|
||
line, _ = location(starts, start)
|
||
headings.append(Heading(level, title, line, start, end, slug))
|
||
return headings
|
||
|
||
|
||
def paragraph_spans(visible: str) -> list[TextSpan]:
|
||
# A pipe table is a comparison structure, not one long prose paragraph.
|
||
# Mask complete GFM-style table blocks while preserving offsets/newlines so
|
||
# surrounding prose still receives its normal paragraph budget.
|
||
table_masked = list(visible)
|
||
lines = visible.splitlines(keepends=True)
|
||
line_offsets: list[int] = []
|
||
offset = 0
|
||
for line in lines:
|
||
line_offsets.append(offset)
|
||
offset += len(line)
|
||
|
||
def is_delimiter_row(line: str) -> bool:
|
||
stripped = line.rstrip("\r\n").strip()
|
||
if "|" not in stripped:
|
||
return False
|
||
cells = stripped.strip("|").split("|")
|
||
return bool(cells) and all(
|
||
re.fullmatch(r"[ \t]*:?-{3,}:?[ \t]*", cell) for cell in cells
|
||
)
|
||
|
||
def normalized_table_line(
|
||
line: str,
|
||
expected: tuple[int, int] | None = None,
|
||
) -> tuple[str, tuple[int, int]] | None:
|
||
content = line.rstrip("\r\n")
|
||
rest, quote_depth = strip_blockquotes(content)
|
||
if expected is not None and quote_depth != expected[0]:
|
||
return None
|
||
item = re.match(
|
||
r"^(?P<prefix>[ \t]*(?:[-+*]|[0-9]{1,9}[.)])[ \t]+)",
|
||
rest,
|
||
)
|
||
if item is not None:
|
||
list_indent = indentation_columns(item.group("prefix"))
|
||
if expected is not None and expected[1] not in {0, list_indent}:
|
||
return None
|
||
rest = rest[item.end() :]
|
||
elif expected is not None and expected[1]:
|
||
leading = re.match(r"^[ \t]*", rest)
|
||
raw_indent = leading.group(0) if leading else ""
|
||
if indentation_columns(raw_indent) < expected[1]:
|
||
return None
|
||
consumed = 0
|
||
columns = 0
|
||
while consumed < len(raw_indent) and columns < expected[1]:
|
||
columns += (
|
||
4 - (columns % 4)
|
||
if raw_indent[consumed] == "\t"
|
||
else 1
|
||
)
|
||
consumed += 1
|
||
rest = rest[consumed:]
|
||
list_indent = expected[1]
|
||
else:
|
||
list_indent = 0
|
||
return rest, (quote_depth, list_indent)
|
||
|
||
index = 0
|
||
while index + 1 < len(lines):
|
||
normalized_header = normalized_table_line(lines[index])
|
||
if normalized_header is None:
|
||
index += 1
|
||
continue
|
||
header, container = normalized_header
|
||
normalized_delimiter = normalized_table_line(lines[index + 1], container)
|
||
if (
|
||
"|" not in header
|
||
or normalized_delimiter is None
|
||
or not is_delimiter_row(normalized_delimiter[0])
|
||
):
|
||
index += 1
|
||
continue
|
||
end_index = index + 2
|
||
while end_index < len(lines):
|
||
normalized_row = normalized_table_line(lines[end_index], container)
|
||
if (
|
||
normalized_row is None
|
||
or not normalized_row[0].strip()
|
||
or "|" not in normalized_row[0]
|
||
):
|
||
break
|
||
end_index += 1
|
||
block_start = line_offsets[index]
|
||
block_end = line_offsets[end_index] if end_index < len(lines) else len(visible)
|
||
for position in range(block_start, block_end):
|
||
if table_masked[position] not in "\r\n":
|
||
table_masked[position] = " "
|
||
index = end_index
|
||
|
||
visible = "".join(table_masked)
|
||
headings = parse_headings(mask_raw_html_blocks(visible))
|
||
|
||
# Thematic breaks are block separators, not prose. A `---` line that is
|
||
# already part of a parsed Setext heading remains part of that heading.
|
||
block_masked = list(visible)
|
||
quote_boundaries: list[int] = []
|
||
quote_active = False
|
||
previous_nonblank = False
|
||
offset = 0
|
||
thematic = re.compile(
|
||
r"^[ ]{0,3}(?:(?:\*[ \t]*){3,}|(?:_[ \t]*){3,}|(?:-[ \t]*){3,})$"
|
||
)
|
||
for line in visible.splitlines(keepends=True):
|
||
content = line.rstrip("\r\n")
|
||
rest, quote_depth = strip_blockquotes(content)
|
||
in_heading = any(
|
||
heading.start <= offset < heading.end for heading in headings
|
||
)
|
||
if not in_heading and thematic.fullmatch(rest):
|
||
for position in range(offset, offset + len(line)):
|
||
if block_masked[position] not in "\r\n":
|
||
block_masked[position] = " "
|
||
quote_active = False
|
||
previous_nonblank = False
|
||
offset += len(line)
|
||
continue
|
||
if not content.strip():
|
||
quote_active = False
|
||
previous_nonblank = False
|
||
elif quote_depth:
|
||
if not quote_active and previous_nonblank:
|
||
quote_boundaries.append(offset)
|
||
quote_active = True
|
||
previous_nonblank = True
|
||
else:
|
||
# An unmarked line immediately following a blockquote is a lazy
|
||
# continuation until a blank/block boundary proves otherwise.
|
||
previous_nonblank = True
|
||
offset += len(line)
|
||
|
||
visible = "".join(block_masked)
|
||
starts = line_starts(visible)
|
||
paragraphs: list[TextSpan] = []
|
||
|
||
def append_segment(start: int, end: int) -> None:
|
||
body = visible[start:end]
|
||
stripped = body.strip()
|
||
if not stripped:
|
||
return
|
||
leading = len(body) - len(body.lstrip())
|
||
segment_start = start + leading
|
||
if re.fullmatch(r"(?:[ \t]*<!--.*?-->[ \t]*\n?)+", stripped, re.DOTALL):
|
||
return
|
||
if all(
|
||
not line.strip()
|
||
or re.match(r"^\s*(?:<!--|-->|\|?\s*:?-{3,})", line)
|
||
for line in stripped.splitlines()
|
||
):
|
||
return
|
||
line, _ = location(starts, segment_start)
|
||
paragraphs.append(TextSpan(segment_start, end, line, visible[segment_start:end]))
|
||
|
||
def append_with_lists(start: int, end: int) -> None:
|
||
body = visible[start:end]
|
||
list_items = list(
|
||
re.finditer(r"(?m)^(?P<indent>[ \t]*)(?:[-+*]|\d+[.)])[ \t]+", body)
|
||
)
|
||
if not list_items:
|
||
append_segment(start, end)
|
||
return
|
||
minimum_indent = min(len(item.group("indent")) for item in list_items)
|
||
boundaries = [
|
||
item.start()
|
||
for item in list_items
|
||
if len(item.group("indent")) == minimum_indent
|
||
]
|
||
if boundaries[0] > 0:
|
||
append_segment(start, start + boundaries[0])
|
||
for index, boundary in enumerate(boundaries):
|
||
item_end = boundaries[index + 1] if index + 1 < len(boundaries) else len(body)
|
||
append_segment(start + boundary, start + item_end)
|
||
|
||
def append_with_headings(start: int, end: int) -> None:
|
||
block_headings = [
|
||
heading
|
||
for heading in headings
|
||
if start <= heading.start and heading.end <= end
|
||
]
|
||
cursor = start
|
||
for heading in block_headings:
|
||
append_with_lists(cursor, heading.start)
|
||
append_segment(heading.start, heading.end)
|
||
cursor = heading.end
|
||
append_with_lists(cursor, end)
|
||
|
||
for match in re.finditer(r"(?:^|\n[ \t]*\n)(?P<body>.*?)(?=\n[ \t]*\n|\Z)", visible, re.DOTALL):
|
||
body_start, body_end = match.span("body")
|
||
cursor = body_start
|
||
for boundary in quote_boundaries:
|
||
if body_start < boundary < body_end:
|
||
append_with_headings(cursor, boundary)
|
||
cursor = boundary
|
||
append_with_headings(cursor, body_end)
|
||
return paragraphs
|
||
|
||
|
||
def sentence_spans(paragraph: TextSpan) -> list[TextSpan]:
|
||
spans: list[TextSpan] = []
|
||
cursor = 0
|
||
pattern = re.compile(r"[.!?。!?]+(?:[\"'”’)]*)\s+")
|
||
for match in pattern.finditer(paragraph.text):
|
||
end = match.end()
|
||
if paragraph.text[cursor:end].strip():
|
||
spans.append(
|
||
TextSpan(
|
||
paragraph.start + cursor,
|
||
paragraph.start + end,
|
||
paragraph.line,
|
||
paragraph.text[cursor:end],
|
||
)
|
||
)
|
||
cursor = end
|
||
if paragraph.text[cursor:].strip():
|
||
spans.append(
|
||
TextSpan(
|
||
paragraph.start + cursor,
|
||
paragraph.end,
|
||
paragraph.line,
|
||
paragraph.text[cursor:],
|
||
)
|
||
)
|
||
return spans
|
||
|
||
|
||
def normalize_space(value: str) -> str:
|
||
return " ".join(value.split())
|
||
|
||
|
||
def normalize_term_name(value: str) -> str:
|
||
return unicodedata.normalize("NFKC", normalize_space(value)).casefold()
|
||
|
||
|
||
def find_flexible(haystack: str, needle: str) -> re.Match[str] | None:
|
||
parts = needle.split()
|
||
if not parts:
|
||
return None
|
||
pattern = r"\s+".join(re.escape(part) for part in parts)
|
||
if re.fullmatch(r"[A-Za-z0-9_$.-]+", needle):
|
||
pattern = rf"(?<![A-Za-z0-9_$]){pattern}(?![A-Za-z0-9_$])"
|
||
return re.search(pattern, haystack)
|
||
|
||
|
||
def find_literal(haystack: str, needle: str) -> int:
|
||
if re.fullmatch(r"[A-Za-z0-9_$.-]+", needle):
|
||
match = re.search(
|
||
rf"(?<![A-Za-z0-9_$]){re.escape(needle)}(?![A-Za-z0-9_$])",
|
||
haystack,
|
||
)
|
||
return match.start() if match else -1
|
||
return haystack.find(needle)
|
||
|
||
|
||
def compile_claim_marker_patterns(templates: list[str]) -> list[re.Pattern[str]]:
|
||
patterns: list[re.Pattern[str]] = []
|
||
claim_group = r"(?P<id>[^\s<>\[\]{}()]+)"
|
||
for template in templates:
|
||
if template.count("{claim_id}") != 1:
|
||
raise InputError(
|
||
"evidence marker에는 {claim_id} placeholder가 정확히 하나 필요합니다."
|
||
)
|
||
try:
|
||
patterns.append(
|
||
re.compile(
|
||
template.replace("{claim_id}", claim_group),
|
||
re.IGNORECASE,
|
||
)
|
||
)
|
||
except re.error as exc:
|
||
raise InputError(f"evidence marker 정규식이 잘못되었습니다: {template}: {exc}") from exc
|
||
return patterns
|
||
|
||
|
||
def extract_claim_markers(
|
||
fence_visible: str,
|
||
reader_visible: str,
|
||
templates: list[str],
|
||
) -> list[tuple[str, int]]:
|
||
"""Extract configured exact comments and reader-visible claim markers."""
|
||
|
||
markers: list[tuple[str, int]] = []
|
||
patterns = compile_claim_marker_patterns(templates)
|
||
marker_visible = mask_markdown_metadata(
|
||
mask_raw_code_html_blocks(reader_visible)
|
||
)
|
||
fence_visible = mask_raw_code_html_blocks(fence_visible)
|
||
inline_spans = inline_code_spans(marker_visible)
|
||
excluded_comment_spans = list(inline_code_spans(fence_visible))
|
||
excluded_comment_spans.extend(
|
||
(start, end, "") for start, end in html_tag_spans(fence_visible)
|
||
)
|
||
excluded_comment_spans.extend(
|
||
(target_start, link_end, "")
|
||
for _, link_end, target_start, _, _ in markdown_inline_links(
|
||
fence_visible, include_images=True
|
||
)
|
||
)
|
||
definitions = reference_definitions(fence_visible)
|
||
excluded_comment_spans.extend(
|
||
(item.start, item.end, "") for item in definitions
|
||
)
|
||
excluded_comment_spans.extend(
|
||
(item.metadata_start, item.metadata_end, "")
|
||
for item in markdown_reference_links(
|
||
fence_visible, definitions, include_images=True
|
||
)
|
||
)
|
||
cursor = 0
|
||
while True:
|
||
start = fence_visible.find("<!--", cursor)
|
||
if start < 0:
|
||
break
|
||
if inside_any_span(start, excluded_comment_spans):
|
||
cursor = start + 4
|
||
continue
|
||
closing = fence_visible.find("-->", start + 4)
|
||
if closing < 0:
|
||
break
|
||
end = closing + 3
|
||
for pattern in patterns:
|
||
match = pattern.fullmatch(fence_visible[start:end])
|
||
if match:
|
||
markers.append((match.group("id"), start))
|
||
break
|
||
cursor = end
|
||
for pattern in patterns:
|
||
for match in pattern.finditer(marker_visible):
|
||
if inside_any_span(match.start(), inline_spans):
|
||
continue
|
||
markers.append((match.group("id"), match.start()))
|
||
return sorted(set(markers), key=lambda item: item[1])
|
||
|
||
|
||
def require_fields(value: dict[str, Any], fields: Iterable[str], label: str) -> None:
|
||
missing = [field for field in fields if field not in value]
|
||
if missing:
|
||
raise InputError(f"{label} 필수 필드가 없습니다: {', '.join(missing)}")
|
||
|
||
|
||
def validate_contracts(
|
||
logic_map: dict[str, Any],
|
||
term_ledger: dict[str, Any],
|
||
reader_contract: dict[str, Any],
|
||
rules: dict[str, Any],
|
||
) -> None:
|
||
for value, label in (
|
||
(logic_map, "logic map"),
|
||
(term_ledger, "term ledger"),
|
||
(reader_contract, "reader contract"),
|
||
):
|
||
schema_version(value, Path(label))
|
||
require_fields(logic_map, REQUIRED_LOGIC_TOP, "logic map")
|
||
require_fields(term_ledger, ("schema_version", "assumed_known", "budgets", "terms"), "term ledger")
|
||
require_fields(reader_contract, REQUIRED_READER, "reader contract")
|
||
for field in ("title", "core_claim", "closure"):
|
||
if not isinstance(logic_map[field], str) or not logic_map[field].strip():
|
||
raise InputError(f"logic map {field}는 비어 있지 않은 문자열이어야 합니다.")
|
||
if not isinstance(logic_map["sections"], list) or not logic_map["sections"]:
|
||
raise InputError("logic map sections는 비어 있지 않은 배열이어야 합니다.")
|
||
section_ids: set[str] = set()
|
||
term_sections: dict[str, list[str]] = defaultdict(list)
|
||
for index, section in enumerate(logic_map["sections"]):
|
||
if not isinstance(section, dict):
|
||
raise InputError(f"logic map sections[{index}]는 객체여야 합니다.")
|
||
require_fields(section, REQUIRED_LOGIC_SECTION, f"logic map sections[{index}]")
|
||
section_id = section["id"]
|
||
if not isinstance(section_id, str) or not section_id.strip() or section_id in section_ids:
|
||
raise InputError(f"logic map section id가 비었거나 중복됩니다: {section_id!r}")
|
||
section_ids.add(section_id)
|
||
for field in ("depends_on", "claim_ids", "new_terms"):
|
||
if not isinstance(section[field], list) or any(
|
||
not isinstance(item, str) or not item.strip() for item in section[field]
|
||
):
|
||
raise InputError(
|
||
f"logic map section {section_id}.{field}는 비어 있지 않은 문자열 배열이어야 합니다."
|
||
)
|
||
for term_id in section["new_terms"]:
|
||
term_sections[term_id].append(section_id)
|
||
for field in ("heading", "role", "reader_state_before", "question", "answer_plain", "reader_state_after"):
|
||
if not isinstance(section[field], str) or not section[field].strip():
|
||
raise InputError(f"logic map section {section_id}.{field}가 비어 있습니다.")
|
||
if section["transition_to"] is not None and not isinstance(section["transition_to"], str):
|
||
raise InputError(f"logic map section {section_id}.transition_to 형식이 잘못되었습니다.")
|
||
if not isinstance(term_ledger["terms"], list):
|
||
raise InputError("term ledger terms는 배열이어야 합니다.")
|
||
term_ids: set[str] = set()
|
||
name_owners: dict[str, tuple[str, str]] = {}
|
||
for index, term in enumerate(term_ledger["terms"]):
|
||
if not isinstance(term, dict):
|
||
raise InputError(f"term ledger terms[{index}]는 객체여야 합니다.")
|
||
require_fields(term, REQUIRED_TERM, f"term ledger terms[{index}]")
|
||
for field in ("id", "canonical", "plain_definition", "why_needed", "first_section", "first_use"):
|
||
if not isinstance(term[field], str) or not term[field].strip():
|
||
raise InputError(f"term ledger terms[{index}].{field}가 비어 있습니다.")
|
||
if term["id"] in term_ids:
|
||
raise InputError(f"term id가 중복됩니다: {term['id']}")
|
||
term_ids.add(term["id"])
|
||
if term["first_section"] not in section_ids:
|
||
raise InputError(f"term {term['id']}의 first_section이 logic map에 없습니다.")
|
||
if not isinstance(term["aliases"], list) or any(
|
||
not isinstance(alias, str) or not alias.strip() for alias in term["aliases"]
|
||
):
|
||
raise InputError(
|
||
f"term {term['id']}.aliases는 비어 있지 않은 문자열 배열이어야 합니다."
|
||
)
|
||
expected_sections = term_sections.get(term["id"], [])
|
||
if expected_sections != [term["first_section"]]:
|
||
raise InputError(
|
||
f"term {term['id']}는 first_section={term['first_section']!r}의 "
|
||
f"new_terms에 정확히 한 번 있어야 합니다: {expected_sections!r}"
|
||
)
|
||
for field, value in (
|
||
("canonical", term["canonical"]),
|
||
*(("alias", alias) for alias in term["aliases"]),
|
||
("english", term.get("english")),
|
||
("abbreviation", term.get("abbreviation")),
|
||
):
|
||
if not isinstance(value, str) or not value.strip():
|
||
continue
|
||
normalized = normalize_term_name(value)
|
||
previous = name_owners.get(normalized)
|
||
if previous is not None:
|
||
raise InputError(
|
||
f"용어 이름 {value!r}이 둘 이상에 배정되었습니다: "
|
||
f"{previous[0]}.{previous[1]}, {term['id']}.{field}"
|
||
)
|
||
name_owners[normalized] = (term["id"], field)
|
||
unknown_term_ids = sorted(set(term_sections) - term_ids)
|
||
if unknown_term_ids:
|
||
raise InputError(f"logic map new_terms에 없는 term id가 있습니다: {unknown_term_ids}")
|
||
expected = rules["thresholds"]["term"]
|
||
budgets = term_ledger.get("budgets")
|
||
if not isinstance(budgets, dict):
|
||
raise InputError("term ledger budgets는 객체여야 합니다.")
|
||
for ledger_key, config_key in (
|
||
("per_sentence", "max_new_terms_per_sentence"),
|
||
("per_paragraph", "max_new_terms_per_paragraph"),
|
||
("per_section", "max_new_terms_per_section"),
|
||
):
|
||
if budgets.get(ledger_key) != expected[config_key]:
|
||
raise InputError(
|
||
f"term ledger budgets.{ledger_key}={budgets.get(ledger_key)!r}가 "
|
||
f"quality rules 값 {expected[config_key]}와 다릅니다."
|
||
)
|
||
for field in ("prerequisites", "assumed_known", "must_explain", "non_goals"):
|
||
if not isinstance(reader_contract[field], list) or any(
|
||
not isinstance(item, str) or not item.strip() for item in reader_contract[field]
|
||
):
|
||
raise InputError(
|
||
f"reader contract {field}는 비어 있지 않은 문자열 배열이어야 합니다."
|
||
)
|
||
for field in ("primary_audience", "purpose", "reader_question", "reader_outcome"):
|
||
if not isinstance(reader_contract[field], str) or not reader_contract[field].strip():
|
||
raise InputError(f"reader contract {field}가 비어 있습니다.")
|
||
assumed = {
|
||
normalize_term_name(str(item)) for item in reader_contract["assumed_known"]
|
||
}
|
||
must_explain = {
|
||
normalize_term_name(str(item)) for item in reader_contract["must_explain"]
|
||
}
|
||
overlap = sorted(assumed & must_explain)
|
||
if overlap:
|
||
raise InputError(
|
||
"reader contract assumed_known와 must_explain은 겹칠 수 없습니다: "
|
||
f"{overlap}"
|
||
)
|
||
explainable: set[str] = set()
|
||
for term in term_ledger["terms"]:
|
||
for value in (
|
||
term["canonical"],
|
||
*term["aliases"],
|
||
term.get("english"),
|
||
term.get("abbreviation"),
|
||
):
|
||
if isinstance(value, str) and value.strip():
|
||
explainable.add(normalize_term_name(value))
|
||
missing_explanations = sorted(
|
||
item
|
||
for item in reader_contract["must_explain"]
|
||
if normalize_term_name(item) not in explainable
|
||
)
|
||
if missing_explanations:
|
||
raise InputError(
|
||
"reader contract must_explain 항목이 term ledger에 등록되지 않았습니다: "
|
||
f"{missing_explanations}"
|
||
)
|
||
|
||
|
||
class Linter:
|
||
def __init__(
|
||
self,
|
||
*,
|
||
document_path: Path,
|
||
text: str,
|
||
logic_map: dict[str, Any],
|
||
term_ledger: dict[str, Any],
|
||
reader_contract: dict[str, Any],
|
||
rules: dict[str, Any],
|
||
) -> None:
|
||
self.document_path = document_path
|
||
self.text = text
|
||
(
|
||
self.visible,
|
||
self.prose_visible,
|
||
self.fenced,
|
||
self.unclosed_comments,
|
||
) = scan_markdown_visibility(text)
|
||
self.markdown_visible = mask_inline_code(self.prose_visible)
|
||
self.metadata_visible = mask_markdown_metadata(
|
||
mask_raw_code_html_blocks(self.markdown_visible)
|
||
)
|
||
contract_source = mask_raw_html_blocks(self.markdown_visible)
|
||
term_source = mask_raw_html_blocks(self.prose_visible)
|
||
logic_source = mask_raw_html_blocks(self.prose_visible)
|
||
self.contract_visible = mask_markdown_metadata(contract_source)
|
||
self.term_visible = mask_markdown_metadata(term_source)
|
||
self.candidate_visible = mask_markdown_metadata(
|
||
mask_raw_html_blocks(self.prose_visible)
|
||
)
|
||
self.metadata_search, self.metadata_offsets = decode_html_entities_with_offsets(
|
||
self.metadata_visible
|
||
)
|
||
self.term_search, self.term_offsets = rendered_search_projection(
|
||
term_source,
|
||
include_inline_code_text=True,
|
||
)
|
||
self.term_inline_spans = inline_code_spans(self.prose_visible)
|
||
self.candidate_search, self.candidate_offsets = decode_html_entities_with_offsets(
|
||
self.candidate_visible
|
||
)
|
||
self.logic_search, self.logic_offsets = rendered_search_projection(
|
||
logic_source,
|
||
include_inline_code_text=True,
|
||
)
|
||
self.logic_inline_spans = inline_code_spans(self.prose_visible)
|
||
self.starts = line_starts(text)
|
||
self.structural_visible = mask_raw_html_blocks(self.prose_visible)
|
||
self.headings = parse_headings(self.structural_visible)
|
||
self.paragraphs = paragraph_spans(mask_raw_html_blocks(self.markdown_visible))
|
||
self.logic_map = logic_map
|
||
self.term_ledger = term_ledger
|
||
self.reader_contract = reader_contract
|
||
self.rules = rules
|
||
self.rule_by_id = rule_index(rules)
|
||
self.findings: list[dict[str, Any]] = []
|
||
self.section_spans: dict[str, tuple[int, int]] = {}
|
||
self.term_positions: dict[str, int] = {}
|
||
self.claim_markers = extract_claim_markers(
|
||
self.visible,
|
||
self.prose_visible,
|
||
self.rules["patterns"]["evidence_markers"],
|
||
)
|
||
|
||
def add(
|
||
self,
|
||
rule_id: str,
|
||
message: str,
|
||
*,
|
||
offset: int | None = None,
|
||
section_id: str | None = None,
|
||
context: str | None = None,
|
||
path: Path | None = None,
|
||
) -> None:
|
||
rule = self.rule_by_id[rule_id]
|
||
line: int | None = None
|
||
column: int | None = None
|
||
if offset is not None:
|
||
line, column = location(self.starts, offset)
|
||
self.findings.append(
|
||
{
|
||
"rule_id": rule_id,
|
||
"severity": rule["severity"],
|
||
"message": message,
|
||
"path": str(path or self.document_path),
|
||
"line": line,
|
||
"column": column,
|
||
"section_id": section_id,
|
||
"context": context,
|
||
}
|
||
)
|
||
|
||
def lint_markdown(self) -> None:
|
||
heading_thresholds = self.rules["thresholds"]["heading"]
|
||
previous: Heading | None = None
|
||
for heading in self.headings:
|
||
if previous and heading.level - previous.level > int(heading_thresholds["max_level_jump"]):
|
||
self.add(
|
||
"DOC-H001",
|
||
f"H{previous.level} 다음에 H{heading.level}이 나옵니다: {heading.text}",
|
||
offset=heading.start,
|
||
context=heading.text,
|
||
)
|
||
previous = heading
|
||
h1 = [heading for heading in self.headings if heading.level == 1]
|
||
required = int(heading_thresholds["required_h1_count"])
|
||
if len(h1) != required:
|
||
offset = h1[1].start if len(h1) > 1 else 0
|
||
self.add(
|
||
"DOC-H002",
|
||
f"H1은 {required}개여야 하지만 {len(h1)}개입니다.",
|
||
offset=offset,
|
||
)
|
||
|
||
for pattern in self.rules["patterns"]["placeholders"]:
|
||
try:
|
||
matches = re.finditer(pattern, self.metadata_search, re.IGNORECASE | re.MULTILINE)
|
||
except re.error as exc:
|
||
raise InputError(f"placeholder 정규식이 잘못되었습니다: {pattern}: {exc}") from exc
|
||
for match in matches:
|
||
self.add(
|
||
"DOC-M001",
|
||
f"미완성 표시가 남아 있습니다: {match.group(0)}",
|
||
offset=self.metadata_offsets[match.start()],
|
||
context=match.group(0),
|
||
)
|
||
|
||
for block in self.fenced:
|
||
if block.kind == "fenced_code" and not block.closed:
|
||
self.add(
|
||
"DOC-M002",
|
||
"닫히지 않은 code fence가 있습니다.",
|
||
offset=block.start,
|
||
)
|
||
for offset in self.unclosed_comments:
|
||
self.add(
|
||
"DOC-M004",
|
||
"닫히지 않은 HTML 주석이 있습니다.",
|
||
offset=offset,
|
||
)
|
||
|
||
heading_anchors = {heading.slug for heading in self.headings if heading.slug}
|
||
explicit_anchors: set[str] = set()
|
||
html_fragment_links: list[tuple[str, int]] = []
|
||
html_spans = html_tag_spans(self.markdown_visible)
|
||
raw_html_structure = mask_raw_html_blocks(self.markdown_visible)
|
||
raw_text_tag: str | None = None
|
||
for start, end in html_spans:
|
||
tag_match = re.match(
|
||
r"<(?P<closing>/)?(?P<name>[A-Za-z][A-Za-z0-9-]*)",
|
||
self.markdown_visible[start:end],
|
||
)
|
||
if tag_match is None:
|
||
continue
|
||
tag_name = tag_match.group("name").casefold()
|
||
if raw_text_tag is not None:
|
||
if tag_match.group("closing") and tag_name == raw_text_tag:
|
||
raw_text_tag = None
|
||
continue
|
||
parsed = html_tag_name_and_attributes(self.markdown_visible[start:end])
|
||
if parsed is None:
|
||
continue
|
||
tag_name, attributes = parsed
|
||
if tag_name in {"pre", "script", "style", "textarea"}:
|
||
raw_text_tag = tag_name
|
||
values = [attributes["id"]] if "id" in attributes else []
|
||
if tag_name == "a" and "name" in attributes:
|
||
values.append(attributes["name"])
|
||
if tag_name == "a" and attributes.get("href", "").startswith("#"):
|
||
html_fragment_links.append((attributes["href"], start))
|
||
for value in values:
|
||
if value:
|
||
explicit_anchors.add(html.unescape(unquote(value)).strip())
|
||
|
||
def validate_fragment(destination: str, offset: int) -> None:
|
||
if not destination.startswith("#"):
|
||
return
|
||
target = re.sub(r"\\(.)", r"\1", destination[1:])
|
||
target = html.unescape(unquote(target)).strip()
|
||
if target not in heading_anchors and target not in explicit_anchors:
|
||
self.add(
|
||
"DOC-M003",
|
||
f"내부 앵커 '#{target}'를 찾을 수 없습니다.",
|
||
offset=offset,
|
||
context=target,
|
||
)
|
||
|
||
for destination, offset in html_fragment_links:
|
||
validate_fragment(destination, offset)
|
||
|
||
definitions = [
|
||
item
|
||
for item in reference_definitions(self.markdown_visible)
|
||
if not (
|
||
item.start < len(self.markdown_visible)
|
||
and not self.markdown_visible[item.start].isspace()
|
||
and raw_html_structure[item.start] == " "
|
||
)
|
||
]
|
||
definition_spans = [(item.start, item.end) for item in definitions]
|
||
for link_start, _, target_start, target_end, _ in markdown_inline_links(
|
||
self.markdown_visible
|
||
):
|
||
if (
|
||
link_start < len(self.markdown_visible)
|
||
and not self.markdown_visible[link_start].isspace()
|
||
and raw_html_structure[link_start] == " "
|
||
):
|
||
continue
|
||
if any(start <= link_start < end for start, end in html_spans):
|
||
continue
|
||
if any(start <= link_start < end for start, end in definition_spans):
|
||
continue
|
||
validate_fragment(
|
||
self.markdown_visible[target_start:target_end],
|
||
target_start,
|
||
)
|
||
|
||
definition_by_id: dict[str, ReferenceDefinition] = {}
|
||
for definition in definitions:
|
||
definition_by_id.setdefault(definition.identifier, definition)
|
||
for link in markdown_reference_links(self.markdown_visible, definitions):
|
||
if (
|
||
link.start < len(self.markdown_visible)
|
||
and not self.markdown_visible[link.start].isspace()
|
||
and raw_html_structure[link.start] == " "
|
||
):
|
||
continue
|
||
if any(start <= link.start < end for start, end in html_spans):
|
||
continue
|
||
definition = definition_by_id[link.identifier]
|
||
validate_fragment(definition.destination, link.start)
|
||
|
||
def lint_logic(self) -> None:
|
||
h1 = [heading for heading in self.headings if heading.level == 1]
|
||
if len(h1) == 1 and rendered_markdown_label(h1[0].text) != rendered_markdown_label(
|
||
self.logic_map["title"]
|
||
):
|
||
self.add(
|
||
"DOC-L001",
|
||
"문서 H1과 logic map title이 일치하지 않습니다.",
|
||
offset=h1[0].start,
|
||
context=f"document={h1[0].text!r}; logic={self.logic_map['title']!r}",
|
||
)
|
||
|
||
document_sections = [heading for heading in self.headings if heading.level == 2]
|
||
logic_sections = self.logic_map["sections"]
|
||
matched: list[tuple[dict[str, Any], Heading]] = []
|
||
for index, section in enumerate(logic_sections):
|
||
heading = document_sections[index] if index < len(document_sections) else None
|
||
if heading is None or rendered_markdown_label(
|
||
heading.text
|
||
) != rendered_markdown_label(section["heading"]):
|
||
self.add(
|
||
"DOC-L001",
|
||
f"logic map 섹션과 같은 위치의 H2가 일치하지 않습니다: {section['heading']}",
|
||
offset=heading.start if heading else None,
|
||
section_id=section["id"],
|
||
context=(
|
||
f"document={heading.text!r}; logic={section['heading']!r}"
|
||
if heading
|
||
else section["heading"]
|
||
),
|
||
)
|
||
continue
|
||
matched.append((section, heading))
|
||
for heading in document_sections[len(logic_sections) :]:
|
||
self.add(
|
||
"DOC-L001",
|
||
f"logic map에 없는 H2 섹션입니다: {heading.text}",
|
||
offset=heading.start,
|
||
context=heading.text,
|
||
)
|
||
for index, (section, heading) in enumerate(matched):
|
||
document_index = document_sections.index(heading)
|
||
end = (
|
||
document_sections[document_index + 1].start
|
||
if document_index + 1 < len(document_sections)
|
||
else len(self.text)
|
||
)
|
||
# A heading is reader-visible section content and may introduce a
|
||
# term. Include it in first-use and section-budget boundaries.
|
||
self.section_spans[section["id"]] = (heading.start, end)
|
||
|
||
core_claim = self.logic_map["core_claim"]
|
||
if not isinstance(core_claim, str) or not core_claim.strip():
|
||
raise InputError("logic map core_claim이 비어 있습니다.")
|
||
match = find_flexible(self.logic_search, core_claim)
|
||
match_source = self.logic_offsets[match.start()] if match else None
|
||
match_inside_code = False
|
||
if match is not None:
|
||
match_source_end = self.logic_offsets[match.end() - 1] + 1
|
||
match_inside_code = any(
|
||
start <= match_source and match_source_end <= end
|
||
for start, end, _ in self.logic_inline_spans
|
||
)
|
||
maximum_front_paragraphs = int(
|
||
self.rules["thresholds"]["logic"]["core_claim_max_reader_paragraphs"]
|
||
)
|
||
heading_spans = [(heading.start, heading.end) for heading in self.headings]
|
||
reader_paragraphs = [
|
||
paragraph
|
||
for paragraph in self.paragraphs
|
||
if not any(
|
||
start <= paragraph.start and paragraph.end <= end
|
||
for start, end in heading_spans
|
||
)
|
||
]
|
||
front_paragraphs = reader_paragraphs[:maximum_front_paragraphs]
|
||
front_limit = front_paragraphs[-1].end if front_paragraphs else 0
|
||
if match is None or match_inside_code or (
|
||
match_source is not None and match_source >= front_limit
|
||
):
|
||
self.add(
|
||
"DOC-L002",
|
||
f"핵심 주장이 독자용 앞 {maximum_front_paragraphs}개 문단 안에 "
|
||
"logic map 문구로 명시되지 않았습니다.",
|
||
offset=match_source if match_source is not None else 0,
|
||
context=core_claim,
|
||
)
|
||
|
||
seen_ids: set[str] = set()
|
||
sections = self.logic_map["sections"]
|
||
for index, section in enumerate(sections):
|
||
section_id = section["id"]
|
||
if index > 0 and not section["depends_on"]:
|
||
self.add(
|
||
"DOC-L004",
|
||
"첫 절이 아닌 절에는 앞선 절을 가리키는 depends_on이 필요합니다.",
|
||
section_id=section_id,
|
||
)
|
||
for dependency in section["depends_on"]:
|
||
if dependency not in seen_ids:
|
||
self.add(
|
||
"DOC-L004",
|
||
f"depends_on '{dependency}'가 앞선 섹션을 가리키지 않습니다.",
|
||
section_id=section_id,
|
||
)
|
||
expected_transition = sections[index + 1]["id"] if index + 1 < len(sections) else None
|
||
if section["transition_to"] != expected_transition:
|
||
self.add(
|
||
"DOC-L004",
|
||
f"transition_to는 {expected_transition!r}이어야 합니다.",
|
||
section_id=section_id,
|
||
)
|
||
seen_ids.add(section_id)
|
||
|
||
span = self.section_spans.get(section_id)
|
||
if span is None:
|
||
continue
|
||
section_text = self.contract_visible[span[0] : span[1]]
|
||
section_claim_ids = {
|
||
claim_id
|
||
for claim_id, position in self.claim_markers
|
||
if span[0] <= position < span[1]
|
||
}
|
||
expected_section_claim_ids = set(section["claim_ids"])
|
||
for claim_id in section["claim_ids"]:
|
||
if claim_id not in section_claim_ids:
|
||
self.add(
|
||
"DOC-L003",
|
||
f"claim marker가 없습니다: {claim_id}",
|
||
offset=span[0],
|
||
section_id=section_id,
|
||
context=str(claim_id),
|
||
)
|
||
for claim_id in sorted(section_claim_ids - expected_section_claim_ids):
|
||
marker_position = next(
|
||
position
|
||
for marker_id, position in self.claim_markers
|
||
if marker_id == claim_id and span[0] <= position < span[1]
|
||
)
|
||
self.add(
|
||
"DOC-L003",
|
||
f"이 절의 logic map에 없는 claim marker가 있습니다: {claim_id}",
|
||
offset=marker_position,
|
||
section_id=section_id,
|
||
context=claim_id,
|
||
)
|
||
for marker in section.get("required_markers", []):
|
||
if marker not in section_text:
|
||
self.add(
|
||
"DOC-L003",
|
||
f"필수 marker가 없습니다: {marker}",
|
||
offset=span[0],
|
||
section_id=section_id,
|
||
context=marker,
|
||
)
|
||
|
||
expected_claim_ids = {
|
||
claim_id
|
||
for section in self.logic_map["sections"]
|
||
for claim_id in section["claim_ids"]
|
||
}
|
||
for claim_id, position in self.claim_markers:
|
||
if claim_id not in expected_claim_ids:
|
||
self.add(
|
||
"DOC-L003",
|
||
f"logic map에 없는 claim marker가 있습니다: {claim_id}",
|
||
offset=position,
|
||
context=claim_id,
|
||
)
|
||
|
||
def _term_mentions(self, term: dict[str, Any]) -> list[tuple[str, int, int]]:
|
||
values = [term["canonical"], *term["aliases"]]
|
||
for key in ("english", "abbreviation"):
|
||
value = term.get(key)
|
||
if isinstance(value, str) and value:
|
||
values.append(value)
|
||
mentions: list[tuple[str, int, int]] = []
|
||
for value in values:
|
||
position = find_literal(self.term_search, value)
|
||
if position >= 0:
|
||
mentions.append((value, position, self.term_offsets[position]))
|
||
return sorted(mentions, key=lambda item: item[1])
|
||
|
||
def lint_terms(self) -> None:
|
||
config = self.rules["thresholds"]["term"]
|
||
ledger_assumed = self.term_ledger.get("assumed_known", [])
|
||
reader_assumed = self.reader_contract.get("assumed_known", [])
|
||
if set(ledger_assumed) != set(reader_assumed):
|
||
self.add(
|
||
"DOC-T009",
|
||
"term ledger와 reader contract의 assumed_known 목록이 다릅니다.",
|
||
context=f"ledger={ledger_assumed!r}; reader={reader_assumed!r}",
|
||
)
|
||
prerequisites = self.reader_contract.get("prerequisites", [])
|
||
max_assumed = int(config["max_assumed_known"])
|
||
max_per_prerequisite = int(config["max_assumed_per_prerequisite"])
|
||
if len(reader_assumed) > max_assumed or (
|
||
len(reader_assumed) > 2
|
||
and (
|
||
not prerequisites
|
||
or len(reader_assumed) > len(prerequisites) * max_per_prerequisite
|
||
)
|
||
):
|
||
self.add(
|
||
"DOC-T008",
|
||
f"assumed_known {len(reader_assumed)}개가 선수지식 {len(prerequisites)}개에 비해 넓습니다.",
|
||
context=", ".join(reader_assumed),
|
||
)
|
||
|
||
for term in self.term_ledger["terms"]:
|
||
term_id = term["id"]
|
||
first_use = term["first_use"]
|
||
first_use_match = find_flexible(
|
||
self.term_search,
|
||
rendered_markdown_label(first_use),
|
||
)
|
||
first_use_inside_code = False
|
||
if first_use_match is not None:
|
||
source_start = self.term_offsets[first_use_match.start()]
|
||
source_end = (
|
||
self.term_offsets[first_use_match.end() - 1] + 1
|
||
if first_use_match.end() > first_use_match.start()
|
||
else source_start
|
||
)
|
||
first_use_inside_code = any(
|
||
start <= source_start and source_end <= end
|
||
for start, end, _ in self.term_inline_spans
|
||
)
|
||
mentions = self._term_mentions(term)
|
||
earliest_search = mentions[0][1] if mentions else -1
|
||
earliest = mentions[0][2] if mentions else -1
|
||
canonical_search = find_literal(self.term_search, term["canonical"])
|
||
canonical_position = (
|
||
self.term_offsets[canonical_search] if canonical_search >= 0 else -1
|
||
)
|
||
canonical_named = find_literal(first_use, term["canonical"]) >= 0
|
||
if not mentions:
|
||
self.add(
|
||
"DOC-T001",
|
||
f"용어 '{term['canonical']}'이 본문에 실제로 등장하지 않습니다.",
|
||
section_id=term["first_section"],
|
||
context=term["canonical"],
|
||
)
|
||
if not canonical_named:
|
||
self.add(
|
||
"DOC-T001",
|
||
f"용어 '{term['canonical']}'의 first_use에 정식 용어가 없습니다.",
|
||
offset=(
|
||
self.term_offsets[first_use_match.start()]
|
||
if first_use_match
|
||
else None
|
||
),
|
||
section_id=term["first_section"],
|
||
context=first_use,
|
||
)
|
||
intro_valid = (
|
||
first_use_match is not None
|
||
and not first_use_inside_code
|
||
and earliest_search >= 0
|
||
and canonical_search >= 0
|
||
and canonical_named
|
||
and first_use_match.start() <= earliest_search
|
||
)
|
||
if not intro_valid:
|
||
self.add(
|
||
"DOC-T001",
|
||
f"용어 '{term['canonical']}'의 first_use 문구가 첫 등장에 없습니다.",
|
||
offset=earliest if earliest >= 0 else None,
|
||
section_id=term["first_section"],
|
||
context=first_use,
|
||
)
|
||
position = canonical_position
|
||
position_search = canonical_search
|
||
else:
|
||
position_search = first_use_match.start()
|
||
position = self.term_offsets[position_search]
|
||
definition = normalize_space(term["plain_definition"])
|
||
mention_position = (
|
||
earliest_search if earliest_search >= 0 else first_use_match.end()
|
||
)
|
||
window_size = int(config["definition_window_chars"])
|
||
window_start = max(0, mention_position - window_size)
|
||
window_end = min(
|
||
len(self.term_search),
|
||
max(first_use_match.end(), mention_position + window_size),
|
||
)
|
||
definition_window = normalize_space(
|
||
self.term_search[window_start:window_end]
|
||
)
|
||
if definition not in definition_window:
|
||
self.add(
|
||
"DOC-T001",
|
||
f"용어 '{term['canonical']}'의 첫 등장 {window_size}자 안에 쉬운 정의가 없습니다.",
|
||
offset=position,
|
||
section_id=term["first_section"],
|
||
context=term["plain_definition"],
|
||
)
|
||
expected_span = self.section_spans.get(term["first_section"])
|
||
if expected_span and not (expected_span[0] <= position < expected_span[1]):
|
||
self.add(
|
||
"DOC-T001",
|
||
f"용어 '{term['canonical']}'이 first_section 밖에서 처음 소개됩니다.",
|
||
offset=position,
|
||
section_id=term["first_section"],
|
||
)
|
||
if position >= 0:
|
||
self.term_positions[term_id] = position
|
||
|
||
for alias in term["aliases"]:
|
||
alias_search = find_literal(self.term_search, alias)
|
||
alias_position = self.term_offsets[alias_search] if alias_search >= 0 else -1
|
||
if alias_search >= 0 and (
|
||
position_search < 0 or alias_search < position_search
|
||
):
|
||
self.add(
|
||
"DOC-T002",
|
||
f"별칭 '{alias}'가 정식 용어 '{term['canonical']}'의 설명보다 먼저 나옵니다.",
|
||
offset=alias_position,
|
||
section_id=term["first_section"],
|
||
context=alias,
|
||
)
|
||
abbreviation = term.get("abbreviation")
|
||
if isinstance(abbreviation, str) and abbreviation:
|
||
abbreviation_search = find_literal(self.term_search, abbreviation)
|
||
abbreviation_position = (
|
||
self.term_offsets[abbreviation_search]
|
||
if abbreviation_search >= 0
|
||
else -1
|
||
)
|
||
full_name_present = term["canonical"] in first_use or (
|
||
isinstance(term.get("english"), str) and term["english"] in first_use
|
||
)
|
||
definition_present = normalize_space(term["plain_definition"]) in normalize_space(first_use)
|
||
if abbreviation_search >= 0 and (
|
||
position_search < 0
|
||
or abbreviation_search < position_search
|
||
or abbreviation not in first_use
|
||
or not full_name_present
|
||
or not definition_present
|
||
):
|
||
self.add(
|
||
"DOC-T003",
|
||
f"약어 '{abbreviation}'를 정식 이름과 쉬운 뜻보다 먼저 사용했습니다.",
|
||
offset=abbreviation_position,
|
||
section_id=term["first_section"],
|
||
context=abbreviation,
|
||
)
|
||
|
||
positions = list(self.term_positions.values())
|
||
self._budget_findings(
|
||
self.paragraphs,
|
||
positions,
|
||
int(config["max_new_terms_per_paragraph"]),
|
||
"DOC-T004",
|
||
"문단",
|
||
)
|
||
sentences = [sentence for paragraph in self.paragraphs for sentence in sentence_spans(paragraph)]
|
||
self._budget_findings(
|
||
sentences,
|
||
positions,
|
||
int(config["max_new_terms_per_sentence"]),
|
||
"DOC-T005",
|
||
"문장",
|
||
)
|
||
for section_id, (start, end) in self.section_spans.items():
|
||
count = sum(start <= position < end for position in positions)
|
||
maximum = int(config["max_new_terms_per_section"])
|
||
if count > maximum:
|
||
self.add(
|
||
"DOC-T006",
|
||
f"절에서 새 용어 {count}개를 소개해 예산 {maximum}개를 넘었습니다.",
|
||
offset=start,
|
||
section_id=section_id,
|
||
)
|
||
self.lint_unregistered_candidates()
|
||
|
||
def _budget_findings(
|
||
self,
|
||
spans: list[TextSpan],
|
||
positions: list[int],
|
||
maximum: int,
|
||
rule_id: str,
|
||
label: str,
|
||
) -> None:
|
||
for span in spans:
|
||
count = sum(span.start <= position < span.end for position in positions)
|
||
if count > maximum:
|
||
self.add(
|
||
rule_id,
|
||
f"{label}에서 새 용어 {count}개를 소개해 예산 {maximum}개를 넘었습니다.",
|
||
offset=span.start,
|
||
context=normalize_space(span.text)[:160],
|
||
)
|
||
|
||
def lint_unregistered_candidates(self) -> None:
|
||
registered: set[str] = set()
|
||
registered_values: list[str] = []
|
||
for section in self.logic_map["sections"]:
|
||
for claim_id in section["claim_ids"]:
|
||
registered.add(claim_id.casefold())
|
||
registered_values.append(claim_id)
|
||
for term in self.term_ledger["terms"]:
|
||
for key in ("canonical", "english", "abbreviation"):
|
||
value = term.get(key)
|
||
if isinstance(value, str) and value:
|
||
registered.add(value.casefold())
|
||
registered_values.append(value)
|
||
for alias in term["aliases"]:
|
||
registered.add(str(alias).casefold())
|
||
registered_values.append(str(alias))
|
||
for value in self.reader_contract["assumed_known"]:
|
||
registered.add(str(value).casefold())
|
||
registered_values.append(str(value))
|
||
allowlist_values = [
|
||
str(value)
|
||
for value in self.rules["patterns"].get(
|
||
"technical_candidate_allowlist", []
|
||
)
|
||
]
|
||
allowlist = {value.casefold() for value in allowlist_values}
|
||
|
||
prose = list(self.candidate_search)
|
||
for start, end, _ in absolute_uri_matches(self.candidate_search):
|
||
for index in range(start, end):
|
||
prose[index] = " "
|
||
metadata_patterns = (
|
||
r"(?<![A-Za-z0-9.!#$%&'*+/=?^_`{|}~-])"
|
||
r"[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@"
|
||
r"(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}"
|
||
r"(?![A-Za-z0-9-])",
|
||
)
|
||
for pattern in metadata_patterns:
|
||
for match in re.finditer(pattern, self.candidate_search):
|
||
for index in range(match.start(), match.end()):
|
||
prose[index] = " "
|
||
prose_text = "".join(prose)
|
||
registered_spans: list[tuple[int, int]] = []
|
||
for value in [*registered_values, *allowlist_values]:
|
||
parts = value.split()
|
||
if not parts:
|
||
continue
|
||
phrase = r"\s+".join(re.escape(part) for part in parts)
|
||
if re.match(r"[A-Za-z0-9_$]", value):
|
||
phrase = rf"(?<![A-Za-z0-9_$]){phrase}"
|
||
if re.search(r"[A-Za-z0-9_$]$", value):
|
||
phrase = rf"{phrase}(?![A-Za-z0-9_$])"
|
||
registered_spans.extend(
|
||
match.span() for match in re.finditer(phrase, prose_text, re.IGNORECASE)
|
||
)
|
||
lowercase_candidates = [
|
||
str(value)
|
||
for value in self.rules["patterns"].get(
|
||
"technical_lowercase_candidates", []
|
||
)
|
||
]
|
||
candidate_patterns = [
|
||
r"(?<![A-Za-z0-9_])[A-Z][A-Z0-9_]{1,}(?![A-Za-z0-9_])",
|
||
r"(?<![A-Za-z0-9_])(?:[A-Z][a-z0-9]+){2,}(?![A-Za-z0-9_])",
|
||
r"(?<![A-Za-z0-9_])[a-z][a-z0-9]*(?:[A-Z][A-Za-z0-9]*)+(?![A-Za-z0-9_])",
|
||
r"(?<![A-Za-z0-9_])[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)+(?![A-Za-z0-9_])",
|
||
r"(?<![A-Za-z0-9_])[a-z][a-z0-9]*(?:[-_][a-z0-9]+)+(?![A-Za-z0-9_])",
|
||
]
|
||
if lowercase_candidates:
|
||
alternatives = "|".join(
|
||
re.escape(value)
|
||
for value in sorted(lowercase_candidates, key=len, reverse=True)
|
||
)
|
||
candidate_patterns.append(
|
||
rf"(?<![A-Za-z0-9_])(?:{alternatives})(?![A-Za-z0-9_])"
|
||
)
|
||
candidates: dict[str, int] = {}
|
||
for pattern in candidate_patterns:
|
||
for match in re.finditer(pattern, prose_text):
|
||
candidates.setdefault(match.group(0), match.start())
|
||
for match in re.finditer(r"`([^`\n]+)`", prose_text):
|
||
value = match.group(1).strip()
|
||
if re.fullmatch(r"[A-Za-z_$][\w$]*(?:[.:/#@-][A-Za-z0-9_$-]+)*", value):
|
||
candidates.setdefault(value, match.start(1))
|
||
|
||
for candidate, offset in sorted(candidates.items(), key=lambda item: item[1]):
|
||
folded = candidate.casefold()
|
||
if folded in registered or folded in allowlist:
|
||
continue
|
||
if any(
|
||
start <= offset and offset + len(candidate) <= end
|
||
for start, end in registered_spans
|
||
):
|
||
continue
|
||
self.add(
|
||
"DOC-T007",
|
||
f"미등록 기술 용어 후보입니다: {candidate}",
|
||
offset=self.candidate_offsets[offset],
|
||
context=candidate,
|
||
)
|
||
|
||
def lint_paragraphs(self) -> None:
|
||
thresholds = self.rules["thresholds"]["paragraph"]
|
||
for paragraph in self.paragraphs:
|
||
compact = normalize_space(paragraph.text)
|
||
if len(compact) > int(thresholds["max_chars"]):
|
||
self.add(
|
||
"DOC-P001",
|
||
f"문단 길이 {len(compact)}자가 상한 {thresholds['max_chars']}자를 넘었습니다.",
|
||
offset=paragraph.start,
|
||
context=compact[:160],
|
||
)
|
||
sentence_count = len(sentence_spans(paragraph))
|
||
if sentence_count > int(thresholds["max_sentences"]):
|
||
self.add(
|
||
"DOC-P002",
|
||
f"문단 문장 수 {sentence_count}개가 상한 {thresholds['max_sentences']}개를 넘었습니다.",
|
||
offset=paragraph.start,
|
||
context=compact[:160],
|
||
)
|
||
|
||
|
||
def protected_context(visible: str, start: int, end: int) -> str:
|
||
"""Bind a protected value to nearby semantic labels, not just its raw spelling."""
|
||
|
||
def words(fragment: str) -> list[str]:
|
||
return [
|
||
match.group(0).casefold()
|
||
for match in re.finditer(r"(?<!\w)[^\W\d_][\w.-]*", fragment, re.UNICODE)
|
||
if not match.group(0).casefold().startswith(
|
||
("http", "ftp", "file", "mailto", "ssh", "git", "www")
|
||
)
|
||
]
|
||
|
||
left = words(visible[max(0, start - 120) : start])[-3:]
|
||
right = words(visible[end : min(len(visible), end + 120)])[:3]
|
||
return "\u241e".join(left) + "\u241f" + "\u241e".join(right)
|
||
|
||
|
||
def reader_visible_double_quotes(visible: str) -> list[ProtectedOccurrence]:
|
||
"""Extract literal/entity double quotes from rendered prose, including hard wraps."""
|
||
|
||
projection = mask_markdown_metadata(
|
||
mask_raw_code_html_blocks(mask_inline_code(visible))
|
||
)
|
||
decoded, _ = decode_html_entities_with_offsets(projection)
|
||
occurrences: list[ProtectedOccurrence] = []
|
||
|
||
def quote_context(start: int, end: int) -> str:
|
||
line_start = decoded.rfind("\n", 0, start) + 1
|
||
if re.match(r"^[ ]{0,3}(?:>[ \t]?)+", decoded[line_start:start]):
|
||
return ""
|
||
return protected_context(decoded, start, end)
|
||
|
||
def canonical_body(body: str) -> str | None:
|
||
if re.search(r"\r?\n[ \t]*\r?\n", body):
|
||
return None
|
||
lines: list[str] = []
|
||
for index, raw_line in enumerate(body.splitlines() or [body]):
|
||
line = raw_line
|
||
if index > 0:
|
||
line = re.sub(r"^[ ]{0,3}(?:>[ \t]?)+", "", line)
|
||
structural = line.lstrip()
|
||
if is_thematic_break(structural) or re.match(
|
||
r"(?:#{1,6}(?:[ \t]+|$)|(?:[-+*]|\d+[.)])[ \t]+|"
|
||
r"`{3,}|~{3,}|(?:={3,}|-{3,}|\*{3,}|_{3,})[ \t]*$|"
|
||
r"<[/!?A-Za-z])",
|
||
structural,
|
||
):
|
||
return None
|
||
lines.append(line.strip())
|
||
value = "\n".join(lines).strip()
|
||
return value if len(normalize_space(value)) >= 2 else None
|
||
|
||
for opener, closer in (("\"", "\""), ("“", "”")):
|
||
position = 0
|
||
while position < len(decoded):
|
||
start = decoded.find(opener, position)
|
||
if start < 0:
|
||
break
|
||
end = decoded.find(closer, start + len(opener))
|
||
if end < 0:
|
||
break
|
||
body = canonical_body(decoded[start + len(opener) : end])
|
||
if body is not None:
|
||
value = f"{opener}{body}{closer}"
|
||
occurrences.append(
|
||
ProtectedOccurrence(
|
||
value,
|
||
quote_context(start, end + len(closer)),
|
||
)
|
||
)
|
||
position = end + len(closer)
|
||
return occurrences
|
||
|
||
|
||
def protected_occurrences(text: str) -> dict[str, list[ProtectedOccurrence]]:
|
||
_, visible, fenced, _ = scan_markdown_visibility(text)
|
||
occurrences: dict[str, list[ProtectedOccurrence]] = {
|
||
"fenced_code": [],
|
||
"indented_code": [],
|
||
"inline_code": [],
|
||
"url": [],
|
||
"link_destination": [],
|
||
"number_unit": [],
|
||
"number_range": [],
|
||
"number": [],
|
||
"date": [],
|
||
"version": [],
|
||
"quote": [],
|
||
}
|
||
for block in fenced:
|
||
if block.closed:
|
||
occurrences[block.kind].append(ProtectedOccurrence(block.text, ""))
|
||
for start, end, value in inline_code_spans(visible):
|
||
if value.strip():
|
||
occurrences["inline_code"].append(
|
||
ProtectedOccurrence(
|
||
value,
|
||
protected_context(visible, start, end),
|
||
)
|
||
)
|
||
for start, end, value in absolute_uri_matches(visible):
|
||
occurrences["url"].append(
|
||
ProtectedOccurrence(value, protected_context(visible, start, end))
|
||
)
|
||
inline_spans = inline_code_spans(visible)
|
||
for link_start, _, target_start, target_end, raw_label in markdown_inline_links(
|
||
visible, include_images=True
|
||
):
|
||
if inside_any_span(link_start, inline_spans):
|
||
continue
|
||
target = visible[target_start:target_end].strip("<>")
|
||
label = rendered_markdown_label(raw_label)
|
||
occurrences["link_destination"].append(
|
||
ProtectedOccurrence(
|
||
target,
|
||
f"label:{label}\u241f{protected_context(visible, target_start, target_end)}",
|
||
)
|
||
)
|
||
for definition in reference_definitions(visible):
|
||
if inside_any_span(definition.start, inline_spans):
|
||
continue
|
||
occurrences["link_destination"].append(
|
||
ProtectedOccurrence(
|
||
definition.destination,
|
||
f"label:{definition.identifier}\u241f"
|
||
f"{protected_context(visible, definition.target_start, definition.target_end)}",
|
||
)
|
||
)
|
||
occupied_numeric_spans: list[tuple[int, int]] = []
|
||
typed_numeric_patterns = {
|
||
"date": re.compile(
|
||
r"(?<![A-Za-z0-9_.])\d{4}-\d{2}-\d{2}(?![A-Za-z0-9_.])"
|
||
),
|
||
"version": re.compile(
|
||
r"(?<![A-Za-z0-9_.])v?\d+(?:\.\d+){1,3}(?:[-+][0-9A-Za-z.-]+)?(?![A-Za-z0-9_.])"
|
||
),
|
||
}
|
||
for kind, pattern in typed_numeric_patterns.items():
|
||
for match in pattern.finditer(visible):
|
||
occupied_numeric_spans.append(match.span())
|
||
occurrences[kind].append(
|
||
ProtectedOccurrence(
|
||
match.group(0),
|
||
protected_context(visible, match.start(), match.end()),
|
||
)
|
||
)
|
||
qualifier = (
|
||
r"(?:(?:최대|최소|약|대략|약간|approximately|about|at[ \t]+most|"
|
||
r"at[ \t]+least|less[ \t]+than|more[ \t]+than)[ \t]*)?"
|
||
)
|
||
operator = r"(?:(?:<=|>=|==|!=|<|>|≤|≥|=|≈|±)[ \t]*)?"
|
||
number_atom = (
|
||
r"[-+]?(?:0[xX][0-9A-Fa-f]+|0[bB][01]+|0[oO][0-7]+|"
|
||
r"(?:\d+(?:[.,]\d+)?|[.,]\d+)(?:[eE][-+]?\d+)?)"
|
||
)
|
||
unit = (
|
||
r"(?:%|℃|°C|ns|us|µs|μs|ms|sec|s|min|h|Hz|kHz|MHz|GHz|THz|"
|
||
r"bit|B|KB|MB|GB|TB|PB|KiB|MiB|GiB|TiB|bps|Kbps|Mbps|Gbps|Tbps|"
|
||
r"B/s|KB/s|MB/s|GB/s|px|mm|cm|m|km|in|ft|V|mV|A|mA|W|kW|Wh|kWh|"
|
||
r"rpm|rps|IOPS|ops/s|req/s|requests/s|cores?|threads?|"
|
||
r"초|분|시간|일|주|개월|년|개|건|회|배|자|줄|명|원|달러)"
|
||
)
|
||
suffix = r"(?:[ \t]*(?:이하|이상|미만|초과))?"
|
||
range_pattern = re.compile(
|
||
rf"(?<![A-Za-z0-9_.]){qualifier}{operator}{number_atom}[ \t]*"
|
||
rf"(?P<range_first_unit>{unit})?[ \t]*"
|
||
rf"(?:\.\.|[-–—~∼/]|에서|부터)[ \t]*{number_atom}[ \t]*"
|
||
rf"(?P<range_second_unit>{unit}){suffix}"
|
||
rf"(?![A-Za-z0-9_])",
|
||
re.IGNORECASE,
|
||
)
|
||
unit_pattern = re.compile(
|
||
rf"(?<![A-Za-z0-9_.]){qualifier}{operator}{number_atom}[ \t]*{unit}{suffix}"
|
||
rf"(?![A-Za-z0-9_])",
|
||
re.IGNORECASE,
|
||
)
|
||
for kind, pattern in (
|
||
("number_range", range_pattern),
|
||
("number_unit", unit_pattern),
|
||
):
|
||
for match in pattern.finditer(visible):
|
||
if kind == "number_range":
|
||
first_unit = match.group("range_first_unit")
|
||
second_unit = match.group("range_second_unit")
|
||
if first_unit is not None and (
|
||
first_unit.replace("µ", "μ").casefold()
|
||
!= second_unit.replace("µ", "μ").casefold()
|
||
):
|
||
continue
|
||
if any(
|
||
start < match.end() and match.start() < end
|
||
for start, end in occupied_numeric_spans
|
||
):
|
||
continue
|
||
occupied_numeric_spans.append(match.span())
|
||
occurrences[kind].append(
|
||
ProtectedOccurrence(
|
||
match.group(0),
|
||
protected_context(visible, match.start(), match.end()),
|
||
)
|
||
)
|
||
standalone_number = re.compile(
|
||
rf"(?<![A-Za-z0-9_.]){qualifier}{operator}{number_atom}{suffix}"
|
||
rf"(?![A-Za-z0-9_.])",
|
||
re.IGNORECASE,
|
||
)
|
||
for match in standalone_number.finditer(visible):
|
||
if any(start < match.end() and match.start() < end for start, end in occupied_numeric_spans):
|
||
continue
|
||
occurrences["number"].append(
|
||
ProtectedOccurrence(
|
||
match.group(0),
|
||
protected_context(visible, match.start(), match.end()),
|
||
)
|
||
)
|
||
occurrences["quote"].extend(reader_visible_double_quotes(visible))
|
||
quote_lines: list[tuple[int, int, str]] = []
|
||
lazy_quote_continuation = False
|
||
offset = 0
|
||
for line in visible.splitlines(keepends=True):
|
||
match = re.match(r"^[ ]{0,3}(?:>[ \t]?)+(?P<body>.*?)(?:\r?\n)?$", line)
|
||
if match:
|
||
body = match.group("body")
|
||
quote_lines.append((offset, offset + len(line), body))
|
||
lazy_quote_continuation = (
|
||
bool(body.strip())
|
||
and not is_thematic_break(body)
|
||
and not re.match(
|
||
r"^[ ]{0,3}(?:#{1,6}(?:[ \t]+|$)|(?:[-+*]|\d+[.)])[ \t]+|"
|
||
r"(?:={1,}|-{1,}|(?:\*[ \t]*){3,}|(?:_[ \t]*){3,})[ \t]*$|"
|
||
r"(?:`{3,}|~{3,})|<[/!?A-Za-z])",
|
||
body,
|
||
)
|
||
)
|
||
elif (
|
||
quote_lines
|
||
and lazy_quote_continuation
|
||
and line.strip()
|
||
and not is_thematic_break(line.rstrip("\r\n"))
|
||
and not re.match(
|
||
r"^[ ]{0,3}(?:#{1,6}(?:[ \t]+|$)|(?:[-+*]|\d+[.)])[ \t]+|"
|
||
r"(?:={3,}|-{3,}|\*{3,}|_{3,})[ \t]*$|<[/!?A-Za-z])",
|
||
line.rstrip("\r\n"),
|
||
)
|
||
):
|
||
quote_lines.append((offset, offset + len(line), line.rstrip("\r\n")))
|
||
elif quote_lines:
|
||
value = "\n".join(item[2].strip() for item in quote_lines).strip()
|
||
if value:
|
||
occurrences["quote"].append(
|
||
ProtectedOccurrence(value, "")
|
||
)
|
||
quote_lines = []
|
||
lazy_quote_continuation = False
|
||
offset += len(line)
|
||
if quote_lines:
|
||
value = "\n".join(item[2].strip() for item in quote_lines).strip()
|
||
if value:
|
||
occurrences["quote"].append(
|
||
ProtectedOccurrence(value, "")
|
||
)
|
||
return occurrences
|
||
|
||
|
||
def protected_items(text: str) -> dict[str, list[str]]:
|
||
return {
|
||
kind: [occurrence.value for occurrence in occurrences]
|
||
for kind, occurrences in protected_occurrences(text).items()
|
||
}
|
||
|
||
|
||
PROTECTED_CONTEXT_MIN_OVERLAP = 2 / 3
|
||
|
||
|
||
def protected_context_features(context: str) -> Counter[str]:
|
||
"""Represent a protected occurrence's bounded left/right semantic context."""
|
||
|
||
if not context:
|
||
return Counter()
|
||
parts = context.split("\u241f")
|
||
features: Counter[str] = Counter()
|
||
if parts[0].startswith("label:"):
|
||
label = normalize_space(parts[0][len("label:") :]).casefold()
|
||
if label:
|
||
features[f"label:{label}"] += 1
|
||
left = parts[-2] if len(parts) >= 3 else ""
|
||
right = parts[-1] if len(parts) >= 2 else ""
|
||
else:
|
||
left = parts[0]
|
||
right = parts[1] if len(parts) >= 2 else ""
|
||
for side, value in (("left", left), ("right", right)):
|
||
for token in value.split("\u241e"):
|
||
normalized = unicodedata.normalize("NFKC", token).strip().casefold()
|
||
if normalized:
|
||
features[f"{side}:{normalized}"] += 1
|
||
return features
|
||
|
||
|
||
def protected_context_overlap(left: Counter[str], right: Counter[str]) -> float:
|
||
"""Return overlap of the smaller bounded context, preserving word direction."""
|
||
|
||
left_size = sum(left.values())
|
||
right_size = sum(right.values())
|
||
if left_size == 0 or right_size == 0:
|
||
return 1.0 if left_size == right_size else 0.0
|
||
return sum((left & right).values()) / min(left_size, right_size)
|
||
|
||
|
||
def context_bound_missing(
|
||
expected: list[ProtectedOccurrence],
|
||
actual: list[ProtectedOccurrence],
|
||
) -> dict[str, int]:
|
||
"""Match every exact value to a compatible local context without reusing copies."""
|
||
|
||
expected_by_value: dict[str, list[ProtectedOccurrence]] = defaultdict(list)
|
||
actual_by_value: dict[str, list[ProtectedOccurrence]] = defaultdict(list)
|
||
for item in expected:
|
||
expected_by_value[item.value].append(item)
|
||
for item in actual:
|
||
actual_by_value[item.value].append(item)
|
||
|
||
missing_by_value: dict[str, int] = {}
|
||
for value, expected_items in expected_by_value.items():
|
||
actual_items = actual_by_value[value]
|
||
expected_features = [
|
||
protected_context_features(item.context) for item in expected_items
|
||
]
|
||
actual_features = [
|
||
protected_context_features(item.context) for item in actual_items
|
||
]
|
||
adjacency: list[list[int]] = []
|
||
for features in expected_features:
|
||
candidates = [
|
||
(protected_context_overlap(features, other), index)
|
||
for index, other in enumerate(actual_features)
|
||
]
|
||
adjacency.append(
|
||
[
|
||
index
|
||
for score, index in sorted(
|
||
candidates, key=lambda candidate: (-candidate[0], candidate[1])
|
||
)
|
||
if score >= PROTECTED_CONTEXT_MIN_OVERLAP
|
||
]
|
||
)
|
||
|
||
# Find a maximum-cardinality bipartite match. The iterative augmenting
|
||
# path avoids recursion limits when a document repeats the same value.
|
||
left_match: dict[int, int] = {}
|
||
right_match: dict[int, int] = {}
|
||
for start in sorted(
|
||
range(len(expected_items)), key=lambda item: len(adjacency[item])
|
||
):
|
||
queue = [start]
|
||
visited_left = {start}
|
||
visited_right: set[int] = set()
|
||
parent_right: dict[int, int] = {}
|
||
free_right: int | None = None
|
||
cursor = 0
|
||
while cursor < len(queue) and free_right is None:
|
||
left_index = queue[cursor]
|
||
cursor += 1
|
||
for right_index in adjacency[left_index]:
|
||
if right_index in visited_right:
|
||
continue
|
||
visited_right.add(right_index)
|
||
parent_right[right_index] = left_index
|
||
owner = right_match.get(right_index)
|
||
if owner is None:
|
||
free_right = right_index
|
||
break
|
||
if owner not in visited_left:
|
||
visited_left.add(owner)
|
||
queue.append(owner)
|
||
if free_right is None:
|
||
continue
|
||
right_index: int | None = free_right
|
||
while right_index is not None:
|
||
left_index = parent_right[right_index]
|
||
previous_right = left_match.get(left_index)
|
||
left_match[left_index] = right_index
|
||
right_match[right_index] = left_index
|
||
right_index = previous_right
|
||
|
||
missing = len(expected_items) - len(left_match)
|
||
if missing:
|
||
missing_by_value[value] = missing
|
||
return missing_by_value
|
||
|
||
|
||
def bounded_change_rate(before: str, after: str) -> float:
|
||
"""Linear-time change estimate that also treats paragraph moves as non-local edits."""
|
||
|
||
def tokens(value: str) -> list[str]:
|
||
return re.findall(r"\w+|[^\w\s]", value, flags=re.UNICODE)
|
||
|
||
before_tokens = tokens(before)
|
||
after_tokens = tokens(after)
|
||
|
||
def multiset_rate(left: Counter[str], right: Counter[str]) -> float:
|
||
total = sum(left.values()) + sum(right.values())
|
||
overlap = sum((left & right).values())
|
||
return 0.0 if total == 0 else 1.0 - (2.0 * overlap / total)
|
||
|
||
lexical_rate = multiset_rate(Counter(before_tokens), Counter(after_tokens))
|
||
before_pairs = Counter(
|
||
"\u241f".join(before_tokens[index : index + 2])
|
||
for index in range(max(0, len(before_tokens) - 1))
|
||
)
|
||
after_pairs = Counter(
|
||
"\u241f".join(after_tokens[index : index + 2])
|
||
for index in range(max(0, len(after_tokens) - 1))
|
||
)
|
||
sequence_rate = multiset_rate(before_pairs, after_pairs)
|
||
|
||
def paragraph_hashes(value: str) -> list[str]:
|
||
paragraphs = [normalize_space(item) for item in re.split(r"\n\s*\n", value)]
|
||
return [hashlib.sha256(item.encode("utf-8")).hexdigest() for item in paragraphs if item]
|
||
|
||
def paragraph_tokens(value: str) -> list[list[str]]:
|
||
paragraphs = [normalize_space(item) for item in re.split(r"\n\s*\n", value)]
|
||
return [re.findall(r"\w+", item.casefold(), flags=re.UNICODE) for item in paragraphs if item]
|
||
|
||
def simhash(values: list[str]) -> int:
|
||
if not values:
|
||
return 0
|
||
weights = [0] * 64
|
||
for value in values:
|
||
hashed = int.from_bytes(
|
||
hashlib.blake2b(value.encode("utf-8"), digest_size=8).digest(), "big"
|
||
)
|
||
for bit in range(64):
|
||
weights[bit] += 1 if hashed & (1 << bit) else -1
|
||
result = 0
|
||
for bit, weight in enumerate(weights):
|
||
if weight >= 0:
|
||
result |= 1 << bit
|
||
return result
|
||
|
||
before_blocks = paragraph_hashes(before)
|
||
after_blocks = paragraph_hashes(after)
|
||
before_block_tokens = paragraph_tokens(before)
|
||
after_block_tokens = paragraph_tokens(after)
|
||
denominator = max(len(before_blocks), len(after_blocks), 1)
|
||
before_counts = Counter(before_blocks)
|
||
after_counts = Counter(after_blocks)
|
||
exact_after: dict[str, list[int]] = defaultdict(list)
|
||
for index, value in enumerate(after_blocks):
|
||
exact_after[value].append(index)
|
||
matched_pairs: list[tuple[int, int]] = []
|
||
used_after: set[int] = set()
|
||
unmatched_before: list[int] = []
|
||
for before_index, value in enumerate(before_blocks):
|
||
candidates = exact_after.get(value, [])
|
||
after_index = next((item for item in candidates if item not in used_after), None)
|
||
if after_index is None:
|
||
unmatched_before.append(before_index)
|
||
continue
|
||
used_after.add(after_index)
|
||
matched_pairs.append((before_index, after_index))
|
||
|
||
unmatched_after = [index for index in range(len(after_blocks)) if index not in used_after]
|
||
token_index: dict[str, list[int]] = defaultdict(list)
|
||
for after_index in unmatched_after:
|
||
for token in set(after_block_tokens[after_index]):
|
||
token_index[token].append(after_index)
|
||
after_simhash = {index: simhash(after_block_tokens[index]) for index in unmatched_after}
|
||
band_index: dict[tuple[int, int], list[int]] = defaultdict(list)
|
||
for after_index, signature in after_simhash.items():
|
||
for band in range(4):
|
||
band_index[(band, (signature >> (band * 16)) & 0xFFFF)].append(after_index)
|
||
|
||
for before_index in unmatched_before:
|
||
values = before_block_tokens[before_index]
|
||
candidate_set: set[int] = set()
|
||
for token in set(values):
|
||
occurrences = token_index.get(token, [])
|
||
if 0 < len(occurrences) <= 3:
|
||
candidate_set.update(occurrences)
|
||
signature = simhash(values)
|
||
if not candidate_set:
|
||
for band in range(4):
|
||
candidate_set.update(
|
||
band_index.get((band, (signature >> (band * 16)) & 0xFFFF), [])
|
||
)
|
||
candidate_set.difference_update(used_after)
|
||
if not candidate_set:
|
||
continue
|
||
before_set = set(values)
|
||
scored: list[tuple[float, int, int]] = []
|
||
for after_index in candidate_set:
|
||
after_set = set(after_block_tokens[after_index])
|
||
union = before_set | after_set
|
||
jaccard = len(before_set & after_set) / max(len(union), 1)
|
||
distance = (signature ^ after_simhash[after_index]).bit_count()
|
||
scored.append((-jaccard, distance, after_index))
|
||
_, distance, after_index = min(scored)
|
||
after_set = set(after_block_tokens[after_index])
|
||
jaccard = len(before_set & after_set) / max(len(before_set | after_set), 1)
|
||
if jaccard < 0.65 and distance > 14:
|
||
continue
|
||
used_after.add(after_index)
|
||
matched_pairs.append((before_index, after_index))
|
||
|
||
moved = 0
|
||
for before_index, after_index in matched_pairs:
|
||
before_position = before_index / max(len(before_blocks) - 1, 1)
|
||
after_position = after_index / max(len(after_blocks) - 1, 1)
|
||
if abs(before_position - after_position) > 0.08:
|
||
moved += 1
|
||
block_move_rate = min(1.0, moved / denominator)
|
||
return round(max(lexical_rate, sequence_rate * 0.5, block_move_rate), 6)
|
||
|
||
|
||
def fidelity_findings(
|
||
linter: Linter,
|
||
baseline_path: Path | None,
|
||
draft_baseline_path: Path | None,
|
||
) -> dict[str, Any]:
|
||
summary: dict[str, Any] = {
|
||
"baseline": None,
|
||
"draft_baseline": None,
|
||
"protected_total": 0,
|
||
"preserved": 0,
|
||
"missing": 0,
|
||
"by_type": {},
|
||
"finalization_change_rate": None,
|
||
"max_finalization_change_rate": linter.rules["thresholds"]["finalization"][
|
||
"max_change_rate"
|
||
],
|
||
}
|
||
if baseline_path is not None:
|
||
baseline_inventory, baseline_bytes = snapshot_file(baseline_path, "baseline")
|
||
baseline = decode_utf8(baseline_bytes, "baseline")
|
||
summary["baseline"] = {
|
||
"path": baseline_inventory["resolved_path"],
|
||
"sha256": baseline_inventory["sha256"],
|
||
}
|
||
rule_for = {
|
||
"fenced_code": "DOC-F001",
|
||
"indented_code": "DOC-F001",
|
||
"inline_code": "DOC-F002",
|
||
"url": "DOC-F003",
|
||
"link_destination": "DOC-F003",
|
||
"number_unit": "DOC-F004",
|
||
"number_range": "DOC-F004",
|
||
"number": "DOC-F004",
|
||
"date": "DOC-F004",
|
||
"version": "DOC-F004",
|
||
"quote": "DOC-F005",
|
||
}
|
||
inventory = protected_occurrences(baseline)
|
||
final_inventory = protected_occurrences(linter.text)
|
||
for kind, items in inventory.items():
|
||
actual_items = final_inventory[kind]
|
||
total = len(items)
|
||
missing_by_value = context_bound_missing(items, actual_items)
|
||
missing = sum(missing_by_value.values())
|
||
summary["by_type"][kind] = {
|
||
"total": total,
|
||
"preserved": total - missing,
|
||
"missing": missing,
|
||
}
|
||
summary["protected_total"] += total
|
||
summary["missing"] += missing
|
||
summary["preserved"] += total - missing
|
||
for value, missing_count in missing_by_value.items():
|
||
if missing_count:
|
||
display = normalize_space(value)
|
||
linter.add(
|
||
rule_for[kind],
|
||
f"기준 문서의 {kind} 보호 항목 또는 주변 의미 연결이 {missing_count}개 보존되지 않았습니다: {display[:120]}",
|
||
offset=0,
|
||
context=display[:200],
|
||
)
|
||
if draft_baseline_path is not None:
|
||
draft_inventory, draft_bytes = snapshot_file(
|
||
draft_baseline_path, "draft baseline"
|
||
)
|
||
draft = decode_utf8(draft_bytes, "draft baseline")
|
||
rate = bounded_change_rate(draft, linter.text)
|
||
maximum = float(linter.rules["thresholds"]["finalization"]["max_change_rate"])
|
||
summary["draft_baseline"] = {
|
||
"path": draft_inventory["resolved_path"],
|
||
"sha256": draft_inventory["sha256"],
|
||
}
|
||
summary["finalization_change_rate"] = rate
|
||
if rate > maximum:
|
||
linter.add(
|
||
"FNL-001",
|
||
f"finalizer 변경률 {rate:.1%}가 상한 {maximum:.1%}를 넘었습니다.",
|
||
offset=0,
|
||
context=f"change_rate={rate}",
|
||
)
|
||
return summary
|
||
|
||
|
||
def lint(args: argparse.Namespace) -> tuple[dict[str, Any], int]:
|
||
document_inventory, document_bytes = snapshot_file(Path(args.document), "document")
|
||
logic_inventory, logic_bytes = snapshot_file(Path(args.logic_map), "logic map")
|
||
term_inventory, term_bytes = snapshot_file(Path(args.term_ledger), "term ledger")
|
||
reader_inventory, reader_bytes = snapshot_file(
|
||
Path(args.reader_contract), "reader contract"
|
||
)
|
||
rules_inventory, _ = snapshot_file(
|
||
Path(args.rules) if args.rules else DEFAULT_RULES_PATH,
|
||
"quality rules",
|
||
)
|
||
document_path = Path(document_inventory["resolved_path"])
|
||
logic_path = Path(logic_inventory["resolved_path"])
|
||
term_path = Path(term_inventory["resolved_path"])
|
||
reader_path = Path(reader_inventory["resolved_path"])
|
||
rules_path = Path(rules_inventory["resolved_path"])
|
||
rules_sha256 = rules_inventory["sha256"]
|
||
rules = load_rules(rules_path)
|
||
if sha256_file(rules_path) != rules_sha256:
|
||
raise InputError("quality rules가 읽는 동안 변경되었습니다.")
|
||
text = decode_utf8(document_bytes, "document")
|
||
logic_text = decode_utf8(logic_bytes, "logic map")
|
||
term_text = decode_utf8(term_bytes, "term ledger")
|
||
reader_text = decode_utf8(reader_bytes, "reader contract")
|
||
logic_map = load_json_text(logic_text, str(logic_path))
|
||
term_ledger = load_json_text(term_text, str(term_path))
|
||
reader_contract = load_json_text(reader_text, str(reader_path))
|
||
validate_with_schema(logic_map, "logic-map.schema.json", "logic map")
|
||
validate_with_schema(term_ledger, "term-ledger.schema.json", "term ledger")
|
||
validate_with_schema(
|
||
reader_contract,
|
||
"reader-contract.schema.json",
|
||
"reader contract",
|
||
)
|
||
validate_contracts(logic_map, term_ledger, reader_contract, rules)
|
||
linter = Linter(
|
||
document_path=document_path,
|
||
text=text,
|
||
logic_map=logic_map,
|
||
term_ledger=term_ledger,
|
||
reader_contract=reader_contract,
|
||
rules=rules,
|
||
)
|
||
linter.lint_markdown()
|
||
linter.lint_logic()
|
||
linter.lint_terms()
|
||
linter.lint_paragraphs()
|
||
fidelity = fidelity_findings(
|
||
linter,
|
||
Path(args.baseline) if args.baseline else None,
|
||
Path(args.draft_baseline) if args.draft_baseline else None,
|
||
)
|
||
severity_order = {"error": 0, "warning": 1, "info": 2}
|
||
linter.findings.sort(
|
||
key=lambda item: (
|
||
item["line"] if item["line"] is not None else 10**9,
|
||
severity_order[item["severity"]],
|
||
item["rule_id"],
|
||
item["message"],
|
||
)
|
||
)
|
||
counts = Counter(item["severity"] for item in linter.findings)
|
||
failed = counts["error"] > 0 or (args.fail_on == "warning" and counts["warning"] > 0)
|
||
report = {
|
||
"schema_version": "1.0",
|
||
"rules_version": rules["rules_version"],
|
||
"rules_sha256": rules_sha256,
|
||
"tool": "lint_document",
|
||
"generated_at": utc_now(),
|
||
"document": {"path": str(document_path), "sha256": document_inventory["sha256"]},
|
||
"logic_map_sha256": logic_inventory["sha256"],
|
||
"term_ledger_sha256": term_inventory["sha256"],
|
||
"reader_contract_sha256": reader_inventory["sha256"],
|
||
"fail_on": args.fail_on,
|
||
"verdict": "fail" if failed else "pass",
|
||
"summary": {
|
||
"errors": counts["error"],
|
||
"warnings": counts["warning"],
|
||
"info": counts["info"],
|
||
"total": len(linter.findings),
|
||
},
|
||
"fidelity": fidelity,
|
||
"limitations": [
|
||
"한국어 전문용어 후보를 형태만으로 완전하게 검출할 수 없습니다. "
|
||
"영문 약어·코드형 식별자와 설정된 소문자 기술어 목록을 보조 신호로 검사합니다."
|
||
],
|
||
"findings": linter.findings,
|
||
}
|
||
return report, 1 if failed else 0
|
||
|
||
|
||
def input_error_report(args: argparse.Namespace, message: str) -> dict[str, Any]:
|
||
return {
|
||
"schema_version": "1.0",
|
||
"rules_version": "unknown",
|
||
"rules_sha256": None,
|
||
"tool": "lint_document",
|
||
"generated_at": utc_now(),
|
||
"document": {"path": str(args.document), "sha256": "0" * 64},
|
||
"fail_on": args.fail_on,
|
||
"verdict": "input_error",
|
||
"summary": {"errors": 1, "warnings": 0, "info": 0, "total": 1},
|
||
"fidelity": {},
|
||
"limitations": [],
|
||
"findings": [
|
||
{
|
||
"rule_id": "INPUT",
|
||
"severity": "error",
|
||
"message": message,
|
||
"path": str(args.document),
|
||
"line": None,
|
||
"column": None,
|
||
"section_id": None,
|
||
"context": None,
|
||
}
|
||
],
|
||
}
|
||
|
||
|
||
def parser() -> argparse.ArgumentParser:
|
||
value = argparse.ArgumentParser(description=__doc__)
|
||
value.add_argument("--document", required=True)
|
||
value.add_argument("--logic-map", required=True)
|
||
value.add_argument("--term-ledger", required=True)
|
||
value.add_argument("--reader-contract", required=True)
|
||
value.add_argument("--baseline", help="revise/review 원본 보호 항목 기준")
|
||
value.add_argument("--draft-baseline", help="finalizer 변경률 기준 07_draft.md")
|
||
value.add_argument("--output", required=True)
|
||
value.add_argument("--fail-on", choices=("error", "warning"), default="error")
|
||
value.add_argument("--rules", help=argparse.SUPPRESS)
|
||
return value
|
||
|
||
|
||
def main(argv: list[str] | None = None) -> int:
|
||
args = parser().parse_args(argv)
|
||
protected = [
|
||
Path(args.document),
|
||
Path(args.logic_map),
|
||
Path(args.term_ledger),
|
||
Path(args.reader_contract),
|
||
Path(args.rules) if args.rules else DEFAULT_RULES_PATH,
|
||
]
|
||
if args.baseline:
|
||
protected.append(Path(args.baseline))
|
||
if args.draft_baseline:
|
||
protected.append(Path(args.draft_baseline))
|
||
try:
|
||
output = prepare_report_output(
|
||
Path(args.output),
|
||
protected_paths=protected,
|
||
expected_tool="lint_document",
|
||
schema_name="lint-report.schema.json",
|
||
)
|
||
except InputError as exc:
|
||
print(f"input error: {exc}", file=sys.stderr)
|
||
return 2
|
||
try:
|
||
report, exit_code = lint(args)
|
||
except InputError as exc:
|
||
report = input_error_report(args, str(exc))
|
||
exit_code = 2
|
||
except (OSError, TypeError, ValueError, KeyError) as exc:
|
||
report = input_error_report(args, f"입력을 검사할 수 없습니다: {exc}")
|
||
exit_code = 2
|
||
try:
|
||
output_path = publish_report_json(output, report)
|
||
except (InputError, OSError) as exc:
|
||
print(
|
||
f"input error: lint report를 쓸 수 없습니다: {output.path}: {exc}",
|
||
file=sys.stderr,
|
||
)
|
||
return 2
|
||
print(
|
||
f"{report['verdict']}: errors={report['summary']['errors']} "
|
||
f"warnings={report['summary']['warnings']} output={output_path}"
|
||
)
|
||
return exit_code
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|