teleo-infrastructure/lib/frontmatter.py
m3taversal 83526bc90e fix: quote YAML edge values containing colons, skip unparseable files in reweave merge
Root cause of 84% reweave PR rejection rate: claim titles with colons
(e.g., "COAL: Meta-PoW: The ORE Treasury Protocol") written as bare
YAML list items, causing yaml.safe_load to fail during merge.

Three changes:
1. frontmatter.py: _yaml_quote() wraps colon-containing values in double quotes
2. reweave.py: _write_edge_regex uses _yaml_quote for new edges
3. merge.py: skip individual files with parse failures instead of aborting entire PR

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 12:07:28 +01:00

142 lines
5.3 KiB
Python

"""Pure YAML frontmatter parsing and serialization for claim/entity files.
Shared by merge (reweave merge, reciprocal edges) and reweave scripts.
All functions are pure — zero I/O, zero async, zero DB.
Extracted from merge.py Phase 6 of decomposition (Ganymede-approved plan).
"""
import yaml
def _yaml_quote(value: str) -> str:
"""Quote a YAML list value if it contains characters that would break parsing."""
s = str(value)
if ":" in s or s.startswith(("{", "[", "'", '"', "*", "&", "!", "|", ">")):
escaped = s.replace('"', '\\"')
return f'"{escaped}"'
return s
# Edge field names recognized in claim frontmatter.
# Order matters: serialize_edge_fields writes them in this order when appending new fields.
REWEAVE_EDGE_FIELDS = ("supports", "challenges", "challenged_by", "depends_on", "related", "reweave_edges")
# Reciprocal edge mapping: when A has edge_type → B, B gets reciprocal → A.
# When A supports B, B also supports A (approximately symmetric).
# When A challenges B, B is challenged_by A (NOT symmetric — direction matters).
RECIPROCAL_EDGE_MAP = {
"supports": "supports",
"challenges": "challenged_by",
"related": "related",
"depends_on": "related", # A depends_on B → B is related to A (not symmetric)
}
def parse_yaml_frontmatter(text: str) -> tuple[dict | None, str, str]:
"""Parse YAML frontmatter from markdown text.
Returns (frontmatter_dict, raw_fm_text, body_text_including_closing_delimiter).
Returns (None, "", text) if no valid frontmatter found.
raw_fm_text is the text between the --- delimiters (no delimiters, no leading newline).
"""
if not text.startswith("---"):
return None, "", text
end = text.find("\n---", 3)
if end == -1:
return None, "", text
try:
raw_fm_text = text[4:end] # skip "---\n", stop before "\n---"
fm = yaml.safe_load(raw_fm_text)
body = text[end:] # includes closing \n--- and body
return (fm if isinstance(fm, dict) else None), raw_fm_text, body
except Exception:
return None, "", text
def union_edge_lists(main_edges: list, branch_edges: list) -> list:
"""Union two edge lists, preserving order from main (append new at end).
Deduplicates by lowercase slug. Main's order is preserved; branch-only
edges are appended in their original order.
"""
seen = set()
result = []
for edge in main_edges:
key = str(edge).strip().lower()
if key not in seen:
seen.add(key)
result.append(edge)
for edge in branch_edges:
key = str(edge).strip().lower()
if key not in seen:
seen.add(key)
result.append(edge)
return result
def serialize_edge_fields(raw_fm_text: str, merged_edges: dict[str, list]) -> str:
"""Splice merged edge fields into raw frontmatter text, preserving all other fields byte-identical.
Only modifies REWEAVE_EDGE_FIELDS lines. All other frontmatter (title, confidence, type, etc.)
stays exactly as it was in the source text — no yaml.dump reformatting.
Args:
raw_fm_text: The raw YAML text between the --- delimiters (no delimiters included).
merged_edges: {field_name: [edge_values]} for each edge field that should be present.
"""
lines = raw_fm_text.split("\n")
result_lines = []
i = 0
fields_written = set()
while i < len(lines):
line = lines[i]
# Check if this line starts an edge field
matched_field = None
for field in REWEAVE_EDGE_FIELDS:
if line.startswith(f"{field}:"):
matched_field = field
break
if matched_field:
fields_written.add(matched_field)
# Skip the old field and its list items (may be indented with spaces)
i += 1
while i < len(lines) and lines[i] and (lines[i][0] in (' ', '-')):
i += 1
# Write the merged version
edges = merged_edges.get(matched_field, [])
if edges:
result_lines.append(f"{matched_field}:")
for edge in edges:
result_lines.append(f"- {_yaml_quote(edge)}")
# Don't increment i — it's already past the old field
continue
else:
result_lines.append(line)
i += 1
# Append any new edge fields that didn't exist in the original
for field in REWEAVE_EDGE_FIELDS:
if field not in fields_written:
edges = merged_edges.get(field, [])
if edges:
result_lines.append(f"{field}:")
for edge in edges:
result_lines.append(f"- {_yaml_quote(edge)}")
return "\n".join(result_lines)
def serialize_frontmatter(raw_fm_text: str, merged_edges: dict[str, list], body: str) -> str:
"""Rebuild markdown file: splice merged edges into raw frontmatter, append body.
Uses string-level surgery — only edge fields are modified. All other frontmatter
stays byte-identical to the source. No yaml.dump reformatting.
"""
spliced = serialize_edge_fields(raw_fm_text, merged_edges)
# body starts with \n--- (closing delimiter + body text)
if body.startswith("\n"):
return f"---\n{spliced}{body}"
return f"---\n{spliced}\n{body}"