fix(convert): track code fences when splitting OpenClaw sections, and pin it with an invariant (#855)

convert_openclaw() split on `## ` without tracking fenced code blocks, tearing six fenced examples across SOUL.md/AGENTS.md in five agents and leaving dangling fences in each. Implementation from #854 (@guozi-lab): fence helpers in lib.sh used by convert.sh and lint-agents.sh, CommonMark indent aware. Regression test from #853 (@dajiaohuang): every source fenced block must land whole in exactly one output file — verified to fail on the unfixed converter.

Reported by @AmineOzil. Closes #849. Supersedes #853 and #854.

Co-Authored-By: fruit <200041037+guozi-lab@users.noreply.github.com>
Co-Authored-By: Wu Shuwen <108231307+dajiaohuang@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Michael Sitarzewski
2026-09-12 11:57:20 -05:00
committed by GitHub
co-authored by fruit Wu Shuwen Claude Opus 5
parent 6d29a9b087
commit ad9264e309
5 changed files with 140 additions and 5 deletions
+65
View File
@@ -288,6 +288,71 @@ for tool in TOOLS:
report(tool, bad_parse, bad_trip,
"parse and round-trip" if fmt in ("yaml-fm", "toml") else "parse, carry their slug, and have their prose file")
# --- Layer A (split integrity): a source fenced block must survive whole -------
# openclaw is the one tool that splits a single agent body across two files, at
# `## ` headings: SOUL.md (persona) / AGENTS.md (operations). A heading inside a
# fenced code block is block content, not a boundary. Splitting on one cuts the
# block in half and leaves each file holding a dangling fence, which renders as
# broken markdown for every user of that integration (#849). So every fenced
# block in a source must land intact in exactly one of the two files.
SPLIT_FENCE = re.compile(r"^(`{3,}|~{3,})")
def body_lines(text):
"""Mirror lib.sh's get_body, including `$(...)`'s trailing-newline strip."""
out, fm = [], 0
for line in text.split("\n"):
if line == "---":
fm += 1
continue
if fm >= 2:
out.append(line)
while out and out[-1] == "":
out.pop()
return out
def fence_blocks(lines):
"""Inclusive (opener, closer) index pairs; closer = last line if unterminated."""
res, marker, mlen, start = [], "", 0, None
for i, line in enumerate(lines):
m = SPLIT_FENCE.match(line)
if not m:
continue
tok = m.group(1)
if not marker:
marker, mlen, start = tok[0], len(tok), i
elif tok[0] == marker and len(tok) >= mlen:
res.append((start, i)); marker, mlen, start = "", 0, None
if marker and start is not None:
res.append((start, len(lines) - 1))
return res
def has_run(hay, needle):
n = len(needle)
return n > 0 and n <= len(hay) and any(hay[i:i+n] == needle for i in range(len(hay) - n + 1))
split_bad = 0
for slug, (_d, _n, path) in sorted(src.items()):
sfile = os.path.join(OUT, "openclaw", slug, "SOUL.md")
afile = os.path.join(OUT, "openclaw", slug, "AGENTS.md")
if not (os.path.isfile(sfile) and os.path.isfile(afile)):
continue
body = body_lines(open(os.path.join(R, path), encoding="utf-8").read())
bl = fence_blocks(body)
if not bl:
continue
outs = [open(sfile, encoding="utf-8").read().split("\n"),
open(afile, encoding="utf-8").read().split("\n")]
for a, b in bl:
if not any(has_run(o, body[a:b + 1]) for o in outs):
split_bad += 1
if split_bad <= 3:
bad(f"openclaw: {slug} source fenced block at lines {a+1}-{b+1} is torn across "
f"SOUL.md/AGENTS.md — a `## ` heading inside the fence was treated as a section boundary")
if split_bad:
if split_bad > 3: bad(f"openclaw: ...and {split_bad-3} more torn fenced blocks")
else:
ok(f"openclaw: all {N} agents keep every source fenced block whole in one output file")
# --- Layer A (app-facing): every SOURCE frontmatter strict-parsed above -------
for m in src_bad[:5]: bad(m)
if len(src_bad) > 5: bad(f"...and {len(src_bad)-5} more source frontmatter problems")