diff --git a/Makefile b/Makefile index 509d1c2d..6ef2c5ce 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Makefile for mcpbridge-wrapper -.PHONY: help install install-webui test test-webui lint format format-check typecheck doccheck doccheck-staged doccheck-branch doccheck-all doccheck-all-strict package-assets-check bump-version clean webui webui-restart webui-health check +.PHONY: help install install-webui test test-webui lint format format-check typecheck doccheck doccheck-staged doccheck-branch doccheck-all doccheck-all-strict package-assets-check bump-version badge-version badge-version-check clean webui webui-restart webui-health check help: @echo "Available targets:" @@ -19,6 +19,8 @@ help: @echo " doccheck-all-strict - Same as doccheck-all + require doc/docc updates in same commit on branch scope" @echo " package-assets-check - Build artifacts and verify required packaged assets" @echo " bump-version - Update pyproject.toml and server.json versions (VERSION=x.y.z, add DRY_RUN=1 to preview)" + @echo " badge-version - Update README version badge (latest tag or TAG=vX.Y.Z; add DRY_RUN=1 to preview)" + @echo " badge-version-check - Fail if README version badge does not match latest tag" @echo " webui - Start wrapper with Web UI dashboard (port 8080)" @echo " webui-restart - Restart Web UI dashboard on chosen port (PORT=8080 by default)" @echo " webui-health - Check Web UI health status" @@ -89,6 +91,24 @@ bump-version: python scripts/publish_helper.py $(VERSION); \ fi +badge-version: + @if [ -n "$(TAG)" ]; then \ + if [ -n "$(DRY_RUN)" ]; then \ + python scripts/update_version_badge.py --tag "$(TAG)" --dry-run; \ + else \ + python scripts/update_version_badge.py --tag "$(TAG)"; \ + fi; \ + else \ + if [ -n "$(DRY_RUN)" ]; then \ + python scripts/update_version_badge.py --dry-run; \ + else \ + python scripts/update_version_badge.py; \ + fi; \ + fi + +badge-version-check: + python scripts/update_version_badge.py --check + check: test lint format-check typecheck doccheck-all package-assets-check clean: diff --git a/README.md b/README.md index 5be9a5cb..e418e6ca 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,16 @@ + +[![Version](https://img.shields.io/badge/version-0.3.3-blue.svg)](https://github.com/SoundBlaster/XcodeMCPWrapper/releases/tag/v0.3.3) + [![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Coverage](https://img.shields.io/badge/coverage-92.19%25-brightgreen.svg)](./SPECS/ARCHIVE/P5-T14_Code_Coverage/) [![MCP Registry](https://img.shields.io/badge/MCP%20Registry-io.github.SoundBlaster%2Fxcode--mcpbridge--wrapper-blue)](https://registry.modelcontextprotocol.io) +Version badge maintenance: run `make badge-version` (or `make badge-version-check` in CI). + A Python wrapper that makes Xcode 26.3's MCP bridge compatible with Cursor and other strict MCP-spec-compliant clients. ## The Problem diff --git a/SPECS/ARCHIVE/INDEX.md b/SPECS/ARCHIVE/INDEX.md index de63574e..9d187696 100644 --- a/SPECS/ARCHIVE/INDEX.md +++ b/SPECS/ARCHIVE/INDEX.md @@ -1,11 +1,12 @@ # mcpbridge-wrapper Tasks Archive -**Last Updated:** 2026-03-01 (Workplan_0.4.0.md) +**Last Updated:** 2026-02-28 (P1-T1) ## Archived Tasks | Task ID | Folder | Archived | Verdict | |---------|--------|----------|---------| +| P1-T1 | [P1-T1_Add_the_version_badge_in_the_README/](P1-T1_Add_the_version_badge_in_the_README/) | 2026-02-28 | PASS | | P15-T1 | [P15-T1_Validate_project_readiness_for_the_next_release/](P15-T1_Validate_project_readiness_for_the_next_release/) | 2026-02-28 | PASS | | FU-P13-T19 | [FU-P13-T19_Add_integration_coverage_for_broker-hosted_Web_UI_observability/](FU-P13-T19_Add_integration_coverage_for_broker-hosted_Web_UI_observability/) | 2026-02-28 | PASS | | FU-P13-T18 | [FU-P13-T18_Document_unified_single-config_setup_for_broker_+_Web_UI_multi-agent_workflows/](FU-P13-T18_Document_unified_single-config_setup_for_broker_+_Web_UI_multi-agent_workflows/) | 2026-02-28 | PASS | @@ -168,6 +169,7 @@ | File | Description | |------|-------------| | [Workplan_0.4.0.md](_Historical/Workplan_0.4.0.md) | Archived workplan snapshot for release 0.4.0 | +| [REVIEW_p1_t1_version_badge_script_tests.md](_Historical/REVIEW_p1_t1_version_badge_script_tests.md) | Review report for P1-T1 (version badge script/tests) | | [REVIEW_p15_t1_next_release_readiness.md](_Historical/REVIEW_p15_t1_next_release_readiness.md) | Review report for P15-T1 | | [REVIEW_fu_p13_t19_broker_webui_observability.md](_Historical/REVIEW_fu_p13_t19_broker_webui_observability.md) | Review report for FU-P13-T19 | | [REVIEW_fu_p13_t18_unified_config_docs.md](_Historical/REVIEW_fu_p13_t18_unified_config_docs.md) | Review report for FU-P13-T18 | @@ -510,3 +512,5 @@ | 2026-02-20 | BUG-T17 | Archived REVIEW_bug_t17_audit_log_rows_stay_unfolded report | | 2026-02-20 | BUG-T14 | Archived Rows_in_Per-Tool_Latency_Statistics_fold_automatically_immediately_after_unfolding (PASS) | | 2026-02-20 | BUG-T14 | Archived REVIEW_bug_t14_latency_rows report | +| 2026-02-28 | P1-T1 | Archived Add_the_version_badge_in_the_README (PASS) | +| 2026-02-28 | P1-T1 | Archived REVIEW_p1_t1_version_badge_script_tests report | diff --git a/SPECS/ARCHIVE/P1-T1_Add_the_version_badge_in_the_README/P1-T1_Add_the_version_badge_in_the_README.md b/SPECS/ARCHIVE/P1-T1_Add_the_version_badge_in_the_README/P1-T1_Add_the_version_badge_in_the_README.md new file mode 100644 index 00000000..8f224649 --- /dev/null +++ b/SPECS/ARCHIVE/P1-T1_Add_the_version_badge_in_the_README/P1-T1_Add_the_version_badge_in_the_README.md @@ -0,0 +1,52 @@ +# P1-T1 — Add the version badge in the README.md + +**Task ID:** P1-T1 +**Phase:** Phase 1: Documentation +**Priority:** P2 +**Dependencies:** none +**Status:** Planned + +## Objective + +Implement maintainable automation for the README version badge so it can be updated from git tags with a small, test-covered script and ergonomic `make` commands. + +## Success Criteria + +- `README.md` contains a version badge wrapped in stable start/end markers. +- `scripts/update_version_badge.py` updates only the marker block using either `--tag` or latest tag. +- Script behavior is covered by unit tests for success and failure paths. +- `Makefile` exposes update and check commands for local/CI usage. +- Quality gates pass: `pytest`, `ruff check src/`, `mypy src/`, `pytest --cov` with coverage >= 90%. + +## Test-First Plan + +1. Add tests for tag normalization, badge block rendering, marker replacement, and check/update flows. +2. Refactor script internals to reduce function complexity and keep logic focused/testable. +3. Run targeted script tests, then full project quality gates. + +## Execution Plan + +### Phase A: Script Refactor +- **Inputs:** current `scripts/update_version_badge.py`, README marker contract +- **Outputs:** smaller helper functions, clearer error paths, idempotent update behavior +- **Verification:** unit tests for pure helpers and CLI behavior + +### Phase B: Test Coverage +- **Inputs:** script contract and expected README badge format +- **Outputs:** `tests/unit/test_update_version_badge.py` +- **Verification:** `pytest tests/unit/test_update_version_badge.py -v` + +### Phase C: Integration and Tooling +- **Inputs:** updated script and `Makefile` +- **Outputs:** validated `badge-version` / `badge-version-check` commands +- **Verification:** dry-run and check command execution; full quality-gate run + +## Notes + +- Keep implementation dependency-free and Python 3.9-compatible. +- Preserve unrelated README badges and formatting. +- Do not rewrite the full README file when marker block is unchanged. + +--- +**Archived:** 2026-02-28 +**Verdict:** PASS diff --git a/SPECS/ARCHIVE/P1-T1_Add_the_version_badge_in_the_README/P1-T1_Validation_Report.md b/SPECS/ARCHIVE/P1-T1_Add_the_version_badge_in_the_README/P1-T1_Validation_Report.md new file mode 100644 index 00000000..a034e038 --- /dev/null +++ b/SPECS/ARCHIVE/P1-T1_Add_the_version_badge_in_the_README/P1-T1_Validation_Report.md @@ -0,0 +1,47 @@ +# Validation Report — P1-T1: Add the version badge in the README.md + +**Date:** 2026-02-28 +**Verdict:** PASS + +## Scope + +Implemented README version badge automation with `make` helpers, refactored the badge update script into a plan/apply flow, and added dedicated unit tests for the new script behavior. + +## Deliverables + +- `README.md` updated with marker-wrapped version badge block +- `Makefile` updated with `badge-version` and `badge-version-check` targets +- `scripts/update_version_badge.py` created and refactored for testability +- `tests/unit/test_update_version_badge.py` created with explicit script behavior coverage + +## Acceptance Criteria Check + +- [x] `README.md` includes a visible version badge near the heading/badges area +- [x] Badge links to canonical GitHub release tag page +- [x] Script updates/checks badge from explicit or latest tag +- [x] Script has dedicated unit tests for core and CLI behavior + +## Commands Executed + +- `pytest tests/unit/test_update_version_badge.py -v` +- `python3 -m ruff check --fix scripts/update_version_badge.py tests/unit/test_update_version_badge.py` +- `python3 -m ruff check scripts/update_version_badge.py tests/unit/test_update_version_badge.py` +- `PYTHONPATH=src pytest` +- `python3 -m ruff check src/` +- `mypy src/` +- `PYTHONPATH=src pytest --cov` +- `make badge-version-check` + +## Results + +- Targeted script tests: PASS (`18 passed`) +- Script/test linting: PASS +- `pytest`: PASS (`715 passed, 5 skipped`) +- `ruff check src/`: PASS +- `mypy src/`: PASS (`18 source files checked`) +- `pytest --cov`: PASS (`Total coverage: 91.72%`, threshold 90%) +- `make badge-version-check`: PASS (`README version badge already up to date (v0.3.3)`) + +## Notes + +- Running plain `pytest` without `PYTHONPATH=src` failed in this environment due `ModuleNotFoundError: mcpbridge_wrapper` during collection. Re-running with `PYTHONPATH=src` resolved the environment issue and all tests passed. diff --git a/SPECS/ARCHIVE/_Historical/REVIEW_p1_t1_version_badge_script_tests.md b/SPECS/ARCHIVE/_Historical/REVIEW_p1_t1_version_badge_script_tests.md new file mode 100644 index 00000000..3c2056eb --- /dev/null +++ b/SPECS/ARCHIVE/_Historical/REVIEW_p1_t1_version_badge_script_tests.md @@ -0,0 +1,32 @@ +## REVIEW REPORT — P1-T1 Version Badge Script + Tests + +**Scope:** origin/main..HEAD +**Files:** 9 + +### Summary Verdict +- [x] Approve +- [ ] Approve with comments +- [ ] Request changes +- [ ] Block + +### Critical Issues + +- None. + +### Secondary Issues + +- None requiring follow-up tasks. + +### Architectural Notes + +- The script uses a clean plan/apply split (`plan_badge_update` + `apply_badge_update`) that makes file mutation deterministic and CLI flags (`--check`, `--dry-run`) straightforward to reason about and test. +- Marker-based replacement in `README.md` keeps edits scoped and avoids touching unrelated badges. + +### Tests + +- New unit suite `tests/unit/test_update_version_badge.py` covers tag normalization, marker replacement, plan generation, apply/check/dry-run modes, and main CLI flows. +- Full suite and coverage were re-run successfully during EXECUTE (`715 passed, 5 skipped`, coverage `91.72%`). + +### Next Steps + +- FOLLOW-UP skipped: no actionable review findings for this task. diff --git a/SPECS/INPROGRESS/next.md b/SPECS/INPROGRESS/next.md index d5730fc7..5bc0ac9d 100644 --- a/SPECS/INPROGRESS/next.md +++ b/SPECS/INPROGRESS/next.md @@ -2,10 +2,11 @@ ## Recently Archived +- **P1-T1** — Add the version badge in the README.md (2026-02-28, PASS) - **P15-T1** — Validate project readiness for the next release (2026-02-28, PASS) - **FU-P13-T19** — Add integration coverage for broker-hosted Web UI observability (2026-02-28, PASS) - **FU-P13-T18** — Document unified single-config setup for broker + Web UI multi-agent workflows (2026-02-28, PASS) ## Suggested Next Tasks -- **None** — All tasks completed (146/146) +- **None** — All tasks completed (1/1) diff --git a/SPECS/Workplan.md b/SPECS/Workplan.md index 9bcd1dda..8daa84dd 100644 --- a/SPECS/Workplan.md +++ b/SPECS/Workplan.md @@ -10,3 +10,20 @@ The previous workplan for release `0.4.0` was archived at: This file is intentionally reset for the next planning cycle. Add new tasks using the canonical template in [TASK_TEMPLATE.md](TASK_TEMPLATE.md). + +## Tasks + +### Phase 1: Documentation + +#### ✅ P1-T1: Add the version badge in the README.md +- **Status:** ✅ Completed (2026-02-28) +- **Description:** Add a package version badge to `README.md` so users can quickly see the currently published version. +- **Priority:** P2 +- **Dependencies:** none +- **Parallelizable:** yes +- **Outputs/Artifacts:** + - `README.md` badge section updated with a version badge + - Badge target URL configured to an authoritative version source +- **Acceptance Criteria:** + - [x] `README.md` includes a visible version badge near the project heading or badges area + - [x] The badge renders correctly and links to the canonical published version page diff --git a/Sources/XcodeMCPWrapper/Documentation.docc/XcodeMCPWrapper.md b/Sources/XcodeMCPWrapper/Documentation.docc/XcodeMCPWrapper.md index 818a4c9c..45062ff9 100644 --- a/Sources/XcodeMCPWrapper/Documentation.docc/XcodeMCPWrapper.md +++ b/Sources/XcodeMCPWrapper/Documentation.docc/XcodeMCPWrapper.md @@ -6,6 +6,12 @@ A Python wrapper that enables external AI agents to connect to Xcode via the Mod [https://github.com/SoundBlaster/XcodeMCPWrapper](https://github.com/SoundBlaster/XcodeMCPWrapper) + +[![Version](https://img.shields.io/badge/version-0.3.3-blue.svg)](https://github.com/SoundBlaster/XcodeMCPWrapper/releases/tag/v0.3.3) + + +Version badge maintenance: run `make badge-version` (or `make badge-version-check` in CI). + ## Overview Xcode 26.3+ includes an MCP bridge (`xcrun mcpbridge`) that exposes Xcode's internal capabilities to MCP clients. However, it has a protocol compatibility issue that prevents it from working with strict MCP spec followers like Cursor. diff --git a/scripts/update_version_badge.py b/scripts/update_version_badge.py new file mode 100644 index 00000000..6e4480e1 --- /dev/null +++ b/scripts/update_version_badge.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Update README version badge from git tags.""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +VERSION_BADGE_START = "" +VERSION_BADGE_END = "" +DEFAULT_REPO = "SoundBlaster/XcodeMCPWrapper" +TAG_RE = re.compile( + r"^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" + r"(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?" + r"(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$" +) + + +@dataclass(frozen=True) +class BadgeUpdatePlan: + """Represents a planned README badge update.""" + + readme_path: Path + tag: str + original_text: str + updated_text: str + + +def _latest_tag(repo_root: Path) -> str: + result = subprocess.run( + ["git", "describe", "--tags", "--abbrev=0"], + cwd=repo_root, + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + msg = result.stderr.strip() or "Unable to resolve latest git tag." + raise ValueError(msg) + tag = result.stdout.strip() + if not tag: + raise ValueError("Latest git tag is empty.") + return tag + + +def _normalize_tag(raw_tag: str) -> tuple[str, str]: + tag = raw_tag.strip() + if tag.startswith("refs/tags/"): + tag = tag.removeprefix("refs/tags/") + + if not TAG_RE.match(tag): + raise ValueError( + f"Invalid tag/version '{raw_tag}'. Expected semantic version like v0.4.0 or 0.4.0." + ) + + normalized_tag = tag if tag.startswith("v") else f"v{tag}" + version = normalized_tag.removeprefix("v") + return normalized_tag, version + + +def _escape_for_badge(value: str) -> str: + return value.replace("-", "--").replace("_", "__").replace(" ", "_") + + +def _build_badge_block(tag: str, version: str, repo_slug: str) -> str: + badge_version = _escape_for_badge(version) + badge_url = f"https://img.shields.io/badge/version-{badge_version}-blue.svg" + release_url = f"https://github.com/{repo_slug}/releases/tag/{tag}" + badge_line = f"[![Version]({badge_url})]({release_url})" + return "\n".join([VERSION_BADGE_START, badge_line, VERSION_BADGE_END]) + + +def _replace_badge_block(readme_text: str, new_block: str) -> str: + if VERSION_BADGE_START not in readme_text or VERSION_BADGE_END not in readme_text: + raise ValueError( + "README is missing version badge markers. " + "Add ... first." + ) + + pattern = re.compile( + rf"{re.escape(VERSION_BADGE_START)}.*?{re.escape(VERSION_BADGE_END)}", + re.DOTALL, + ) + return pattern.sub(new_block, readme_text, count=1) + + +def _resolve_tag(raw_tag: str | None, repo_root: Path) -> tuple[str, str]: + normalized_input = raw_tag if raw_tag is not None else _latest_tag(repo_root) + return _normalize_tag(normalized_input) + + +def plan_badge_update( + readme_path: Path, + raw_tag: str | None, + repo_slug: str, + repo_root: Path, +) -> BadgeUpdatePlan: + """Create a deterministic plan for README version badge update.""" + if not readme_path.exists(): + raise ValueError(f"README not found at {readme_path}") + + tag, version = _resolve_tag(raw_tag, repo_root) + new_block = _build_badge_block(tag, version, repo_slug) + original_text = readme_path.read_text(encoding="utf-8") + updated_text = _replace_badge_block(original_text, new_block) + + return BadgeUpdatePlan( + readme_path=readme_path, + tag=tag, + original_text=original_text, + updated_text=updated_text, + ) + + +def apply_badge_update(plan: BadgeUpdatePlan, check: bool, dry_run: bool) -> int: + """Apply a prepared badge update plan according to execution flags.""" + if plan.updated_text == plan.original_text: + print(f"README version badge already up to date ({plan.tag}).") + return 0 + + if check: + print(f"README version badge is outdated (expected {plan.tag}).", file=sys.stderr) + return 1 + + if dry_run: + print(f"[DRY RUN] Would update README version badge to {plan.tag}.") + return 0 + + plan.readme_path.write_text(plan.updated_text, encoding="utf-8") + print(f"Updated README version badge to {plan.tag}.") + return 0 + + +def parse_args(argv: list[str]) -> argparse.Namespace: + """Parse CLI arguments for badge synchronization.""" + repo_root = Path(__file__).resolve().parent.parent + parser = argparse.ArgumentParser(description="Update README version badge from tag.") + parser.add_argument( + "--tag", + help="Tag or version to use (e.g. v0.4.0 or 0.4.0). Defaults to latest git tag.", + ) + parser.add_argument( + "--repo", + default=DEFAULT_REPO, + help=f"GitHub owner/repo for release links (default: {DEFAULT_REPO}).", + ) + parser.add_argument( + "--readme", + type=Path, + default=repo_root / "README.md", + help="Path to README.md", + ) + parser.add_argument( + "--check", + action="store_true", + help="Exit with status 1 if README badge is not up to date.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Preview update without writing file.", + ) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """Run the badge update command.""" + args = parse_args(argv if argv is not None else sys.argv[1:]) + repo_root = Path(__file__).resolve().parent.parent + + try: + plan = plan_badge_update( + readme_path=args.readme, + raw_tag=args.tag, + repo_slug=args.repo, + repo_root=repo_root, + ) + except ValueError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + + return apply_badge_update(plan=plan, check=args.check, dry_run=args.dry_run) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/unit/test_update_version_badge.py b/tests/unit/test_update_version_badge.py new file mode 100644 index 00000000..ef556a63 --- /dev/null +++ b/tests/unit/test_update_version_badge.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Tests for scripts/update_version_badge.py.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from update_version_badge import ( # noqa: E402 + VERSION_BADGE_END, + VERSION_BADGE_START, + _build_badge_block, + _normalize_tag, + _replace_badge_block, + apply_badge_update, + main, + plan_badge_update, +) + + +@pytest.fixture +def sample_readme(tmp_path: Path) -> Path: + """Create a README fixture with a version badge marker block.""" + readme = tmp_path / "README.md" + readme.write_text( + ( + "# Project\n\n" + f"{VERSION_BADGE_START}\n" + "[![Version](https://img.shields.io/badge/version-0.0.1-blue.svg)]" + "(https://github.com/acme/repo/releases/tag/v0.0.1)\n" + f"{VERSION_BADGE_END}\n" + "[![Python](https://img.shields.io/badge/python-3.9+-blue.svg)]" + "(https://python.org)\n" + ), + encoding="utf-8", + ) + return readme + + +class TestNormalizeTag: + """Tag normalization and validation behavior.""" + + @pytest.mark.parametrize( + ("raw", "expected_tag", "expected_version"), + [ + ("0.4.0", "v0.4.0", "0.4.0"), + ("v1.2.3", "v1.2.3", "1.2.3"), + ("refs/tags/v2.0.1", "v2.0.1", "2.0.1"), + ("1.2.3-rc.1", "v1.2.3-rc.1", "1.2.3-rc.1"), + ], + ) + def test_normalize_valid_values( + self, + raw: str, + expected_tag: str, + expected_version: str, + ) -> None: + """Valid raw tags are normalized consistently.""" + tag, version = _normalize_tag(raw) + assert tag == expected_tag + assert version == expected_version + + @pytest.mark.parametrize("raw", ["", "latest", "v1", "1.2", "v1.2.3.4"]) + def test_rejects_invalid_values(self, raw: str) -> None: + """Invalid tag strings raise ValueError.""" + with pytest.raises(ValueError, match="Invalid tag/version"): + _normalize_tag(raw) + + +def test_build_badge_block_formats_urls() -> None: + """Badge block contains expected shields and release URLs.""" + block = _build_badge_block("v1.2.3-rc.1", "1.2.3-rc.1", "SoundBlaster/XcodeMCPWrapper") + assert VERSION_BADGE_START in block + assert VERSION_BADGE_END in block + assert "version-1.2.3--rc.1-blue.svg" in block + assert "/releases/tag/v1.2.3-rc.1" in block + + +def test_replace_badge_block_replaces_only_marker_section(sample_readme: Path) -> None: + """Only marker content is replaced and other README content is preserved.""" + original = sample_readme.read_text(encoding="utf-8") + replacement = "\n".join([VERSION_BADGE_START, "[![Version](new)](new)", VERSION_BADGE_END]) + updated = _replace_badge_block(original, replacement) + + assert "[![Version](new)](new)" in updated + assert "[![Python]" in updated + assert updated.count(VERSION_BADGE_START) == 1 + assert updated.count(VERSION_BADGE_END) == 1 + + +def test_replace_badge_block_requires_markers() -> None: + """Missing marker block fails with clear error message.""" + with pytest.raises(ValueError, match="missing version badge markers"): + _replace_badge_block("# No markers here\n", "replacement") + + +def test_plan_badge_update_uses_explicit_tag(sample_readme: Path) -> None: + """Planning with explicit tag produces updated text and normalized tag.""" + plan = plan_badge_update( + readme_path=sample_readme, + raw_tag="0.4.0", + repo_slug="SoundBlaster/XcodeMCPWrapper", + repo_root=sample_readme.parent, + ) + + assert plan.tag == "v0.4.0" + assert plan.original_text != plan.updated_text + assert "/releases/tag/v0.4.0" in plan.updated_text + + +def test_apply_badge_update_writes_file(sample_readme: Path) -> None: + """Apply mode writes README when plan is outdated.""" + plan = plan_badge_update( + readme_path=sample_readme, + raw_tag="v0.4.0", + repo_slug="SoundBlaster/XcodeMCPWrapper", + repo_root=sample_readme.parent, + ) + + code = apply_badge_update(plan=plan, check=False, dry_run=False) + assert code == 0 + assert "/releases/tag/v0.4.0" in sample_readme.read_text(encoding="utf-8") + + +def test_apply_badge_update_check_mode_fails_when_outdated(sample_readme: Path) -> None: + """Check mode reports mismatch without changing file.""" + plan = plan_badge_update( + readme_path=sample_readme, + raw_tag="v0.4.0", + repo_slug="SoundBlaster/XcodeMCPWrapper", + repo_root=sample_readme.parent, + ) + original = sample_readme.read_text(encoding="utf-8") + + code = apply_badge_update(plan=plan, check=True, dry_run=False) + assert code == 1 + assert sample_readme.read_text(encoding="utf-8") == original + + +def test_main_check_passes_when_badge_is_current( + sample_readme: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """CLI check exits zero when latest tag already matches README badge.""" + main(["--readme", str(sample_readme), "--tag", "v0.4.0"]) + monkeypatch.setattr("update_version_badge._latest_tag", lambda _: "v0.4.0") + + code = main(["--readme", str(sample_readme), "--check"]) + assert code == 0 + + +def test_main_dry_run_leaves_file_unchanged(sample_readme: Path) -> None: + """CLI dry-run shows intent and does not modify README.""" + original = sample_readme.read_text(encoding="utf-8") + code = main(["--readme", str(sample_readme), "--tag", "v0.4.0", "--dry-run"]) + assert code == 0 + assert sample_readme.read_text(encoding="utf-8") == original + + +def test_main_rejects_missing_readme(tmp_path: Path) -> None: + """CLI returns non-zero when README path does not exist.""" + code = main(["--readme", str(tmp_path / "MISSING.md"), "--tag", "v0.4.0"]) + assert code == 1