diff --git a/CLAUDE.md b/CLAUDE.md index 1b4691de..a5050236 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -205,7 +205,8 @@ Rules a change must keep: - `ui::draw` reads no file, runs no program and asks no clock, so a `TestBackend` case is an assertion about the screen and not about the machine. - A decision that also has a bash implementation is held to a shared table under `tests/lib/` - (`session-args.cases`, `sweep-findings.json`, `triage.cases`, `sweep-tell.cases`). + (`session-args.cases`, `sweep-findings.json`, `triage.cases`, `sweep-tell.cases`, + `epic-family.json`). - Const thresholds in Rust (`END_GRACE_SECONDS`, `STUCK_CEILING_SECONDS`, …) have a literal twin in the scripts; change both. @@ -239,7 +240,9 @@ Each of these answers one question in one place. Add a caller, never a second co the fleet view's Sweeps section finds, each held to `tests/lib/sweep-findings.json`; `scripts/verifier-epic-candidates` — when an epic family is Psylocke's to sweep. - `scripts/bead-delivery.sh` — whether a bead's work reached the default branch. -- `scripts/work-beads` — the board read, and the epic rule. +- `scripts/work-beads` — the board read, and the epic rule. Which beads under an epic are its + family (an `event` never is) is held to `tests/lib/epic-family.json`, which it, + `verifier-epic-candidates`, `sweep-epics.sh` and the fleet view's `model.rs` all answer. - `scripts/worktree-safety.sh` — whether a worktree can go without losing anything. - `scripts/session-marker.sh` — the marker sentence; `scripts/marker-readers` checks every reader subscribes to `tests/lib/session-args.cases`. diff --git a/fleet-view/src/model.rs b/fleet-view/src/model.rs index 8a2808d1..267b1ab7 100644 --- a/fleet-view/src/model.rs +++ b/fleet-view/src/model.rs @@ -2025,6 +2025,75 @@ mod tests { ); } + /// One row of `tests/lib/epic-family.json`: an epic, everything bd files under it, and which + /// of those are its family. The bash readers' expectation fields this crate has no use for + /// (`last_child_closed_at`) are ignored. + #[derive(Deserialize)] + struct EpicFamilyRow { + name: String, + epic: EpicFamilyBead, + under: Vec, + family: Vec, + family_closed: bool, + } + + #[derive(Deserialize)] + struct EpicFamilyBead { + id: String, + status: String, + #[serde(default = "epic_issue_type")] + issue_type: String, + } + + fn epic_issue_type() -> String { + "epic".into() + } + + /// The rule the fleet view, `scripts/work-beads`, `scripts/verifier-epic-candidates` and + /// `scripts/sweep-epics.sh` all answer, row by row: an `event` under an epic is never its + /// child (cb-cng9, cb-z330, cb-u64g - one defect, three fixes, hence the one table, cb-7iv1). + /// Here the family decides three things: whether the epic is in `epics`, whether an open epic + /// is work of its own (unplanned), and whether a closed one reaches Merged. + #[test] + fn the_epic_family_table_is_answered() { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/lib/epic-family.json"); + let text = + std::fs::read_to_string(path).unwrap_or_else(|e| panic!("epic-family.json: {e}")); + let values: Vec = + serde_json::from_str(&text).expect("epic-family.json is a JSON array"); + let rows: Vec = values + .into_iter() + // An element carrying `_` is the file's own comment - JSON has none. + .filter(|value| value.get("_").is_none()) + .map(|value| serde_json::from_value(value).expect("a well-formed row")) + .collect(); + assert!(!rows.is_empty(), "epic-family.json has no rows"); + + for row in rows { + let epic_id = row.epic.id.clone(); + let epic_open = row.epic.status == "open"; + let beads = std::iter::once(&row.epic) + .chain(&row.under) + .map(|b| bead(&b.id, &b.status, &b.issue_type, &[])) + .collect(); + let buckets = partition_beads(beads); + let childless = row.family.is_empty(); + let has = |list: &[Bead]| list.iter().any(|b| b.id == epic_id); + + assert_eq!(buckets.epics.contains_key(&epic_id), !childless, "{}: in epics", row.name); + if epic_open { + assert_eq!(has(&buckets.unplanned), childless, "{}: open epic as work", row.name); + } else { + assert_eq!( + has(&buckets.merged), + childless || row.family_closed, + "{}: closed epic in Merged", + row.name + ); + } + } + } + /// The set is DIRECT parents, not ancestors: everything before an id's LAST dot. A /// `starts_with("cb-p.")` spelling would call `cb-p` parented by its grandchild and skip it. #[test] diff --git a/scripts/sweep-epics.sh b/scripts/sweep-epics.sh index 0b39f56e..b4d2f1da 100755 --- a/scripts/sweep-epics.sh +++ b/scripts/sweep-epics.sh @@ -55,8 +55,11 @@ while IFS= read -r epic; do children_json="$(bd -C "$repo_root" children "$id" --json 2>/dev/null || echo "[]")" # The newest `closed_at` among closed children - eligibility already means every child is closed, - # so this is "when did the last one land", not "are they all closed". - last_closed="$(jq -r '[.[] | select(.status == "closed") | .closed_at // empty] | sort | last // empty' <<<"$children_json")" + # so this is "when did the last one land", not "are they all closed". `bd children` also returns + # the `event` beads `bd set-state` files under the epic; they are not children, and one closed + # after the last real child would make the close look fresh and hold the offer back. The rule + # every implementation answers is tests/lib/epic-family.json (cb-cng9, cb-z330, cb-u64g, cb-7iv1). + last_closed="$(jq -r '[.[] | select(.issue_type != "event" and .status == "closed") | .closed_at // empty] | sort | last // empty' <<<"$children_json")" minutes=null if [[ -n "$last_closed" ]]; then diff --git a/tests/lib/epic-family.json b/tests/lib/epic-family.json new file mode 100644 index 00000000..834d8378 --- /dev/null +++ b/tests/lib/epic-family.json @@ -0,0 +1,77 @@ +[ + { + "_": "Which beads under an epic are its family. `bd set-state` files its audit record as an `event` bead under the bead it changed, with a dotted child id, and `bd children` returns it - but an event is never a child. That rule reached main broken three times, each time fixed in one implementation only (cb-z330, cb-cng9, cb-u64g), so every implementation answers this table: tests/work-beads.sh, tests/verifier-epic-candidates.sh, tests/sweep-epics.sh and model::tests::the_epic_family_table_is_answered in fleet-view/src/model.rs. `under` is everything bd files under the epic, as `bd children` returns it. The three expectations are stated, not derived, so no reader re-implements the rule in its own test: `family` the ids that ARE children (no implementation exposes membership, so every reader checks only whether it is empty; the ids say which beads count, for the reader of a row); `family_closed` true when the epic has a family and every member is closed; `last_child_closed_at` the newest `closed_at` in the family, or null. An element carrying this `_` key is a comment and is skipped by every reader." + }, + { + "name": "an unsplit epic with one state change is still childless", + "epic": { "id": "ef-a", "status": "open" }, + "under": [ + { "id": "ef-a.1", "issue_type": "event", "status": "closed", "closed_at": "2026-01-05T10:00:00Z" } + ], + "family": [], + "family_closed": false, + "last_child_closed_at": null + }, + { + "name": "a closed epic whose only bead under it is an event is childless", + "epic": { "id": "ef-b", "status": "closed" }, + "under": [ + { "id": "ef-b.1", "issue_type": "event", "status": "closed", "closed_at": "2026-01-05T10:00:00Z" } + ], + "family": [], + "family_closed": false, + "last_child_closed_at": null + }, + { + "name": "an event newer than the last real child does not make the last close fresh", + "epic": { "id": "ef-c", "status": "open" }, + "under": [ + { "id": "ef-c.1", "issue_type": "task", "status": "closed", "closed_at": "2026-01-02T08:00:00Z" }, + { "id": "ef-c.2", "issue_type": "event", "status": "closed", "closed_at": "2026-01-09T08:00:00Z" } + ], + "family": ["ef-c.1"], + "family_closed": true, + "last_child_closed_at": "2026-01-02T08:00:00Z" + }, + { + "name": "an open event does not hold back a family whose children are all closed", + "epic": { "id": "ef-d", "status": "closed" }, + "under": [ + { "id": "ef-d.1", "issue_type": "feature", "status": "closed", "closed_at": "2026-01-03T12:00:00Z" }, + { "id": "ef-d.2", "issue_type": "event", "status": "open", "closed_at": null } + ], + "family": ["ef-d.1"], + "family_closed": true, + "last_child_closed_at": "2026-01-03T12:00:00Z" + }, + { + "name": "a closed event does not close a family whose real child is open", + "epic": { "id": "ef-e", "status": "open" }, + "under": [ + { "id": "ef-e.1", "issue_type": "task", "status": "open", "closed_at": null }, + { "id": "ef-e.2", "issue_type": "event", "status": "closed", "closed_at": "2026-01-04T09:00:00Z" } + ], + "family": ["ef-e.1"], + "family_closed": false, + "last_child_closed_at": null + }, + { + "name": "an epic with nothing under it is childless", + "epic": { "id": "ef-f", "status": "open" }, + "under": [], + "family": [], + "family_closed": false, + "last_child_closed_at": null + }, + { + "name": "two real children and no event: the newer close is the last", + "epic": { "id": "ef-g", "status": "closed" }, + "under": [ + { "id": "ef-g.1", "issue_type": "task", "status": "closed", "closed_at": "2026-01-02T08:00:00Z" }, + { "id": "ef-g.2", "issue_type": "bug", "status": "closed", "closed_at": "2026-01-06T15:30:00Z" } + ], + "family": ["ef-g.1", "ef-g.2"], + "family_closed": true, + "last_child_closed_at": "2026-01-06T15:30:00Z" + } +] diff --git a/tests/sweep-epics.sh b/tests/sweep-epics.sh new file mode 100644 index 00000000..580e90d6 --- /dev/null +++ b/tests/sweep-epics.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# +# Proves scripts/sweep-epics.sh: every epic `bd epic status --eligible-only` calls closable, with +# the minutes since its last child closed, which the fleet view judges against its threshold +# (tests/lib/sweep-findings.json holds that judgement; this suite holds the script's facts). +# +# The fact that has broken is "last child": `bd children` also returns the `event` beads +# `bd set-state` files under an epic, and an event that closed after the last real child made the +# close look fresh and held the offer back. tests/lib/epic-family.json is the rule every +# implementation answers (cb-7iv1); here, `minutes_since_last_child_closed` is measured from the +# row's `last_child_closed_at`, and is null when that is. +# +# No framework: plain bash, set -euo pipefail, exit non-zero on the first failed assertion. Run +# from the submodule root: +# +# bash tests/sweep-epics.sh + +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# fail, pass, $work_dir and its cleanup trap - see tests/lib/consumer.sh. +source "$repo_root/tests/lib/consumer.sh" + +# A stub `bd` on PATH ahead of the real one, which would read this machine's own backlog. It +# answers `epic status` from one file and `children` from another: one epic per case. +stub_dir="$work_dir/bin" +mkdir -p "$stub_dir" +epics_file="$work_dir/epics.json" +children_file="$work_dir/children.json" +cat > "$stub_dir/bd" <&2 +exit 1 +STUB +chmod +x "$stub_dir/bd" +export PATH="$stub_dir:$PATH" + +consumer="$(consumer_new repo --link consumer-root sweep-epics.sh)" +sweep="$consumer/.cerebro/cerebro/scripts/sweep-epics.sh" + +# ISO-8601 UTC to a Unix timestamp, GNU or BSD date. +iso_to_epoch() { + date -u -d "$1" +%s 2>/dev/null || date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$1" +%s +} + +# --- refuses a call without --json --------------------------------------------------------------- +set +e +(cd "$consumer" && "$sweep") >/dev/null 2>&1 +status=$? +set -e +[ "$status" -eq 2 ] || fail "a call without --json: expected exit 2, got $status" +pass "refuses a call without --json" + +# --- no eligible epic is an empty array ---------------------------------------------------------- +echo '[]' > "$epics_file" +echo '[]' > "$children_file" +out="$(cd "$consumer" && "$sweep" --json)" || fail "sweep-epics.sh --json exited non-zero" +[ "$out" = "[]" ] || fail "no eligible epic printed '$out'" +pass "prints an empty array when no epic is eligible" + +# --- the shared table: the last child is a real child, never an event ---------------------------- +table="$repo_root/tests/lib/epic-family.json" +rows="$(jq -c '.[] | select(has("_") | not)' "$table")" || fail "could not read $table" +[ -n "$rows" ] || fail "$table has no rows" +bad="" +while IFS= read -r row; do + name="$(jq -r '.name' <<<"$row")" + epic_id="$(jq -r '.epic.id' <<<"$row")" + last="$(jq -r '.last_child_closed_at // empty' <<<"$row")" + jq -c '[{epic: {id: .epic.id, title: .name}}]' <<<"$row" > "$epics_file" + jq -c '.under' <<<"$row" > "$children_file" + if ! out="$(cd "$consumer" && "$sweep" --json)"; then + bad="$bad + $name: sweep-epics.sh exited non-zero" + continue + fi + got="$(jq -r --arg id "$epic_id" '.[] | select(.id == $id) | .minutes_since_last_child_closed' <<<"$out")" + if [ -z "$last" ]; then + if [ "$got" != null ]; then + bad="$bad + $name: minutes is '$got', the table has no last child close" + fi + else + # The script reads the clock a moment before this does, so a minute boundary may fall between. + want=$(( ($(date -u +%s) - $(iso_to_epoch "$last")) / 60 )) + if ! [[ "$got" =~ ^[0-9]+$ ]] || [ $(( want - got )) -lt 0 ] || [ $(( want - got )) -gt 1 ]; then + bad="$bad + $name: minutes is '$got', since $last is $want" + fi + fi +done <<<"$rows" +[ -z "$bad" ] || fail "sweep-epics.sh disagrees with tests/lib/epic-family.json:$bad" +pass "answers every row of the epic-family table" + +suite_passed diff --git a/tests/verifier-epic-candidates.sh b/tests/verifier-epic-candidates.sh index 54b193aa..2a3b4002 100644 --- a/tests/verifier-epic-candidates.sh +++ b/tests/verifier-epic-candidates.sh @@ -153,4 +153,35 @@ if grep -qxF "ep-only-events" <<<"$ids"; then bad+=" epic whose only children ar [ -z "$bad" ] || fail "event children counted as members:$bad" pass "ignores event beads when judging a family" +# --- the shared table: which beads under an epic are its family (cb-7iv1) ---------------------- +# +# tests/lib/epic-family.json is the rule every implementation answers. Its fixtures carry no +# verification labels, so every family is unverified and an epic is due exactly when it has a +# family and every member is closed: `family_closed`. +table="$repo_root/tests/lib/epic-family.json" +rows="$(jq -c '.[] | select(has("_") | not)' "$table")" || fail "could not read $table" +[ -n "$rows" ] || fail "$table has no rows" +bad="" +while IFS= read -r row; do + name="$(jq -r '.name' <<<"$row")" + epic_id="$(jq -r '.epic.id' <<<"$row")" + want="$(jq -r '.family_closed' <<<"$row")" + # `--exclude-type event` on the real `bd list` keeps events out of it, so the list is the epic. + set_stdout list "$(jq -c '[.epic + {issue_type: "epic", labels: []}]' <<<"$row")" + set_stdout "children.$epic_id" "$(jq -c '[.under[] + {labels: []}]' <<<"$row")" + if ! ids="$(run)"; then + bad="$bad + $name: verifier-epic-candidates failed" + continue + fi + got=false + if grep -qxF "$epic_id" <<<"$ids"; then got=true; fi + if [ "$got" != "$want" ]; then + bad="$bad + $name: listed is $got, family_closed is $want" + fi +done <<<"$rows" +[ -z "$bad" ] || fail "verifier-epic-candidates disagrees with tests/lib/epic-family.json:$bad" +pass "answers every row of the epic-family table" + suite_passed diff --git a/tests/work-beads.sh b/tests/work-beads.sh index cbd20d45..de5175b7 100644 --- a/tests/work-beads.sh +++ b/tests/work-beads.sh @@ -173,22 +173,43 @@ ids="$(run --status open | jq -r '.[].id' | tr '\n' ' ')" [ "$ids" = "tt-ord " ] || fail "a parented epic survived: got '$ids'" pass "keeps a childless epic and drops a parented one, asking bd children per epic" -# --- an epic's event beads are not its children (cb-cng9) --------------------------------------- +# --- an epic's event beads are not its children: the shared table (cb-cng9, cb-7iv1) ----------- # # `bd set-state` files an `event` bead under the bead it changed, and `bd children` returns it. An # epic nobody split but whose state changed once (cb-dr8, event child cb-dr8.6) counted one child -# and left the board. Only the fallback is exposed: the list the prefilter reads excludes events. -set_stub '[{"id":"tt-lone","issue_type":"epic"},{"id":"tt-ord","issue_type":"task"}]' -set_stub_for children '[{"id":"tt-lone.1","issue_type":"event","status":"closed"}]' -ids="$(run --status open | jq -r '.[].id' | tr '\n' ' ')" -[ "$ids" = "tt-lone tt-ord " ] || fail "an epic whose only children are events was classed parented: got '$ids'" -pass "keeps an epic whose only children are event beads" - -set_stub '[{"id":"tt-lone","issue_type":"epic"},{"id":"tt-ord","issue_type":"task"}]' -set_stub_for children '[{"id":"tt-lone.1","issue_type":"event"},{"id":"tt-lone.2","issue_type":"task"}]' -ids="$(run --status open | jq -r '.[].id' | tr '\n' ' ')" -[ "$ids" = "tt-ord " ] || fail "an epic with a real child beside an event survived: got '$ids'" -pass "drops an epic with a real child beside an event bead" +# and left the board. tests/lib/epic-family.json is the rule every implementation answers; here the +# epic stays on the list exactly while its `family` is empty. Each row runs twice: once with the +# epic alone in the list (the `bd children` fallback decides) and once with everything under it in +# the list too (the id-prefix prefilter decides, after the jq guard has dropped the events). +table="$repo_root/tests/lib/epic-family.json" +rows="$(jq -c '.[] | select(has("_") | not)' "$table")" || fail "could not read $table" +[ -n "$rows" ] || fail "$table has no rows" +bad="" +while IFS= read -r row; do + name="$(jq -r '.name' <<<"$row")" + epic_id="$(jq -r '.epic.id' <<<"$row")" + childless="$(jq -r '.family | length == 0' <<<"$row")" + under="$(jq -c '.under' <<<"$row")" + for listing in alone with-under; do + if [ "$listing" = alone ]; then + set_stub "$(jq -c '[.epic + {issue_type: "epic"}]' <<<"$row")" + else + set_stub "$(jq -c '[.epic + {issue_type: "epic"}] + .under' <<<"$row")" + fi + set_stub_for children "$under" + if ! listed="$(run --status open,closed | jq --arg id "$epic_id" 'any(.[]; .id == $id)')"; then + bad="$bad + $name ($listing): work-beads failed" + continue + fi + if [ "$listed" != "$childless" ]; then + bad="$bad + $name ($listing): epic on the list is $listed, a childless epic is $childless" + fi + done +done <<<"$rows" +[ -z "$bad" ] || fail "work-beads disagrees with tests/lib/epic-family.json:$bad" +pass "answers every row of the epic-family table, through bd children and through the prefilter" # --- the prefilter: a child in the list settles its parent with no bd call (cb-hzl) ------------- #