diff --git a/skills/bmad-story-automator/src/story_automator/commands/state.py b/skills/bmad-story-automator/src/story_automator/commands/state.py index 38990141..9abebcad 100644 --- a/skills/bmad-story-automator/src/story_automator/commands/state.py +++ b/skills/bmad-story-automator/src/story_automator/commands/state.py @@ -8,6 +8,7 @@ from ..core.frontmatter import extract_frontmatter, parse_simple_frontmatter from ..core.runtime_policy import PolicyError, load_policy_for_state, snapshot_effective_policy from ..core.agent_config import normalize_model as _model_or_none +from ..core.sprint import sprint_status_done_in_text from ..core.utils import count_matches, ensure_dir, file_exists, get_project_root, now_utc, now_utc_z, read_text, write_json @@ -192,11 +193,8 @@ def cmd_sprint_compare(args: list[str]) -> int: if isinstance(current_story, str) and current_story in story_range: before = story_range[: story_range.index(current_story)] sprint_text = read_text(sprint) - incomplete = [] - for story_id in before: - match = re.search(rf"(?m)^\s*{re.escape(story_id)}:\s*(\S+)", sprint_text) - if not match or match.group(1) != "done": - incomplete.append(story_id) + project_root = get_project_root() + incomplete = [sid for sid in before if not sprint_status_done_in_text(sprint_text, sid, project_root)] write_json({"ok": True, "incomplete": incomplete, "checked": before}) return 0 diff --git a/skills/bmad-story-automator/src/story_automator/core/sprint.py b/skills/bmad-story-automator/src/story_automator/core/sprint.py index 224148b8..401b0bf5 100644 --- a/skills/bmad-story-automator/src/story_automator/core/sprint.py +++ b/skills/bmad-story-automator/src/story_automator/core/sprint.py @@ -16,11 +16,7 @@ class SprintStatus: reason: str = "" -def sprint_status_get(project_root: str, story_key: str) -> SprintStatus: - status_file = sprint_status_file(project_root) - if not file_exists(status_file): - return SprintStatus(False, story_key, "unknown", False, "sprint-status.yaml not found") - content = read_text(status_file) +def _status_from_content(project_root: str, content: str, story_key: str) -> SprintStatus: norm = normalize_story_key(project_root, story_key) if norm is not None: result = _best_status_match(project_root, content, story_key, norm) @@ -33,6 +29,19 @@ def sprint_status_get(project_root: str, story_key: str) -> SprintStatus: return SprintStatus(False, story_key, "not_found", False) +def sprint_status_get(project_root: str, story_key: str) -> SprintStatus: + status_file = sprint_status_file(project_root) + if not file_exists(status_file): + return SprintStatus(False, story_key, "unknown", False, "sprint-status.yaml not found") + return _status_from_content(project_root, read_text(status_file), story_key) + + +def sprint_status_done_in_text(content: str, story_id: str, project_root: str = "") -> bool: + # Text-based variant of sprint_status_get for callers that already hold the + # sprint-status content and an explicit path (e.g. sprint-compare). + return _status_from_content(project_root, content, story_id).done + + def sprint_status_epic(project_root: str, epic: str) -> tuple[list[str], int]: status_file = sprint_status_file(project_root) if not file_exists(status_file): diff --git a/tests/test_sprint_compare.py b/tests/test_sprint_compare.py new file mode 100644 index 00000000..38e9e8b0 --- /dev/null +++ b/tests/test_sprint_compare.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +import io +import json +import tempfile +import textwrap +import unittest +from contextlib import redirect_stdout +from pathlib import Path + +from story_automator.commands.state import cmd_sprint_compare +from story_automator.core.sprint import sprint_status_done_in_text + + +SPRINT_STATUS = textwrap.dedent( + """\ + development_status: + epic-1: in-progress + 1-1-host-feasibility-probe: done + 1-2-docker-dev-test-environment: done + 1-3-database-wrapper-migrations: done + 1-4-redis-wrapper-clock: done + 2-1-users-schema-permanent-admin: in-progress + """ +) + + +class SprintStatusDoneInTextTests(unittest.TestCase): + """`sprint_status_done_in_text` must resolve dotted IDs against descriptive + slug keys -- the BMAD default `sprint-status.yaml` format.""" + + def test_dotted_id_resolves_descriptive_slug(self) -> None: + self.assertTrue(sprint_status_done_in_text(SPRINT_STATUS, "1.1")) + + def test_dashed_prefix_resolves_descriptive_slug(self) -> None: + self.assertTrue(sprint_status_done_in_text(SPRINT_STATUS, "1-1")) + + def test_full_slug_key_exact_match(self) -> None: + self.assertTrue(sprint_status_done_in_text(SPRINT_STATUS, "1-1-host-feasibility-probe")) + + def test_not_done_is_false(self) -> None: + self.assertFalse(sprint_status_done_in_text(SPRINT_STATUS, "2.1")) + + def test_missing_story_is_false(self) -> None: + self.assertFalse(sprint_status_done_in_text(SPRINT_STATUS, "9.9")) + + def test_descriptive_slug_wins_over_bare_prefix_regardless_of_order(self) -> None: + # When both a bare prefix key and a descriptive slug key exist for the + # same story, the descriptive slug must win deterministically (matching + # `_best_status_match` / `sprint_status_get`), not whichever comes first. + prefix_first = textwrap.dedent( + """\ + development_status: + 1-1: in-progress + 1-1-host-feasibility-probe: done + """ + ) + slug_first = textwrap.dedent( + """\ + development_status: + 1-1-host-feasibility-probe: done + 1-1: in-progress + """ + ) + self.assertTrue(sprint_status_done_in_text(prefix_first, "1.1")) + self.assertTrue(sprint_status_done_in_text(slug_first, "1.1")) + + +class SprintCompareCommandTests(unittest.TestCase): + """End-to-end regression for the false-positive `incomplete` bug: dotted + `storyRange` vs descriptive-slug `sprint-status.yaml` keys.""" + + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + root = Path(self.tmp.name) + self.sprint = root / "sprint-status.yaml" + self.sprint.write_text(SPRINT_STATUS) + self.state = root / "orchestration.md" + self.state.write_text( + textwrap.dedent( + """\ + --- + storyRange: ["1.1", "1.2", "1.3", "1.4", "2.1"] + currentStory: 2.1 + --- + # Orchestration + """ + ) + ) + + def tearDown(self) -> None: + self.tmp.cleanup() + + def _run(self) -> dict: + buf = io.StringIO() + with redirect_stdout(buf): + rc = cmd_sprint_compare(["--state", str(self.state), "--sprint", str(self.sprint)]) + self.assertEqual(rc, 0) + return json.loads(buf.getvalue()) + + def test_all_earlier_done_reports_nothing_incomplete(self) -> None: + result = self._run() + self.assertEqual(result["checked"], ["1.1", "1.2", "1.3", "1.4"]) + self.assertEqual(result["incomplete"], []) + + def test_flags_genuinely_incomplete_story(self) -> None: + # 2.1 is in-progress; if it were an earlier story it must be flagged. + self.state.write_text( + textwrap.dedent( + """\ + --- + storyRange: ["1.1", "2.1", "1.2"] + currentStory: 1.2 + --- + """ + ) + ) + result = self._run() + self.assertEqual(result["checked"], ["1.1", "2.1"]) + self.assertEqual(result["incomplete"], ["2.1"]) + + +if __name__ == "__main__": + unittest.main()