diff --git a/plugins/planning/.claude-plugin/plugin.json b/plugins/planning/.claude-plugin/plugin.json index 6d7a6ce3b7..c40084cb0b 100644 --- a/plugins/planning/.claude-plugin/plugin.json +++ b/plugins/planning/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "planning", - "version": "0.39.6", + "version": "0.39.7", "userConfig": { "use_ask_user_question": { "type": "boolean", diff --git a/plugins/planning/CHANGELOG.md b/plugins/planning/CHANGELOG.md index fb79e9e2d0..00b1ec5acb 100644 --- a/plugins/planning/CHANGELOG.md +++ b/plugins/planning/CHANGELOG.md @@ -3,6 +3,37 @@ All notable changes to the `planning` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.39.7] + +### Fixed + +- **`scripts/check-open-questions.sh`:** a ledger or Brief holding more than one heading that + matches the section the gate grades now exits 2 naming every matching line, instead of binding + silently to the first. A ledger copied from the checklist template that kept the template's own + `## Open-question register` (whose example rows are unfenced) above a live one was graded on the + example rows and reported a phantom `status=open`. +- **`scripts/check-open-questions.sh`:** every register-derived ungradeable message (zero rows, + malformed row or id, duplicate or gapped id, unknown status) and the Brief's missing-id message + now name the heading the gate bound to and its line number, so a bind to the wrong section is + visible from stderr alone. +- **`scripts/check-open-questions.sh`:** heading detection and section extraction skip fenced + code blocks. The template's register section carries a fenced `bash` block whose `# Step 3` + comment lines were read as headings, so a live row written after that fence was invisible and + the gate reported an empty register. +- **`scripts/check-open-questions.sh`:** a fenced block opened and never closed is ungradeable, + named as the cause. Every row after such a fence was skipped as documentation, so a register with + one answered row and an open row hidden behind a stray fence graded clean. +- **`scripts/check-open-questions.sh`:** a fence closes only on a line of the same character, at + least as long as its opener, with nothing else on it. A four-backtick fence quoting a + three-backtick example, or a `~~~` line inside a backtick fence, toggled the fence off and read + the quoted example's heading as a live one. One awk function now defines "inside a fence" for + heading detection and section extraction, and the row loop reads the state it emits instead of + detecting fences a third time. +- **`scripts/check-open-questions.sh`:** a named `--brief` is read only when the register retired + a `deferred` or `blocked` row. With nothing to look up the cross-check reports `brief=ok`, so an + unrelated unterminated fence elsewhere in a large Brief no longer fails a clean register. The + Brief must still exist. + ## [0.39.6] ### Fixed diff --git a/plugins/planning/scripts/check-open-questions.sh b/plugins/planning/scripts/check-open-questions.sh index 09d2d1d583..cae1c77ea0 100755 --- a/plugins/planning/scripts/check-open-questions.sh +++ b/plugins/planning/scripts/check-open-questions.sh @@ -19,9 +19,10 @@ # # Exit 0 = every registered question is resolved (register is clean) # Exit 1 = at least one question is still `open` (the contract is not locked) -# Exit 2 = ungradeable: no ledger, no register section, an empty register, a -# malformed row, an unknown status, a duplicate or non-contiguous Q id, -# or a named `--brief` that is missing +# Exit 2 = ungradeable: no ledger, no register section, a duplicate register or +# deferred-questions heading, an unterminated fenced block, an empty +# register, a malformed row, an unknown status, a duplicate or +# non-contiguous Q id, or a named `--brief` that is missing # # Usage: # bash check-open-questions.sh --ledger [--brief ] @@ -36,7 +37,16 @@ # A row the ledger retired but the contract never records is the same silent # hole this gate exists to refuse. When --brief is omitted the verdict says # `brief=unchecked` rather than omitting the field: a check the caller only -# appeared to get is worse than one it knowingly skipped. +# appeared to get is worse than one it knowingly skipped. The named Brief must +# exist, but it is only READ when the register retired a row: with nothing to +# look up the cross-check is satisfied (`brief=ok`), and the Brief's own +# headings and fences are not graded, so a stray fence in an unrelated section +# of a large planning document cannot fail a clean register. +# +# Fenced blocks (``` or ~~~) are documentation in both files. A fence closes +# only on a line of the same character at least as long as its opener with +# nothing else on it, so a four-backtick fence can quote a three-backtick +# example and a `~~~` line inside a backtick fence is content. # # Output (stdout, greppable): # `registered= open= deferred= blocked= withdrawn= answered= brief= status=` @@ -49,18 +59,79 @@ usage() { sed -n '/^# Mechanical/,/^# `registered=/p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' } -# Print a markdown section's body: everything after the first heading whose text -# matches `pattern` (matched case-insensitively, so a ledger that title-cases the -# section still grades), up to the next heading of any level or end of file. -# Exits 3 when no such heading exists — distinguishable from an empty section. -extract_section() { - awk -v pattern="$1" ' - /^#+[[:space:]]/ { - if (inside) { exit } - if (tolower($0) ~ pattern) { inside = 1; found = 1; next } +# The one definition of "inside a fence", shared by heading_matches and +# extract_section so the two cannot disagree about what a heading is; the row +# loop below reads the state these emit rather than detecting fences a third +# time. fence_line(line) returns 1 when the line is a fence delimiter (and +# updates the `fenced` state), 0 otherwise. A fence opens on three or more +# backticks or tildes; it closes only on a line of the SAME character, at least +# as LONG as the opener, with nothing but whitespace after it (a trailing CR +# counts as whitespace, so a CRLF closer closes). Any other fence-shaped line +# while a fence is open is content: a four-backtick fence can quote a +# three-backtick example, and `~~~` inside a backtick fence does not close it. +# A parity toggle got both wrong and read the quoted example's heading as live. +fence_awk=' + function fence_line(line, run, ch, n) { + if (match(line, /^[[:space:]]*(```+|~~~+)/) == 0) { return 0 } + run = substr(line, RSTART, RLENGTH) + sub(/^[[:space:]]*/, "", run) + ch = substr(run, 1, 1) + n = length(run) + if (!fenced) { fenced = 1; fence_ch = ch; fence_len = n; return 1 } + if (ch == fence_ch && n >= fence_len && substr(line, RSTART + RLENGTH) ~ /^[[:space:]]*$/) { + fenced = 0 + return 1 + } + return 0 + } +' + +# List the headings whose text matches `pattern` (case-insensitively, so a ledger +# that title-cases the section still grades), one per line as +# ``. A heading-shaped line inside a fenced block is +# documentation, never a heading: the template's own register carries a fenced +# bash block whose `# Step 3 ...` comment lines would otherwise count, and a +# fenced comment must neither bind a section nor terminate one. A trailing CR is +# stripped so a CRLF ledger names its heading cleanly. No output means no match. +# A fence still open at end of file exits 4: every heading after it was hidden, +# and hiding is the silent drop this gate exists to refuse. +heading_matches() { + awk -v pattern="$1" "$fence_awk"' + fence_line($0) { next } + fenced { next } + /^#+[[:space:]]/ && tolower($0) ~ pattern { + heading = $0 + sub(/\r$/, "", heading) + print NR "\t" heading } - inside { print } - END { if (!found) { exit 3 } } + END { if (fenced) { exit 4 } } + ' "$2" +} + +# Count of, and comma-separated line numbers from, a heading_matches result. +match_count() { printf '%s\n' "$1" | wc -l | tr -d '[:space:]'; } +match_lines() { printf '%s\n' "$1" | cut -f1 | paste -sd, - | sed 's/,/, /g'; } + +# Print a section body: every line after `start` (the matched heading's line +# number) up to the next unfenced heading of any level or end of file, each +# prefixed `` where the marker is `f` for a fence delimiter or a +# line inside a fence and `.` for a live line. The marker carries the fence +# state to the caller so no second fence detector is needed. Taking the line +# number rather than re-matching a pattern binds the body graded to the heading +# the caller already named in its diagnostics. A fence opened in the section and +# never closed exits 4: the row loop would otherwise skip every row after it as +# documentation and grade the register clean with a question hidden. A caller +# that ran heading_matches first never sees that exit (a heading only binds +# outside a fence, so the whole-file check fires first); the guard is for a +# caller that extracts by line number without it. +extract_section() { + awk -v start="$1" "$fence_awk"' + NR <= start { next } + fence_line($0) { print "f\t" $0; next } + fenced { print "f\t" $0; next } + /^#+[[:space:]]/ { exit } + { print ".\t" $0 } + END { if (fenced) { exit 4 } } ' "$2" } @@ -115,13 +186,36 @@ if [[ "$brief_named" -eq 1 ]]; then [[ -f "$brief" ]] || die_ungradeable "--brief not found: $brief" fi -section="$(extract_section 'open-question register' "$ledger")" -awk_status=$? +register_matches="$(heading_matches 'open-question register' "$ledger")" +matches_status=$? +if [[ "$matches_status" -eq 4 ]]; then + die_ungradeable "unterminated fenced block in: $ledger (every heading after it is hidden; close the fence)" +elif [[ "$matches_status" -ne 0 ]]; then + die_ungradeable "could not read the headings of: $ledger" +fi +[[ -n "$register_matches" ]] || die_ungradeable "no '## Open-question register' section in: $ledger" + +# The gate reads exactly one section. Binding to the first match would grade a +# template's instructional copy (whose example rows parse as data) instead of +# the live register below it; binding to the last would guess. Either hides the +# ambiguity, so two matches are a refusal that names both. +register_count="$(match_count "$register_matches")" +if [[ "$register_count" -gt 1 ]]; then + die_ungradeable "$register_count headings match 'open-question register' in: $ledger (lines $(match_lines "$register_matches")); the gate reads exactly one section, so delete the template's instructional copy and keep the single live register" +fi + +register_line="${register_matches%%$'\t'*}" +register_heading="${register_matches#*$'\t'}" +# Every register-derived error names the heading it bound to and its line, so a +# bind to the wrong section is visible from stderr alone. +where="register '$register_heading' at line $register_line" -if [[ "$awk_status" -eq 3 ]]; then - die_ungradeable "no '## Open-question register' section in: $ledger" -elif [[ "$awk_status" -ne 0 ]]; then - die_ungradeable "could not read the register section from: $ledger" +section="$(extract_section "$register_line" "$ledger")" +extract_status=$? +if [[ "$extract_status" -eq 4 ]]; then + die_ungradeable "unterminated fenced block in the register section; rows after it would be skipped as documentation ($where in: $ledger)" +elif [[ "$extract_status" -ne 0 ]]; then + die_ungradeable "could not read the register section from: $ledger ($where)" fi registered=0 @@ -134,17 +228,15 @@ seen_ids=" " deferred_ids="" expected=1 -in_fence=0 skipped_fenced_row=0 while IFS= read -r line; do - # A fenced block inside the register section is documentation (the row shape, - # a worked example), not data. Grading it would fail a ledger for quoting its - # own schema. - if [[ "$line" =~ ^[[:space:]]*(\`\`\`|~~~) ]]; then - in_fence=$((1 - in_fence)) - continue - fi - if [[ "$in_fence" -ne 0 ]]; then + # extract_section prefixes every line with its fence state; strip the marker + # before parsing. A fenced block inside the register section is documentation + # (the row shape, a worked example), not data. Grading it would fail a ledger + # for quoting its own schema. + marker="${line%%$'\t'*}" + line="${line#*$'\t'}" + if [[ "$marker" == "f" ]]; then if [[ "$line" =~ ^[[:space:]]*-[[:space:]]+[Qq][0-9]+([^0-9]|$) ]]; then skipped_fenced_row=1 fi @@ -160,7 +252,7 @@ while IFS= read -r line; do [[ "$line" =~ ^[[:space:]]*-[[:space:]]+[Qq][0-9]+([^0-9]|$) ]] || continue if ! [[ "$line" =~ ^[[:space:]]*-[[:space:]]+[Qq][0-9]+[[:space:]]*\| ]]; then - die_ungradeable "malformed register row (needs 'Q | status | round | question'): $line" + die_ungradeable "malformed register row (needs 'Q | status | round | question'): $line ($where)" fi row="${line#*-}" @@ -180,7 +272,7 @@ while IFS= read -r line; do # reports NF as the number of `|` separators plus one. separators="${row//[!|]/}" if [[ "${#separators}" -lt 3 ]]; then - die_ungradeable "malformed register row (needs 'Q | status | round | question'): $line" + die_ungradeable "malformed register row (needs 'Q | status | round | question'): $line ($where)" fi # No leading zeros, and no Q0. `[[ ]]` numeric comparison evaluates its @@ -191,13 +283,13 @@ while IFS= read -r line; do # by the register's own contract anyway. num="${id#[Qq]}" if ! [[ "$num" =~ ^[1-9][0-9]*$ ]]; then - die_ungradeable "malformed question id (expected Q1, Q2, … with no leading zero): $id" + die_ungradeable "malformed question id (expected Q1, Q2, … with no leading zero): $id ($where)" fi # Normalize so `q3` and `Q3` collide as the same id. id="Q$num" case "$seen_ids" in - *" $id "*) die_ungradeable "duplicate question id: $id" ;; + *" $id "*) die_ungradeable "duplicate question id: $id ($where)" ;; *) ;; # not seen before — fall through and register it esac seen_ids="$seen_ids$id " @@ -206,7 +298,7 @@ while IFS= read -r line; do # a gap is a row that went missing after it was written — the exact silent drop # this gate is here to refuse. Ungradeable, never a pass. if [[ "$num" -ne "$expected" ]]; then - die_ungradeable "non-contiguous question id: expected Q$expected, got $id" + die_ungradeable "non-contiguous question id: expected Q$expected, got $id ($where)" fi expected=$((expected + 1)) @@ -223,30 +315,56 @@ while IFS= read -r line; do blocked=$((blocked + 1)) deferred_ids="$deferred_ids$id " ;; - *) die_ungradeable "unknown status '$status_field' in row: $line" ;; + *) die_ungradeable "unknown status '$status_field' in row: $line ($where)" ;; esac done <<<"$section" if [[ "$registered" -eq 0 ]]; then if [[ "$skipped_fenced_row" -eq 1 ]]; then - die_ungradeable "the register section holds no question rows in: $ledger (rows inside a fenced block are ignored by design; register rows must be unfenced)" + die_ungradeable "the register section holds no question rows in: $ledger ($where; rows inside a fenced block are ignored by design; register rows must be unfenced)" fi - die_ungradeable "the register section holds no question rows in: $ledger" + die_ungradeable "the register section holds no question rows in: $ledger ($where)" fi - brief_state="unchecked" -if [[ "$brief_named" -eq 1 ]]; then - deferred_section="$(extract_section 'deferred questions' "$brief")" - brief_awk=$? - - if [[ "$brief_awk" -eq 3 ]]; then +if [[ "$brief_named" -eq 1 && -z "$deferred_ids" ]]; then + # Nothing was retired, so there is nothing to look up and the Brief is not + # read. Its headings and fences are graded only in service of the lookup: a + # Brief is a large planning document with its own code samples, and refusing a + # clean register over a stray fence in a section this gate never grades would + # fail the caller for a defect outside the check they asked for. The + # existence check above still ran; the caller named a file that must exist. + brief_state="ok" +elif [[ "$brief_named" -eq 1 ]]; then + brief_matches="$(heading_matches 'deferred questions' "$brief")" + brief_matches_status=$? + if [[ "$brief_matches_status" -eq 4 ]]; then + die_ungradeable "unterminated fenced block in: $brief (every heading after it is hidden; close the fence)" + elif [[ "$brief_matches_status" -ne 0 ]]; then + die_ungradeable "could not read the headings of: $brief" + fi + brief_where="" + if [[ -z "$brief_matches" ]]; then if [[ -n "$deferred_ids" ]]; then die_ungradeable "no '### Deferred questions' section in: $brief (register retires:${deferred_ids% })" fi deferred_section="" - elif [[ "$brief_awk" -ne 0 ]]; then - die_ungradeable "could not read the deferred-questions section from: $brief" + else + # Same one-section rule as the register: two matches are a refusal, not a guess. + brief_count="$(match_count "$brief_matches")" + if [[ "$brief_count" -gt 1 ]]; then + die_ungradeable "$brief_count headings match 'deferred questions' in: $brief (lines $(match_lines "$brief_matches")); the gate reads exactly one section" + fi + brief_line="${brief_matches%%$'\t'*}" + brief_heading="${brief_matches#*$'\t'}" + brief_where=" (deferred questions '$brief_heading' at line $brief_line)" + deferred_section="$(extract_section "$brief_line" "$brief")" + brief_extract_status=$? + if [[ "$brief_extract_status" -eq 4 ]]; then + die_ungradeable "unterminated fenced block in the deferred-questions section of: $brief$brief_where" + elif [[ "$brief_extract_status" -ne 0 ]]; then + die_ungradeable "could not read the deferred-questions section from: $brief$brief_where" + fi fi missing="" @@ -269,7 +387,7 @@ if [[ "$brief_named" -eq 1 ]]; then fi done if [[ -n "$missing" ]]; then - die_ungradeable "deferred/blocked question(s) absent from the Brief's deferred questions: ${missing% }" + die_ungradeable "deferred/blocked question(s) absent from the Brief's deferred questions: ${missing% }$brief_where" fi brief_state="ok" fi diff --git a/plugins/planning/scripts/check-open-questions.test.sh b/plugins/planning/scripts/check-open-questions.test.sh index 91865e4c09..86e1a1777a 100755 --- a/plugins/planning/scripts/check-open-questions.test.sh +++ b/plugins/planning/scripts/check-open-questions.test.sh @@ -342,6 +342,271 @@ EOF )" expect_exit "open row wins over a passing brief check -> 1" 1 --ledger "$open_plus" --brief "$brief_ok" +# stderr_of — the script's stderr alone, for message assertions. +stderr_of() { + # shellcheck disable=SC2069 # deliberate: stderr to the capture, stdout dropped + bash "$SUT" "$@" 2>&1 >/dev/null +} + +# heading_lines — comma-separated 1-based line numbers of the +# headings matching , computed from the fixture so an assertion never +# hard-codes a line that a preamble edit would move. +heading_lines() { + grep -n -i -E "^#+[[:space:]].*$2" "$1" | cut -d: -f1 | paste -sd, - | sed 's/,/, /g' +} + +# 26. Two headings match the register pattern. A ledger copied from the template +# keeps the template's instructional section, whose example rows are unfenced +# and parse as data, and appends a live register further down. Grading the +# first match reports a phantom verdict off the examples; grading the last +# guesses. The gate refuses: exit 2, both lines named, never a verdict. +dup="$( + mkledger <<'EOF' +- Q1 | answered | round 1 | Who can write comments? | enrolled + instructor + admin +- Q2 | open | round 1 | What content format? | +- Q3 | deferred | round 2 | Moderation model? | post-V1 +EOF +)" +printf '\n## Open-question register\n\n- Q1 | answered | round 1 | Live question? | yes\n' >>"$dup" +expect_exit "duplicate register headings -> 2" 2 --ledger "$dup" +expect_stdout "duplicate register headings report ungradeable" "status=ungradeable" --ledger "$dup" +dup_out="$(bash "$SUT" --ledger "$dup" 2>/dev/null)" +if [[ "$dup_out" != *"status=open"* && "$dup_out" != *"status=clean"* ]]; then + pass "duplicate register headings never grade a verdict" +else + fail "duplicate register headings never grade a verdict (stdout: '$dup_out')" +fi +dup_err="$(stderr_of --ledger "$dup")" +dup_want="lines $(heading_lines "$dup" 'open-question register')" +if [[ "$dup_err" == *"$dup_want"* ]]; then + pass "duplicate register headings name both lines" +else + fail "duplicate register headings name both lines (want '$dup_want', stderr: '$dup_err')" +fi + +# 27. A second match at another level and case is still a duplicate: the match +# is on heading text, not on `## ` exactly. +dup_level="$( + mkledger <<'EOF' +- Q1 | answered | round 1 | a | x +EOF +)" +printf '\n### Open-Question Register\n\n- Q1 | answered | round 1 | b | y\n' >>"$dup_level" +expect_exit "duplicate heading at another level -> 2" 2 --ledger "$dup_level" + +# 28. The zero-rows message names the heading the gate bound to and its line, so +# a bind to the wrong section is visible from stderr alone. +empty_named="$(mkledger "$brief_dup" <<'EOF' +## Brief + +### Deferred questions + +- Q9 — an example row from a template + +### Deferred questions + +- Q2 — Moderation model? — **arbiter: /planning:plan** +- Q4 — Retention window? — **arbiter: USER-RESERVED** + +## Plan +EOF +expect_exit "duplicate deferred-questions headings -> 2" 2 --ledger "$mixed" --brief "$brief_dup" +brief_dup_err="$(stderr_of --ledger "$mixed" --brief "$brief_dup")" +brief_dup_want="lines $(heading_lines "$brief_dup" 'deferred questions')" +if [[ "$brief_dup_err" == *"$brief_dup_want"* ]]; then + pass "duplicate deferred-questions headings name both lines" +else + fail "duplicate deferred-questions headings name both lines (want '$brief_dup_want', stderr: '$brief_dup_err')" +fi + +# 31. The missing-id message names the deferred-questions heading's line. +brief_missing_line="$(heading_lines "$brief_missing" 'deferred questions')" +brief_missing_err="$(stderr_of --ledger "$mixed" --brief "$brief_missing")" +if [[ "$brief_missing_err" == *"at line $brief_missing_line"* ]]; then + pass "missing-id error names the deferred-questions line" +else + fail "missing-id error names the deferred-questions line (want line $brief_missing_line, stderr: '$brief_missing_err')" +fi + +# 32. A heading-shaped comment inside a fenced block does not end the section. +# The template's own register carries a fenced bash block whose `# Step 3` +# comment lines look like headings; a live row written after that fence must +# still be graded, so this ledger is `open` (exit 1), not empty (exit 2). +fenced_comment="$( + mkledger <<'EOF' +```bash +# Step 3, before locking the contract +bash check-open-questions.sh --ledger x +``` + +- Q1 | open | round 1 | Visible after the fence? | +EOF +)" +expect_exit "row after a fenced heading-shaped comment is graded -> 1" 1 --ledger "$fenced_comment" + +# 33. A heading-shaped line inside a fence before the real register is not a +# heading either: the gate binds to the real one and grades clean. +fenced_head="$TMP/fenced-head.md" +# shellcheck disable=SC2016 # the backticks are literal fence markers, not expansion +printf '# Checklist\n\n```bash\n# open-question register helper\n```\n\n## Open-question register\n\n- Q1 | answered | round 1 | a | x\n' >"$fenced_head" +expect_exit "fenced heading-shaped line is not a register heading -> 0" 0 --ledger "$fenced_head" + +# 34. A fence opened in the register and never closed would make every later +# row documentation: one answered row, then an unterminated fence hiding an +# open one, graded clean. That is the silent drop the gate refuses, so it is +# ungradeable and the message names the cause. +unterminated="$( + mkledger <<'EOF' +- Q1 | answered | round 1 | fine | ok + +```text +accidentally unterminated example fence +- Q2 | open | round 1 | hidden by the fence? | +EOF +)" +expect_exit "unterminated fence in the register -> 2" 2 --ledger "$unterminated" +unterminated_err="$(stderr_of --ledger "$unterminated")" +if [[ "$unterminated_err" == *"unterminated fenced block"* ]]; then + pass "unterminated fence error names the cause" +else + fail "unterminated fence error names the cause (stderr: '$unterminated_err')" +fi + +# 35. A fence opened before the register heading and never closed hides the +# heading itself: ungradeable with the same cause, not "no register section". +unterminated_before="$TMP/unterminated-before.md" +# shellcheck disable=SC2016 # literal fence markers +printf '# Checklist\n\n```bash\nnever closed\n\n## Open-question register\n\n- Q1 | answered | round 1 | a | x\n' >"$unterminated_before" +expect_exit "unterminated fence before the register -> 2" 2 --ledger "$unterminated_before" +unterminated_before_err="$(stderr_of --ledger "$unterminated_before")" +if [[ "$unterminated_before_err" == *"unterminated fenced block"* ]]; then + pass "unterminated fence before the register names the cause" +else + fail "unterminated fence before the register names the cause (stderr: '$unterminated_before_err')" +fi + +# 36. The Brief's deferred-questions section gets the same refusal. +brief_unterminated="$TMP/plan-unterminated.md" +cat >"$brief_unterminated" <<'EOF' +## Brief + +### Deferred questions + +```text +- Q2 — Moderation model? — **arbiter: /planning:plan** +- Q4 — Retention window? — **arbiter: USER-RESERVED** +EOF +expect_exit "unterminated fence in the Brief's deferred questions -> 2" 2 --ledger "$mixed" --brief "$brief_unterminated" + +# 37. The Brief is read only when the register retired a row. With nothing +# deferred or blocked there is nothing to look up, so a stray unclosed fence +# in some unrelated section of a large Brief must not flip a clean register +# to ungradeable: the cross-check is vacuously satisfied and says so. +brief_unrelated_fence="$TMP/plan-unrelated-fence.md" +cat >"$brief_unrelated_fence" <<'EOF' +## Brief + +### Goal + +ship it + +## Plan + +```bash +echo "a code sample whose fence was never closed" +EOF +expect_exit "nothing retired: an unrelated unterminated fence in the Brief is not graded -> 0" 0 --ledger "$clean" --brief "$brief_unrelated_fence" +expect_stdout "nothing retired: brief=ok is reported, not unchecked" "brief=ok" --ledger "$clean" --brief "$brief_unrelated_fence" + +# 38. A fence closes only on its own delimiter. A four-backtick fence can hold a +# three-backtick example (the documented way to show a fenced block inside +# one), so the inner ``` must not toggle the outer fence off. Here the outer +# fence wraps an example register heading; with a parity toggle it would +# count as a second live heading and exit 2. +nested_fence="$TMP/nested-fence.md" +cat >"$nested_fence" <<'EOF' +# Checklist + +````markdown +An example ledger: + +```text +## Open-question register + +- Q1 | open | round 1 | example only | +``` +```` + +## Open-question register + +- Q1 | answered | round 1 | live question | yes +EOF +expect_exit "a shorter inner fence does not close a longer outer fence -> 0" 0 --ledger "$nested_fence" + +# 39. A tilde fence line inside a backtick fence is content, not a closer, and +# the other way round. The heading behind it stays hidden. +mixed_delims="$TMP/mixed-delims.md" +cat >"$mixed_delims" <<'EOF' +# Checklist + +```text +~~~ +## Open-question register +~~~ +``` + +~~~text +``` +## Open-question register +``` +~~~ + +## Open-question register + +- Q1 | answered | round 1 | live question | yes +EOF +expect_exit "a fence of the other character does not close the open one -> 0" 0 --ledger "$mixed_delims" + +# 40. A closing fence may be longer than its opener; it still closes. +longer_closer="$TMP/longer-closer.md" +cat >"$longer_closer" <<'EOF' +# Checklist + +```text +## Open-question register +```` + +## Open-question register + +- Q1 | answered | round 1 | live question | yes +EOF +expect_exit "a longer closing fence closes the shorter opener -> 0" 0 --ledger "$longer_closer" + if [[ "$fails" -ne 0 ]]; then printf '\n%d test(s) failed.\n' "$fails" >&2 exit 1