#!/usr/bin/env bash set -u ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # shellcheck source=test/harness.sh source "$ROOT/test/harness.sh" # The suite drives the GITHUB backend: its gh() stubs ARE the forge boundary # now, and forge_api/forge_issue_edit/... resolve to the gh invocations those # stubs already intercept (#188). Without this the verbs are undefined. # shellcheck source=lib/forge.sh . "$ROOT/lib/forge.sh" forge_select github # shellcheck source=lib/ruling.sh source "$ROOT/lib/ruling.sh" TMP="$(mktemp -d)" trap 'rm -rf "$TMP"' EXIT # --------------------------------------------------------------------------- # Pure decisions. Epochs are arbitrary fixed numbers — no wall clock anywhere. # --------------------------------------------------------------------------- check "needs-ruling is stale-exempt" 0 "EXEMPT" ruling_stale_exempt <<< $'claimed\nneeds-ruling' check "a flag-free issue sweeps normally" 0 "SWEEP" ruling_stale_exempt <<< $'claimed' L=100000 # the labeled event, in every window case below check "escalation 14 minutes before the flag accompanies it" 0 "ACCOMPANIED" \ ruling_bare_decision setter "$L" <<<"setter $((L - 840))" check "escalation exactly 15 minutes before still accompanies (no earlier than)" 0 "ACCOMPANIED" \ ruling_bare_decision setter "$L" <<<"setter $((L - 900))" check "escalation after the flag accompanies it" 0 "ACCOMPANIED" \ ruling_bare_decision setter "$L" <<<"setter $((L + 60))" check "escalation 16 minutes before is bare" 0 "BARE" \ ruling_bare_decision setter "$L" <<<"setter $((L - 960))" check "somebody else's comment does not satisfy the contract" 0 "BARE" \ ruling_bare_decision setter "$L" <<<"bystander $((L - 60))" check "no comment at all is bare" 0 "BARE" \ ruling_bare_decision setter "$L" fold\nOptions: A — x B — y\nRecommend: A.\nBlocked: nothing\nDefault: none\n' check "leading whitespace is tolerated" 0 "SHAPED" \ ruling_shape_decision <<<$' Options: A B\n Recommend: A.\n Blocked: x\n Default: none' check "one missing label is named" 0 "MALFORMED Recommend:" \ ruling_shape_decision <<<$'Options: A — x B — y\nBlocked: z\nDefault: none — hard block' check "two missing labels are both named" 0 "MALFORMED Recommend: Default:" \ ruling_shape_decision <<<$'Options: A — x B — y\nBlocked: z' check "labels only mid-sentence are malformed — line-anchoring is the rule" 0 \ "MALFORMED Options: Recommend: Blocked: Default:" \ ruling_shape_decision <<<$'we should Options: A or B, and I Recommend: A; Blocked: no; Default: none' check "an empty body is missing everything" 0 "MALFORMED Options: Recommend: Blocked: Default:" \ ruling_shape_decision >"$TMP/posted-$n" file="$TMP/repos_${REPO%%/*}_${REPO#*/}_issues_${n}_comments.json" [ -f "$file" ] || printf '[]\n' >"$file" jq --arg b "$body" --arg at "$(iso "$NOW")" \ '. + [{"user":{"login":"sweep-bot"},"created_at":$at,"html_url":"https://x/posted","body":$b}]' \ "$file" >"$file.tmp" && mv "$file.tmp" "$file" elif [ "$1" = issue ] && [ "$2" = edit ]; then printf '%s\n' "$*" >>"$TMP/edits" fi } posts() { [ -f "$TMP/posted-$1" ] && grep -c '^----$' "$TMP/posted-$1" || echo 0; } timeline_file() { printf '%s/repos_owner_repo_issues_%s_timeline.json' "$TMP" "$1"; } comments_file() { printf '%s/repos_owner_repo_issues_%s_comments.json' "$TMP" "$1"; } # -- bare flag, swept twice: exactly one comment ----------------------------- T=$((NOW - 3600)) jq -n --arg at "$(iso "$T")" \ '[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$at}]' \ >"$(timeline_file 9)" printf '[]\n' >"$(comments_file 9)" check "a bare flag is commented on" 0 "ruling flag is bare" reconcile_ruling 9 "$T" "$NOW" check "...and the sweep never posted twice" 0 "" reconcile_ruling 9 "$T" "$NOW" check "one bare comment across two sweeps" 0 "1" posts 9 check "the bare comment carries its marker" 0 "" \ grep -qF '' "$TMP/posted-9" check "the bare comment names the missing contract" 0 "" \ grep -q 'question' "$TMP/posted-9" check "a bare flag draws no shape or rung comment — bare stops here" 1 "" \ grep -qE 'needs-ruling-(shape|rung)' "$TMP/posted-9" # -- accompanied, conforming (bold, the live spelling): silence -------------- jq -n --arg at "$(iso "$T")" \ '[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$at}]' \ >"$(timeline_file 10)" jq -n --arg at "$(iso "$((T - 840))")" --arg b "$TPL_BOLD" \ '[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc","body":$b}]' \ >"$(comments_file 10)" reconcile_ruling 10 "$T" "$NOW" >/dev/null check "an accompanied conforming flag posts nothing" 0 "0" posts 10 # -- re-flag: judged on its own escalation, marker scoped per event ---------- T1=$((NOW - 86400)) T2=$((NOW - 7200)) jq -n --arg t1 "$(iso "$T1")" --arg t2 "$(iso "$T2")" \ '[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$t1}, {"event":"unlabeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$t2}, {"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$t2}]' \ >"$(timeline_file 11)" jq -n --arg esc "$(iso "$((T1 - 60))")" --arg marked "$(iso "$((T1 + 300))")" \ '[{"user":{"login":"setter"},"created_at":$esc,"html_url":"https://x/esc1","body":"the first escalation"}, {"user":{"login":"sweep-bot"},"created_at":$marked,"html_url":"https://x/bare1","body":"\nolder episode"}]' \ >"$(comments_file 11)" reconcile_ruling 11 "$T2" "$NOW" >/dev/null check "a re-flag is re-checked against its own escalation" 0 "1" posts 11 # -- nudge: fires past 7 quiet days, links the escalation, resets itself ----- # The escalation is conforming and the rung markers are pre-seeded (by 8 days # in, both rungs fired long ago — the realistic board), so the nudge's # behavior is observed alone. T0=$((NOW - 8 * 86400)) jq -n --arg at "$(iso "$T0")" \ '[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$at}]' \ >"$(timeline_file 12)" jq -n --arg at "$(iso "$((T0 - 60))")" --arg b "$TPL" \ --arg r12 "$(iso "$((T0 + 13 * 3600))")" --arg r24 "$(iso "$((T0 + 25 * 3600))")" \ '[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc12","body":$b}, {"user":{"login":"sweep-bot"},"created_at":$r12,"html_url":"https://x/r12","body":"\nrung"}, {"user":{"login":"sweep-bot"},"created_at":$r24,"html_url":"https://x/r24","body":"\nrung"}]' \ >"$(comments_file 12)" check "8 quiet days nudge" 0 "ruling nudge" reconcile_ruling 12 "$T0" "$NOW" check "one nudge posted" 0 "1" posts 12 check "the nudge links the escalation comment" 0 "" grep -qF 'https://x/esc12' "$TMP/posted-12" check "the nudge addresses the decider" 0 "" grep -qF '@danmt' "$TMP/posted-12" check "the nudge does not tag the flag-setter" 1 "" grep -qF '@setter' "$TMP/posted-12" check "the nudge carries no marker — the comment itself resets the window" 1 "" \ grep -qF "$RULING_BARE_MARKER" "$TMP/posted-12" # The reset, through the surfaces' own activity computation: the nudge the # stub appended is the newest comment, so the recomputed last-activity is NOW. newest_at="$(jq -r 'map(.created_at) | max' "$(comments_file 12)")" check "the posted nudge is now the newest activity" 0 "" \ test "$(date -d "$newest_at" +%s)" = "$NOW" reconcile_ruling 12 "$(date -d "$newest_at" +%s)" "$NOW" >/dev/null check "a sweep right after the nudge holds its silence" 0 "1" posts 12 # -- 6 quiet days: silence. The flag itself is fresh (label churn is not # activity, so a quiet item can be freshly flagged): rung 0, no nudge. ------ jq -n --arg at "$(iso "$((NOW - 3600))")" \ '[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$at}]' \ >"$(timeline_file 13)" jq -n --arg at "$(iso "$((NOW - 3660))")" --arg b "$TPL" \ '[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc13","body":$b}]' \ >"$(comments_file 13)" reconcile_ruling 13 $((NOW - 6 * 86400)) "$NOW" >/dev/null check "6 quiet days do not nudge, and a fresh flag sits on rung 0" 0 "0" posts 13 # -- unreadable timeline: nothing happens ------------------------------------ check "an unreadable timeline invents no verdict" 0 "timeline unreadable" \ reconcile_ruling 14 "$T" "$NOW" check "...and posts nothing" 0 "0" posts 14 # -- malformed escalation, rung 0: the shape comment, exactly once ----------- T15=$((NOW - 3600)) jq -n --arg at "$(iso "$T15")" \ '[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$at}]' \ >"$(timeline_file 15)" jq -n --arg at "$(iso "$((T15 - 60))")" \ --arg b $'Options: A — x B — y\nBlocked: z\nDefault: none — hard block' \ '[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc15","body":$b}]' \ >"$(comments_file 15)" check "a malformed escalation is commented on" 0 "escalation malformed" \ reconcile_ruling 15 "$T15" "$NOW" reconcile_ruling 15 "$T15" "$NOW" >/dev/null check "one shape comment across two sweeps" 0 "1" posts 15 check "the shape comment names exactly the missing label" 0 "" \ grep -qF 'missing required field labels: **Recommend:**' "$TMP/posted-15" check "the shape comment links the escalation" 0 "" grep -qF 'https://x/esc15' "$TMP/posted-15" check "the shape comment quotes the template location" 0 "" \ grep -qF 'BUILDER.md#the-ruling-ask' "$TMP/posted-15" check "no bare comment beside the shape comment" 1 "" \ grep -qF "$RULING_BARE_MARKER" "$TMP/posted-15" check "the shape comment carries its marker" 0 "" \ grep -qF "$RULING_SHAPE_MARKER" "$TMP/posted-15" # -- 13h in, activity minutes old: the 12h rung fires anyway (D14) ----------- T16=$((NOW - 13 * 3600)) jq -n --arg at "$(iso "$T16")" \ '[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$at}]' \ >"$(timeline_file 16)" jq -n --arg at "$(iso "$((T16 - 60))")" --arg b "$TPL_BOLD" \ '[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc16","body":$b}]' \ >"$(comments_file 16)" check "the 12h rung fires despite recent activity — rungs never reset" 0 "12h rung" \ reconcile_ruling 16 $((NOW - 60)) "$NOW" reconcile_ruling 16 $((NOW - 60)) "$NOW" >/dev/null check "one 12h rung comment, once per episode" 0 "1" posts 16 check "the rung comment is addressed to the flag-setter" 0 "" grep -qF '@setter' "$TMP/posted-16" check "the rung comment names the hard block" 0 "" grep -qF 'Default: none' "$TMP/posted-16" check "no nudge rode along — activity is recent and the nudge does reset" 1 "" \ grep -qF '@danmt' "$TMP/posted-16" check "the rung comment carries its marker" 0 "" \ grep -qF "$RULING_RUNG12_MARKER" "$TMP/posted-16" # -- the ladder walked on the cron: 12h rung at 13h, 24h rung at 25h --------- L17=$((NOW - 25 * 3600)) jq -n --arg at "$(iso "$L17")" \ '[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$at}]' \ >"$(timeline_file 17)" jq -n --arg at "$(iso "$((L17 - 60))")" --arg b "$TPL" \ '[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc17","body":$b}]' \ >"$(comments_file 17)" reconcile_ruling 17 "$L17" $((L17 + 13 * 3600)) >/dev/null check "a sweep at 13h posts the 12h rung" 0 "1" posts 17 check "the rung comment names the stated deadline" 0 "" \ grep -qF '2026-07-23T21:00Z' "$TMP/posted-17" check "a sweep at 25h posts the 24h rung — one comment per rung" 0 "24h rung" \ reconcile_ruling 17 "$L17" "$NOW" check "two rung comments total" 0 "2" posts 17 check "the 24h comment names triage's past-24h authority" 0 "" \ grep -qF 'triage picks the option' "$TMP/posted-17" reconcile_ruling 17 "$L17" "$NOW" >/dev/null check "...and never a third within the episode" 0 "2" posts 17 # -- first observed past 24h: the missed 12h moment is not paged after the # fact — the 24h comment carries the whole remaining duty --------------------- T18=$((NOW - 25 * 3600)) jq -n --arg at "$(iso "$T18")" \ '[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$at}]' \ >"$(timeline_file 18)" jq -n --arg at "$(iso "$((T18 - 60))")" \ --arg b $'Options: A — x B — y\nRecommend: A, because x.\nBlocked: z\nDefault: when it feels right' \ '[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc18","body":$b}]' \ >"$(comments_file 18)" reconcile_ruling 18 "$T18" "$NOW" >/dev/null check "a rung first observed past 24h pages once, not retroactively" 0 "1" posts 18 check "only the 24h comment fired" 1 "" grep -qF "$RULING_RUNG12_MARKER" "$TMP/posted-18" check "the unparseable default is reported, not guessed" 0 "" \ grep -qF 'unparseable' "$TMP/posted-18" # -- a re-flag climbs its own ladder: old rung markers belong to the old # episode ------------------------------------------------------------------- TA=$((NOW - 3 * 86400)) TB=$((NOW - 13 * 3600)) jq -n --arg ta "$(iso "$TA")" --arg tb "$(iso "$TB")" \ '[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$ta}, {"event":"unlabeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$tb}, {"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$tb}]' \ >"$(timeline_file 19)" jq -n --arg esc "$(iso "$((TB - 60))")" --arg b "$TPL" \ --arg r12 "$(iso "$((TA + 13 * 3600))")" --arg r24 "$(iso "$((TA + 25 * 3600))")" \ '[{"user":{"login":"sweep-bot"},"created_at":$r12,"html_url":"https://x/r12","body":"\nold episode"}, {"user":{"login":"sweep-bot"},"created_at":$r24,"html_url":"https://x/r24","body":"\nold episode"}, {"user":{"login":"setter"},"created_at":$esc,"html_url":"https://x/esc19","body":$b}]' \ >"$(comments_file 19)" reconcile_ruling 19 "$TB" "$NOW" >/dev/null check "a re-flag climbs its own ladder — old rung markers do not stick" 0 "1" posts 19 check "the re-flag's rung comment is the 12h rung" 0 "" \ grep -qF "$RULING_RUNG12_MARKER" "$TMP/posted-19" # -- unreadable comment list: nothing happens -------------------------------- jq -n --arg at "$(iso "$T")" \ '[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$at}]' \ >"$(timeline_file 20)" check "an unreadable comment list invents no verdict" 0 "comments unreadable" \ reconcile_ruling 20 "$T" "$NOW" check "...and it posts nothing" 0 "0" posts 20 # -- malformed AND on a rung: the shape is owed and the ladder still climbs -- T21=$((NOW - 13 * 3600)) jq -n --arg at "$(iso "$T21")" \ '[{"event":"labeled","label":{"name":"needs-ruling"},"actor":{"login":"setter"},"created_at":$at}]' \ >"$(timeline_file 21)" jq -n --arg at "$(iso "$((T21 - 60))")" \ --arg b $'Options: A — x B — y\nRecommend: A, because x.\nBlocked: z' \ '[{"user":{"login":"setter"},"created_at":$at,"html_url":"https://x/esc21","body":$b}]' \ >"$(comments_file 21)" reconcile_ruling 21 "$T21" "$NOW" >/dev/null check "a malformed escalation still climbs the ladder — two comments" 0 "2" posts 21 check "the shape half fired" 0 "" grep -qF "$RULING_SHAPE_MARKER" "$TMP/posted-21" check "the rung half fired" 0 "" grep -qF "$RULING_RUNG12_MARKER" "$TMP/posted-21" reconcile_ruling 21 "$T21" "$NOW" >/dev/null check "both halves are once-per-episode" 0 "2" posts 21 # -- across every scenario above: not one label write ------------------------ check "the ruling sweep never wrote a label" 1 "" test -f "$TMP/edits" # Grep-level pin for #50 D9: no mutation call in the sweep code names the # flag. The only writes reconcile_ruling makes are comments. check "no add/remove-label mutation names the ruling flag" 1 "" \ grep -rEn -- '(add|remove)-label[^"]*needs-ruling' "$ROOT/actions" "$ROOT/lib" summary