fix(e2e): confine scenario card validation

Reuse the bounded fence filter for every scenario-card structural check so headings, coverage text, and falsification prose inside examples cannot satisfy the gate. Valid cards may still contain unrelated fenced examples.\n\nTreat each Card cell as a filename stem: remove at most one enclosing backtick pair, require lowercase kebab case, and reject invalid values before they can be stored or joined to the cards directory. This blocks parent traversal, subdirectories, uppercase names, and empty segments.\n\nAdd real-process RED/GREEN coverage for fenced-only cards, traversal, invalid stems, preserved backtick behavior, and the explicit diagnostic for intentionally unsupported pipe-less tables.
This commit is contained in:
Drew Ritter
2026-08-06 14:50:46 -07:00
parent 2a2e0b41c2
commit 7dff0b2d48
2 changed files with 89 additions and 6 deletions

View File

@@ -73,7 +73,7 @@ expected_section() {
if (insec) exit if (insec) exit
} }
insec { print } insec { print }
' "$1" '
} }
# --- extract the first table under the (case-insensitive) heading ---------- # --- extract the first table under the (case-insensitive) heading ----------
@@ -90,6 +90,7 @@ TABLE="$(awk '
if [ -z "$TABLE" ]; then if [ -z "$TABLE" ]; then
echo "no scenario table: $SPEC has no \"E2E scenario cards\" heading with a table under it" >&2 echo "no scenario table: $SPEC has no \"E2E scenario cards\" heading with a table under it" >&2
echo "(heading must be exactly \"E2E scenario cards\" — no numbering or extra words)" >&2 echo "(heading must be exactly \"E2E scenario cards\" — no numbering or extra words)" >&2
echo "(scenario table rows must use leading and trailing outer pipes)" >&2
exit 2 exit 2
fi fi
@@ -121,7 +122,6 @@ while IFS= read -r line; do
c="$(printf '%s' "$c" | normalize)" c="$(printf '%s' "$c" | normalize)"
trimmed+=("$c") trimmed+=("$c")
done done
# cells[0] is empty (before first |); last may be empty too
if [ "$lineno" -eq 1 ]; then if [ "$lineno" -eq 1 ]; then
for i in "${!trimmed[@]}"; do for i in "${!trimmed[@]}"; do
low="$(printf '%s' "${trimmed[$i]}" | tr '[:upper:]' '[:lower:]')" low="$(printf '%s' "${trimmed[$i]}" | tr '[:upper:]' '[:lower:]')"
@@ -153,11 +153,20 @@ while IFS= read -r line; do
fi fi
card="${trimmed[$CARD_COL]:-}" card="${trimmed[$CARD_COL]:-}"
falsif="${trimmed[$FALS_COL]:-}" falsif="${trimmed[$FALS_COL]:-}"
card="${card//\`/}" # tolerate `card-name` backticks in the cell
if [ -z "$card" ] || [ -z "$falsif" ]; then if [ -z "$card" ] || [ -z "$falsif" ]; then
fail "row $lineno: empty Card or Falsification cell" fail "row $lineno: empty Card or Falsification cell"
continue continue
fi fi
case "$card" in
\`*\`)
card="${card#\`}"
card="${card%\`}"
;;
esac
if ! [[ "$card" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
fail "row $lineno: invalid Card value: $card"
continue
fi
ROW_CARD[$ROWS]="$card"; ROW_FALS[$ROWS]="$falsif"; ROWS=$((ROWS + 1)) ROW_CARD[$ROWS]="$card"; ROW_FALS[$ROWS]="$falsif"; ROWS=$((ROWS + 1))
done <<< "$TABLE" done <<< "$TABLE"
@@ -174,15 +183,16 @@ while [ "$i" -lt "$ROWS" ]; do
fail "missing card file: $f" fail "missing card file: $f"
i=$((i + 1)); continue i=$((i + 1)); continue
fi fi
hay="$(expected_section "$f" | normalize)" card_text="$(without_fenced_code "$f")"
hay="$(printf '%s\n' "$card_text" | expected_section | normalize)"
case "$hay" in case "$hay" in
*"$falsif"*) : ;; *"$falsif"*) : ;;
*) fail "$f: falsification line not present verbatim in the ## Expected section. *) fail "$f: falsification line not present verbatim in the ## Expected section.
expected (normalized): $falsif" ;; expected (normalized): $falsif" ;;
esac esac
grep -q '\*\*What this covers\*\*' "$f" || fail "$f: missing **What this covers**" grep -q '\*\*What this covers\*\*' <<< "$card_text" || fail "$f: missing **What this covers**"
for sec in Pre-state Steps Expected Cleanup; do for sec in Pre-state Steps Expected Cleanup; do
grep -Eiq "^#{2,}[[:space:]]*${sec}[[:space:]]*$" "$f" || fail "$f: missing ## ${sec} section" grep -Eiq "^#{2,}[[:space:]]*${sec}[[:space:]]*$" <<< "$card_text" || fail "$f: missing ## ${sec} section"
done done
i=$((i + 1)) i=$((i + 1))
done done

View File

@@ -254,6 +254,79 @@ EOF
assert_exit 0 "real table after fenced example -> exit 0" \ assert_exit 0 "real table after fenced example -> exit 0" \
"$CHECKER" "$TEST_ROOT/t13/spec.md" "$TEST_ROOT/t13/cards" "$CHECKER" "$TEST_ROOT/t13/spec.md" "$TEST_ROOT/t13/cards"
echo "card structure inside a fenced example does not count"
make_spec "$TEST_ROOT/t14"; make_cards "$TEST_ROOT/t14/cards"
cat > "$TEST_ROOT/t14/cards/widget-show-table.md" <<'EOF'
# widget-show-table: illustrative card only
~~~markdown
**What this covers**: the rendered table.
## Pre-state
A built widget binary.
## Steps
1. Run `widget show`.
## Expected
If stdout's last line is not `TOTAL` followed by the two-decimal sum (20.85 for the seed fixture), or the TOTAL row is absent entirely, the scenario FAILS.
## Cleanup
Nothing to clean.
~~~
EOF
assert_exit 1 "required card content found only inside a fence -> exit 1" \
"$CHECKER" "$TEST_ROOT/t14/spec.md" "$TEST_ROOT/t14/cards"
echo "unrelated fenced examples remain valid card content"
make_spec "$TEST_ROOT/t15"; make_cards "$TEST_ROOT/t15/cards"
cat >> "$TEST_ROOT/t15/cards/widget-show-table.md" <<'EOF'
```console
$ widget show
TOTAL 20.85
```
EOF
assert_exit 0 "valid card with unrelated fenced example -> exit 0" \
"$CHECKER" "$TEST_ROOT/t15/spec.md" "$TEST_ROOT/t15/cards"
echo "card names cannot traverse outside the cards directory"
make_spec "$TEST_ROOT/t16"; make_cards "$TEST_ROOT/t16/cards"
sed -i.bak 's/| widget-show-table |/| ..\/outside |/' "$TEST_ROOT/t16/spec.md"
mv "$TEST_ROOT/t16/cards/widget-show-table.md" "$TEST_ROOT/t16/outside.md"
assert_exit 1 "parent-traversing Card value -> exit 1" \
"$CHECKER" "$TEST_ROOT/t16/spec.md" "$TEST_ROOT/t16/cards"
assert_out_contains "invalid Card value" "traversal failure names the invalid value"
echo "card names use lowercase kebab case"
make_spec "$TEST_ROOT/t17"; make_cards "$TEST_ROOT/t17/cards"
sed -i.bak 's/| widget-show-table |/| Upper-Case |/' "$TEST_ROOT/t17/spec.md"
mv "$TEST_ROOT/t17/cards/widget-show-table.md" "$TEST_ROOT/t17/cards/Upper-Case.md"
assert_exit 1 "uppercase Card value -> exit 1" \
"$CHECKER" "$TEST_ROOT/t17/spec.md" "$TEST_ROOT/t17/cards"
assert_out_contains "invalid Card value" "uppercase failure names the invalid value"
make_spec "$TEST_ROOT/t18"; make_cards "$TEST_ROOT/t18/cards"
sed -i.bak 's/| widget-show-table |/| two--segments |/' "$TEST_ROOT/t18/spec.md"
mv "$TEST_ROOT/t18/cards/widget-show-table.md" "$TEST_ROOT/t18/cards/two--segments.md"
assert_exit 1 "empty kebab segment -> exit 1" \
"$CHECKER" "$TEST_ROOT/t18/spec.md" "$TEST_ROOT/t18/cards"
assert_out_contains "invalid Card value" "empty-segment failure names the invalid value"
echo "one enclosing backtick pair remains supported"
make_spec "$TEST_ROOT/t19"; make_cards "$TEST_ROOT/t19/cards"
sed -i.bak 's/| widget-show-table |/| `widget-show-table` |/' "$TEST_ROOT/t19/spec.md"
assert_exit 0 "backticked kebab-case Card value -> exit 0" \
"$CHECKER" "$TEST_ROOT/t19/spec.md" "$TEST_ROOT/t19/cards"
echo "pipe-less tables report the canonical outer-pipe contract"
make_spec "$TEST_ROOT/t20"; make_cards "$TEST_ROOT/t20/cards"
sed -E -i.bak 's/^\| (.*) \|$/\1/' "$TEST_ROOT/t20/spec.md"
assert_exit 2 "pipe-less table remains unsupported -> exit 2" \
"$CHECKER" "$TEST_ROOT/t20/spec.md" "$TEST_ROOT/t20/cards"
assert_out_contains "scenario table rows must use leading and trailing outer pipes" \
"diagnostic names the canonical outer-pipe contract"
echo "no scenario table" echo "no scenario table"
mkdir -p "$TEST_ROOT/t7/cards" mkdir -p "$TEST_ROOT/t7/cards"
printf '# Widget Design\n\nNo table here.\n' > "$TEST_ROOT/t7/spec.md" printf '# Widget Design\n\nNo table here.\n' > "$TEST_ROOT/t7/spec.md"