//! Front-matter checks for story files: review holds, blocked state, and merge failures. /// Read the optional `agent:` pin for a story. /// /// After story 871 the agent assignment lives in the CRDT typed register /// (`PipelineItemView.agent`), not the YAML front matter. We check the CRDT /// first; falling back to legacy YAML parsing keeps behaviour intact for any /// stories whose CRDT entry doesn't yet have the field set. pub(super) fn read_story_front_matter_agent(story_id: &str) -> Option { // Story 929: agent name comes from the CRDT register. The previous // YAML fallback is gone — post-891 every story has its CRDT entry, // and any story without one is treated as having no pinned agent. crate::crdt_state::read_item(story_id).and_then(|w| w.agent().map(|a| a.to_string())) } /// Return `true` if the story is in `Stage::ReviewHold`. /// /// Story 945: `Stage::ReviewHold { resume_to, reason }` is the single source /// of truth — the legacy `review_hold: bool` CRDT register has been deleted. /// The auto-assigner uses this to keep human-QA items / spikes parked after /// gates pass until a reviewer explicitly clears the hold (e.g. via /// `tool_approve_qa`). pub(super) fn has_review_hold(story_id: &str) -> bool { crate::crdt_state::read_item(story_id) .map(|w| matches!(w.stage(), crate::pipeline_state::Stage::ReviewHold { .. })) .unwrap_or(false) } /// Return `true` if the story is blocked via the typed `Stage::Blocked` or /// `Stage::MergeFailure` variant (or the legacy `Archived(Blocked)` state). /// /// The typed pipeline stage register is the only source consulted — the legacy /// `blocked: true` YAML front-matter field is no longer checked. pub(super) fn is_story_blocked(story_id: &str) -> bool { crate::pipeline_state::read_typed(story_id) .ok() .flatten() .map(|item| { matches!( item.stage, crate::pipeline_state::Stage::Blocked { .. } | crate::pipeline_state::Stage::MergeFailure { .. } | crate::pipeline_state::Stage::MergeFailureFinal { .. } | crate::pipeline_state::Stage::Archived { reason: crate::pipeline_state::ArchiveReason::Blocked { .. }, .. } ) }) .unwrap_or(false) } /// Return `true` if the story has any `depends_on` entries that are not yet in /// `5_done` or `6_archived`. Reads dependency state from the CRDT (story 929). pub(super) fn has_unmet_dependencies(story_id: &str) -> bool { !crate::crdt_state::check_unmet_deps_crdt(story_id).is_empty() } /// Return the list of dependency story numbers that are in `6_archived` (satisfied /// via archive rather than via a clean `5_done` completion). Reads from the CRDT /// (story 929). pub(super) fn check_archived_dependencies(story_id: &str) -> Vec { crate::crdt_state::check_archived_deps_crdt(story_id) } /// Return `true` if the story is in `Stage::Frozen`. /// /// Story 945: `Stage::Frozen { resume_to }` is the single source of truth — /// the legacy `frozen: bool` CRDT register has been deleted. Frozen stories /// are skipped by the auto-assigner until `Unfreeze` returns them to /// `resume_to`. pub(super) fn is_story_frozen(story_id: &str) -> bool { crate::crdt_state::read_item(story_id) .map(|view| matches!(view.stage(), crate::pipeline_state::Stage::Frozen { .. })) .unwrap_or(false) } // ── Tests ────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; // ── has_review_hold ─────────────────────────────────────────────────────── #[test] fn has_review_hold_returns_true_when_flag_set() { crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); // Story 945: review_hold is now a typed Stage variant, seeded via // the wire-form stage register directly. crate::crdt_state::write_item_str( "890_spike_held", "review_hold", Some("Held Spike"), None, None, None, ); assert!(has_review_hold("890_spike_held")); } #[test] fn has_review_hold_returns_false_when_flag_unset() { crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); crate::crdt_state::write_item_str( "890_spike_active_qa", "3_qa", Some("Active QA Spike"), None, None, None, ); assert!(!has_review_hold("890_spike_active_qa")); } #[test] fn has_review_hold_returns_false_when_story_unknown() { assert!(!has_review_hold("99_spike_missing")); } // ── is_story_blocked — regression: typed stage is sole authority ────────── #[test] fn is_story_blocked_set_via_typed_stage_returns_true() { crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); crate::db::write_item_with_content( "890_story_blocked_set", "2_blocked", "---\nname: Blocked Story\n---\n", crate::db::ItemMeta::named("Blocked Story"), ); assert!(is_story_blocked("890_story_blocked_set")); } #[test] fn is_story_blocked_cleared_via_typed_stage_returns_false() { crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); // First set to blocked. crate::db::write_item_with_content( "890_story_blocked_clear", "2_blocked", "---\nname: Clearable Story\n---\n", crate::db::ItemMeta::named("Clearable Story"), ); // Then clear by transitioning to an active stage. crate::db::write_item_with_content( "890_story_blocked_clear", "2_current", "---\nname: Clearable Story\n---\n", crate::db::ItemMeta::named("Clearable Story"), ); assert!(!is_story_blocked("890_story_blocked_clear")); } #[test] fn is_story_blocked_stale_yaml_is_ignored() { crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); // YAML front matter says `blocked: true`, but the typed CRDT stage is backlog. // After removing the YAML fallback, the function must return false. crate::db::write_item_with_content( "890_story_stale_yaml", "1_backlog", "---\nname: Stale\nblocked: true\n---\n", crate::db::ItemMeta::named("Stale"), ); assert!( !is_story_blocked("890_story_stale_yaml"), "stale YAML `blocked: true` must not be reported as blocked when typed stage is Backlog" ); } // ── has_unmet_dependencies ──────────────────────────────────────────────── #[test] fn has_unmet_dependencies_returns_true_when_dep_not_done() { crate::crdt_state::init_for_test(); crate::crdt_state::write_item_str( "10_story_blocked", "2_current", Some("Blocked"), None, Some("[999]"), None, ); assert!(has_unmet_dependencies("10_story_blocked")); } #[test] fn has_unmet_dependencies_returns_false_when_dep_done() { crate::crdt_state::init_for_test(); crate::crdt_state::write_item_str("999_story_dep", "5_done", Some("Dep"), None, None, None); crate::crdt_state::write_item_str( "10_story_ok", "2_current", Some("Ok"), None, Some("[999]"), None, ); assert!(!has_unmet_dependencies("10_story_ok")); } #[test] fn has_unmet_dependencies_returns_false_when_no_deps() { crate::crdt_state::init_for_test(); crate::crdt_state::write_item_str( "5_story_free", "2_current", Some("Free"), None, None, None, ); assert!(!has_unmet_dependencies("5_story_free")); } // ── Bug 503: archived-dep visibility ───────────────────────────────────── /// check_archived_dependencies returns dep IDs that are in 6_archived. #[test] fn check_archived_dependencies_returns_archived_ids() { crate::crdt_state::init_for_test(); crate::crdt_state::write_item_str( "500_spike_crdt", "6_archived", Some("CRDT Spike"), None, None, None, ); crate::crdt_state::write_item_str( "503_story_dependent", "1_backlog", Some("Dependent"), None, Some("[500]"), None, ); let archived_deps = check_archived_dependencies("503_story_dependent"); assert_eq!(archived_deps, vec![500]); } /// check_archived_dependencies returns empty when dep is in 5_done (not archived). #[test] fn check_archived_dependencies_empty_when_dep_in_done() { crate::crdt_state::init_for_test(); crate::crdt_state::write_item_str( "490_story_done", "5_done", Some("Done"), None, None, None, ); crate::crdt_state::write_item_str( "503_story_waiting", "1_backlog", Some("Waiting"), None, Some("[490]"), None, ); let archived_deps = check_archived_dependencies("503_story_waiting"); assert!(archived_deps.is_empty()); } }