Files
Edward Cheng-I Wu 175f79bcca feat: diff/patch revision mode Slice A — deterministic toolchain (#89 Item 7) (#423)
Slice A of the #89 Item 7 diff/patch revision mode (spec #390 §9.1): shared fail-closed block parser, anchorize script (script-owned ID stamping + block manifest as sole hash source), two-phase apply script with byte-span splicing, two patch schemas, 92 tests incl. the §8.3 byte-identity property test, 3 new CI pytest manifest entries. Zero prompt changes; Slice B wires revision-mode adoption.

Verification: full pytest manifest 51 entries green; cross-model review R1 (1 P1 + 2 P2) → R2 (1 residual + 1 new P2) → R3 all CLOSED, 0 P1/P2; boundary scans clean.

Closes #422

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:44:42 +08:00

397 lines
14 KiB
Python

"""Shared block parser for the diff/patch revision mode toolchain (#89 Item 7 Slice A).
Normative source: `docs/design/2026-06-10-390-diff-patch-revision-mode-spec.md`
§3.1 (block segmentation, marker grammar, malformed-state rules,
unsupported-construct rejection). Both `ars_anchorize_draft.py` and
`ars_apply_revision_patch.py` import this module so segmentation can never
drift between the stamping side and the splicing side.
Design constraints the implementation must not violate:
- **Fail-closed, never guess.** Anything the line-based scan cannot
classify into the §3.1 supported block classes raises
``BlockParseError`` naming the construct. Mis-anchoring would silently
misroute patches; a loud stop is the contract.
- **Byte-span fidelity.** Blocks carry character offsets into the
original text (UTF-8 decoded, no newline translation) so the apply
script can splice the original byte stream. The parser never
re-serializes content.
- **Hash normalization is read-side only** (§3.2): CRLF→LF, marker line
excluded, block-level leading/trailing blank lines stripped, intra-line
whitespace untouched. Normalized text exists for hash computation and
is never written back.
Supported block classes (§3.1, closed list): fenced code, ATX heading,
table run, list run, blockquote run, plain text run, plus skipped YAML
frontmatter. Setext-underline shapes, line-initial raw-HTML openers
(detector subset: ``^</?[A-Za-z]``, NOT full CommonMark HTML-block
coverage — spec §10 R3 P2 advisory), and footnote definitions are
rejected by name.
"""
from __future__ import annotations
import hashlib
import os
import re
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
MARKER_RE = re.compile(r"^<!--block:(B\d{4,})-->$")
MARKER_PREFIX = "<!--block:"
_FENCE_OPEN_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$")
_ATX_HEADING_RE = re.compile(r"^ {0,3}#{1,6}(\s|$)")
_TABLE_LINE_RE = re.compile(r"^ {0,3}\|")
_LIST_START_RE = re.compile(r"^ {0,3}(?:[-*+]\s|\d{1,9}[.)]\s)")
_BLOCKQUOTE_RE = re.compile(r"^ {0,3}>")
_SETEXT_UNDERLINE_RE = re.compile(r"^ {0,3}(=+|-+)\s*$")
_RAW_HTML_OPENER_RE = re.compile(r"^ {0,3}</?[A-Za-z]")
_FOOTNOTE_DEF_RE = re.compile(r"^ {0,3}\[\^[^\]]*\]:")
BLOCK_ID_FORMAT = "B{:04d}"
class BlockParseError(ValueError):
"""Raised on any document the parser refuses to classify (§3.1).
``kind`` is a stable machine-readable failure class; ``line_no`` is
1-based; ``hint`` is the human repair hint the spec requires for
orphan markers and friends.
"""
def __init__(self, kind: str, line_no: int, message: str, hint: str = ""):
self.kind = kind
self.line_no = line_no
self.hint = hint
super().__init__(f"line {line_no}: {message}" + (f" (hint: {hint})" if hint else ""))
@dataclass
class Block:
"""One §3.1 block: marker (optional) + content lines, with char spans."""
kind: str # fence | heading | table | list | blockquote | text
block_id: str | None
marker_span: tuple[int, int] | None # char span of the marker line incl. EOL
span: tuple[int, int] # char span of the content lines incl. trailing EOL (if present)
first_line: str # first content line, EOL stripped (manifest excerpt source)
normalized_text: str = field(repr=False, default="")
norm_hash: str = ""
@property
def full_start(self) -> int:
return self.marker_span[0] if self.marker_span is not None else self.span[0]
@dataclass
class ParsedDocument:
text: str
frontmatter_span: tuple[int, int] | None
blocks: list[Block]
def block_by_id(self) -> dict[str, Block]:
return {b.block_id: b for b in self.blocks if b.block_id is not None}
def next_fresh_id_num(self) -> int:
nums = [int(b.block_id[1:]) for b in self.blocks if b.block_id is not None]
return (max(nums) + 1) if nums else 1
def split_lines_keepends(text: str) -> list[str]:
"""Split on ``\\n`` only, keeping line endings.
``str.splitlines`` also splits on \\x0b/\\x0c/\\u2028/..., which would
desynchronize char offsets from markdown line semantics. A trailing
segment without a newline is kept as the final line.
"""
lines = text.split("\n")
out = [line + "\n" for line in lines[:-1]]
if lines[-1] != "":
out.append(lines[-1])
return out
def _strip_eol(line: str) -> str:
return line.rstrip("\n").rstrip("\r")
def _is_blank(line: str) -> bool:
return _strip_eol(line).strip() == ""
def normalize_block_text(lines: list[str]) -> str:
"""§3.2 hash normalization: LF endings, strip block-level blank edges."""
norm = [_strip_eol(line) for line in lines]
while norm and norm[0].strip() == "":
norm.pop(0)
while norm and norm[-1].strip() == "":
norm.pop()
return "\n".join(norm)
def block_hash(normalized_text: str) -> str:
"""First 12 hex chars of SHA-256 over the normalized text (§3.2)."""
return hashlib.sha256(normalized_text.encode("utf-8")).hexdigest()[:12]
def base_draft_hash(raw: bytes) -> str:
"""First 12 hex chars of SHA-256 over the entire file's raw bytes (§3.2)."""
return hashlib.sha256(raw).hexdigest()[:12]
def atomic_write_bytes(path: Path, data: bytes) -> None:
"""Temp-file + atomic rename write, shared by the Slice A toolchain.
An interrupted write leaves no partial artifact (§3.3); the temp file
is removed on any failure before the rename.
"""
fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.")
try:
with os.fdopen(fd, "wb") as fh:
fh.write(data)
os.replace(tmp_name, path)
except BaseException:
if os.path.exists(tmp_name):
os.unlink(tmp_name)
raise
def _classify_start(stripped: str) -> str:
"""Class of the block starting at a non-blank, non-marker line."""
if _FENCE_OPEN_RE.match(stripped):
return "fence"
if _ATX_HEADING_RE.match(stripped):
return "heading"
if _TABLE_LINE_RE.match(stripped):
return "table"
if _LIST_START_RE.match(stripped):
return "list"
if _BLOCKQUOTE_RE.match(stripped):
return "blockquote"
return "text"
def _reject_unsupported_text_line(stripped: str, line_no: int, first_of_run: bool) -> None:
"""§3.1 unsupported-construct rejection, by name, inside a text run."""
if not first_of_run and _SETEXT_UNDERLINE_RE.match(stripped):
raise BlockParseError(
"unsupported_construct:setext_underline",
line_no,
"setext-underline shape (===/--- directly under a non-blank line) is unsupported",
"use an ATX heading (#/##) instead",
)
if _RAW_HTML_OPENER_RE.match(stripped):
raise BlockParseError(
"unsupported_construct:raw_html_block",
line_no,
"line-initial raw-HTML opener is unsupported (detector subset: ^</?[A-Za-z])",
"raw HTML blocks cannot use patch mode yet",
)
if _FOOTNOTE_DEF_RE.match(stripped):
raise BlockParseError(
"unsupported_construct:footnote_definition",
line_no,
"footnote-definition opener ([^...]:) is unsupported",
"footnote definitions cannot use patch mode yet",
)
def parse_document(text: str, *, fragment: bool = False) -> ParsedDocument:
"""Parse a draft (or, with ``fragment=True``, a patch ``new_text``).
Fragment mode differences: no YAML-frontmatter detection (frontmatter
is never patchable, §3.1, so a fragment starting with ``---`` is just
an unsupported shape), and any ``<!--block:`` marker line is rejected
(ID assignment is the apply script's exclusive authority, §3.2).
"""
lines = split_lines_keepends(text)
n = len(lines)
offsets = [0] * (n + 1)
for i, line in enumerate(lines):
offsets[i + 1] = offsets[i] + len(line)
blocks: list[Block] = []
frontmatter_span: tuple[int, int] | None = None
i = 0
if not fragment and n > 0 and _strip_eol(lines[0]) == "---":
j = 1
while j < n and _strip_eol(lines[j]) != "---":
j += 1
if j >= n:
raise BlockParseError(
"unterminated_frontmatter", 1, "YAML frontmatter fence is never closed"
)
frontmatter_span = (offsets[0], offsets[j + 1])
i = j + 1
pending_marker: tuple[int, str] | None = None # (line index, block id)
def _finish_block(kind: str, start_line: int, end_line: int) -> None:
nonlocal pending_marker
marker_span = None
block_id = None
if pending_marker is not None:
m_idx, block_id = pending_marker
marker_span = (offsets[m_idx], offsets[m_idx + 1])
pending_marker = None
content_lines = lines[start_line:end_line]
normalized = normalize_block_text(content_lines)
blocks.append(
Block(
kind=kind,
block_id=block_id,
marker_span=marker_span,
span=(offsets[start_line], offsets[end_line]),
first_line=_strip_eol(lines[start_line]),
normalized_text=normalized,
norm_hash=block_hash(normalized),
)
)
while i < n:
stripped = _strip_eol(lines[i])
if _is_blank(lines[i]):
if pending_marker is not None:
raise BlockParseError(
"orphan_marker",
pending_marker[0] + 1,
f"marker {pending_marker[1]} is attached to nothing (blank line follows)",
"remove the marker line or re-run anchorize",
)
i += 1
continue
marker_match = MARKER_RE.match(stripped)
if marker_match:
if fragment:
raise BlockParseError(
"marker_in_fragment",
i + 1,
"new_text must not contain <!--block:--> markers (§3.2)",
)
if pending_marker is not None:
raise BlockParseError(
"marker_stack",
pending_marker[0] + 1,
f"marker {pending_marker[1]} is followed by another marker line",
"each marker labels exactly one block",
)
pending_marker = (i, marker_match.group(1))
i += 1
if i >= n:
raise BlockParseError(
"orphan_marker",
pending_marker[0] + 1,
f"marker {pending_marker[1]} is attached to nothing (end of file)",
"remove the marker line or re-run anchorize",
)
continue
kind = _classify_start(stripped)
start = i
if kind == "fence":
fence_match = _FENCE_OPEN_RE.match(stripped)
fence_str = fence_match.group(1)
fence_char = fence_str[0]
fence_len = len(fence_str)
close_re = re.compile(r"^ {0,3}(" + re.escape(fence_char) + r"{" + str(fence_len) + r",})\s*$")
i += 1
while i < n and not close_re.match(_strip_eol(lines[i])):
i += 1
if i >= n:
raise BlockParseError(
"unterminated_fence",
start + 1,
"fenced code block has no matching closing fence",
"close the fence; the parser does not guess at EOF-terminated fences",
)
i += 1 # include the closing fence line
elif kind == "heading":
i += 1
elif kind == "table":
while i < n and _TABLE_LINE_RE.match(_strip_eol(lines[i])):
i += 1
elif kind == "list":
i += 1
while i < n:
cur = _strip_eol(lines[i])
if _is_blank(lines[i]):
# A blank stays inside the run only when the next
# non-blank line continues the list (loose lists).
k = i
while k < n and _is_blank(lines[k]):
k += 1
if k < n:
nxt = _strip_eol(lines[k])
if not MARKER_RE.match(nxt) and (
_LIST_START_RE.match(nxt)
or (nxt[:1] in (" ", "\t") and nxt.strip() != "")
):
i = k
continue
break
if _LIST_START_RE.match(cur) or (cur[:1] in (" ", "\t")):
i += 1
continue
break
elif kind == "blockquote":
while i < n and _BLOCKQUOTE_RE.match(_strip_eol(lines[i])):
i += 1
else: # text run
_reject_unsupported_text_line(stripped, i + 1, first_of_run=True)
i += 1
while i < n:
cur_raw = lines[i]
cur = _strip_eol(cur_raw)
if _is_blank(cur_raw) or MARKER_RE.match(cur):
break
if _classify_start(cur) != "text":
break
_reject_unsupported_text_line(cur, i + 1, first_of_run=False)
i += 1
_finish_block(kind, start, i)
# Unreachable invariant, not a third orphan-marker code path: a marker
# before a blank line is rejected in the blank branch, and a marker at
# EOF is rejected right after it is consumed.
assert pending_marker is None
seen: dict[str, int] = {}
for b in blocks:
if b.block_id is None:
continue
if b.block_id in seen:
raise BlockParseError(
"duplicate_block_id",
0,
f"block ID {b.block_id} appears more than once",
"duplicates can only arise from hand-editing; re-anchorize from a clean draft",
)
seen[b.block_id] = 1
return ParsedDocument(text=text, frontmatter_span=frontmatter_span, blocks=blocks)
def segment_fragment(new_text: str) -> list[Block]:
"""Segment a patch op's ``new_text`` into blocks (§3.2).
Returns the parsed ``Block`` objects (spans are into ``new_text``),
using the same normative segmentation as the document parser. Raises
``BlockParseError`` on anything the parser refuses (unsupported
constructs, embedded markers) and on an empty/blank fragment.
"""
parsed = parse_document(new_text, fragment=True)
if not parsed.blocks:
raise BlockParseError("empty_fragment", 1, "new_text contains no block content")
return parsed.blocks