diff --git a/.github/workflows/check-tools.yml b/.github/workflows/check-tools.yml index 369f4b56..d0226f9b 100644 --- a/.github/workflows/check-tools.yml +++ b/.github/workflows/check-tools.yml @@ -36,6 +36,9 @@ jobs: # maintainers regenerate at landing) and enforced on pushes to main. run: bash scripts/test-convert-outputs.sh ${{ github.event_name == 'pull_request' && '--drift=advisory' || '' }} + - name: Parse indented code fences like lib.sh in the outputs eval + run: bash scripts/test-convert-fence-model.sh + - name: Validate agent selection (install.sh --agent / --agents-file) run: bash scripts/test-agent-selection.sh diff --git a/scripts/test-convert-fence-model.sh b/scripts/test-convert-fence-model.sh new file mode 100755 index 00000000..e8092e52 --- /dev/null +++ b/scripts/test-convert-fence-model.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# +# test-convert-fence-model.sh — the converted-outputs eval must parse source +# code fences the way lib.sh and GitHub do. +# +# The eval's Python model read fences only at column 0, while CommonMark (and +# lib.sh's fence_open_p / fence_closes_p, aligned to it in #855 and #1028) +# allows up to three spaces of indentation and does not constrain a closer by +# the opener's indent. With an indented opening fence and a column-0 closer, +# the model missed the opener and treated the closer as its own opener, so it +# reported the following lines as a torn fenced block across SOUL.md/AGENTS.md +# — a hard failure in the Check Tools workflow for an agent file that lint, +# GitHub and convert_openclaw all handle correctly. +# +# Builds a throwaway repo around the real eval and one such fixture, then +# requires the eval to validate it cleanly. `--update` keeps the check on a +# fresh repo without a committed manifest. +# +# Usage: ./scripts/test-convert-fence-model.sh + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +scratch="$(mktemp -d "${TMPDIR:-/tmp}/agency-fence-model.XXXXXX")" +trap 'rm -rf "$scratch"' EXIT + +mkdir -p "$scratch/repo/scripts" "$scratch/repo/engineering" +cp "$SCRIPT_DIR/convert.sh" "$SCRIPT_DIR/lib.sh" "$SCRIPT_DIR/build-hermes-plugin.py" \ + "$SCRIPT_DIR/test-convert-outputs.sh" "$scratch/repo/scripts/" + +cat > "$scratch/repo/divisions.json" <<'EOF' +{ + "divisions": { + "engineering": {"label": "Engineering", "icon": "Code", "color": "#3B82F6"} + } +} +EOF + +cat > "$scratch/repo/engineering/fence-fixture.md" <<'EOF' +--- +name: Fence Model Fixture +description: Fixture agent with an indented opening code fence +color: blue +--- +## Identity + ```text + code sample +``` +## Core Mission +mission text +EOF + +cd "$scratch/repo" +if ! bash scripts/test-convert-outputs.sh --update > "$scratch/eval.log" 2>&1; then + cat "$scratch/eval.log" >&2 + echo "FAIL: the outputs eval rejected a GitHub-valid indented opening fence" >&2 + exit 1 +fi +if grep -q 'torn across' "$scratch/eval.log"; then + echo "FAIL: the outputs eval reported a torn fence for correctly split output" >&2 + exit 1 +fi +echo "PASS: the outputs eval parses indented code fences like lib.sh and GitHub" diff --git a/scripts/test-convert-outputs.sh b/scripts/test-convert-outputs.sh index 800c3377..fac3fdbf 100755 --- a/scripts/test-convert-outputs.sh +++ b/scripts/test-convert-outputs.sh @@ -330,7 +330,14 @@ elif not colour_bad: # 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,})(.*)$") +# +# This model must read fences exactly as lib.sh's fence_open_p / fence_closes_p +# do (and as GitHub renders): up to three spaces of indentation, same character, +# a closer at least as long as the opener with nothing but whitespace after it. +# Reading fences at column 0 only missed an indented opener and paired its +# column-0 closer with the following lines, reporting a tear the converter had +# not made. +OPEN_FENCE = re.compile(r"^( {0,3})(`{3,}|~{3,})") def body_lines(text): """Mirror lib.sh's get_body, including `$(...)`'s trailing-newline strip.""" @@ -346,13 +353,17 @@ def body_lines(text): return out def fence_blocks(lines): - """Inclusive (opener, closer) index pairs; closer = last line if unterminated.""" + """Inclusive (opener, closer) index pairs; closer = last line if unterminated. + + Mirrors lib.sh fence_open_p / fence_closes_p so the eval sees the same + blocks the converter does. + """ res, marker, mlen, start = [], "", 0, None for i, line in enumerate(lines): - m = SPLIT_FENCE.match(line) + m = OPEN_FENCE.match(line) if not m: continue - tok, rest = m.group(1), m.group(2) + tok, rest = m.group(2), line[m.end():] if not marker: marker, mlen, start = tok[0], len(tok), i elif tok[0] == marker and len(tok) >= mlen and not rest.strip():