From 1a3bfa07bc01027fb2f2c2e16c4173086b1ee269 Mon Sep 17 00:00:00 2001 From: "Mikhail [azalio] Petrov" Date: Tue, 15 Sep 2026 21:28:49 +0300 Subject: [PATCH] fix(update): skip the framework's own source repo in mapify _update (#462) The skill preflight runs `mapify _update --mode automatic --project .`. Inside the mapify-cli repository the running `mapify` is normally a uv-tool install, so the existing source-checkout skip never fired, and the first skill run after a release bump re-installed the shipped templates over the repo's rendered .claude/, .codex/, .agents/ and .map/scripts/ trees: 115 tracked files fenced with MAP-MANAGED headers plus ~100 .bak siblings. Detect the framework repo (src/mapify_cli/templates_src/ next to a pyproject.toml whose project.name is mapify-cli) before any config, state, lock or network access: automatic mode returns `skipped` with a message, manual mode an actionable `error`. Covered by unit tests on a synthetic fingerprint, on this checkout itself, and at the hidden CLI boundary. --- CHANGELOG.md | 8 ++++ docs/ARCHITECTURE.md | 9 ++++ docs/USAGE.md | 5 +- src/mapify_cli/auto_update.py | 39 ++++++++++++++++ tests/test_auto_update.py | 86 +++++++++++++++++++++++++++++++++++ tests/test_mapify_cli.py | 41 +++++++++++++++++ 6 files changed, 187 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0373944..f70cd208 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 instead of skipping the whole directory — so `mapify _update` delivers runtime fixes to Codex-only installs. A symlinked `.map` or `.map/scripts` is rejected with a plain `Error:` line before any write. (#461) +- **Skill preflight no longer rewrites the framework's own source checkout.** + `mapify _update` now recognises the mapify-cli repository itself + (`src/mapify_cli/templates_src/` next to a `pyproject.toml` naming + `mapify-cli`) and never treats it as an install target: automatic mode + reports `skipped`, manual mode an actionable `error`. Previously the first + skill run after a release bump re-installed the shipped templates over the + repo's rendered `.claude/`, `.codex/`, `.agents/` and `.map/scripts/` trees + (115 tracked files fenced, ~100 `.bak` siblings). (#462) ## [3.30.0] - 2026-09-15 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8827029c..10a2175f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -169,6 +169,15 @@ policy with `.map/update.lock`, serializes the package-manager lifetime with `uv tool` or the current interpreter's pip and never mutates source/editable installs. The state timestamp records an automatic attempt, not only success. +The mapify-cli source repository is never an update target, whichever way the +running `mapify` is installed: when the project contains +`src/mapify_cli/templates_src/` next to a `pyproject.toml` whose +`project.name` is `mapify-cli`, automatic mode returns `skipped` and manual mode +an actionable `error` before any state, lock, or network access. The repo's +generated trees are fence-free renders of `templates_src/` +(`make render-templates`); installing the shipped templates over them would +fence 100+ tracked files and leave a `.bak` beside each (#462). + Update-state schema v4 retains the three-phase write-ahead state machine and adds an optional `declined_major_version` exact-version policy field: diff --git a/docs/USAGE.md b/docs/USAGE.md index 513cab6e..d8990b31 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -647,7 +647,10 @@ actually invoked. This includes configuration/state errors, lock contention, network and package-manager failures, source/editable installs, and project refresh failures. A package update that succeeds before refresh fails is recorded as pending; a later preflight can retry the local provider refresh without another -network version check. +network version check. Inside the mapify-cli source repository itself the +preflight is always a no-op (`skipped`), because that repo's `.claude/`, +`.codex/`, `.agents/` and `.map/scripts/` trees are rendered from +`src/mapify_cli/templates_src/`, not installed. Configure the project with: diff --git a/src/mapify_cli/auto_update.py b/src/mapify_cli/auto_update.py index 46998b35..b7941092 100644 --- a/src/mapify_cli/auto_update.py +++ b/src/mapify_cli/auto_update.py @@ -2,6 +2,7 @@ from __future__ import annotations +import tomllib from dataclasses import dataclass, replace from datetime import UTC, datetime from enum import StrEnum @@ -45,6 +46,33 @@ LOCK_TIMEOUT_SECONDS = 0.0 REFRESH_BARRIER_TIMEOUT_SECONDS = 0.0 +FRAMEWORK_SOURCE_REPO_MESSAGE = ( + "This project is the mapify-cli source repository; its provider trees are " + "rendered from src/mapify_cli/templates_src/ by `make render-templates` " + "and are never installed by the updater." +) + + +def is_framework_source_repo(project_path: Path) -> bool: + """Return True when ``project_path`` is the mapify-cli source repository. + + The framework repo's generated trees (``.claude/``, ``.codex/``, + ``.agents/``, ``.map/scripts/``) are fence-free renders of + ``src/mapify_cli/templates_src/``. Running the provider refresh there + re-installs the shipped templates with fences and ``MAP-MANAGED`` headers + over 100+ tracked files and leaves a ``.bak`` beside each (#462). + """ + project_path = Path(project_path) + if not (project_path / "src" / "mapify_cli" / "templates_src").is_dir(): + return False + try: + with (project_path / "pyproject.toml").open("rb") as handle: + pyproject = tomllib.load(handle) + except (OSError, tomllib.TOMLDecodeError): + return False + project = pyproject.get("project") + return isinstance(project, dict) and project.get("name") == "mapify-cli" + class UpdateMode(StrEnum): """Whether an update check was initiated by a skill or explicitly by a user.""" @@ -714,6 +742,17 @@ def check_and_update( completed_result: UpdateResult | None = None try: + if is_framework_source_repo(project_path): + # Intent: the framework repo is never an install target. Its + # generated trees are the rendered source of truth (#462). + if mode is UpdateMode.AUTOMATIC: + return UpdateResult( + UpdateStatus.SKIPPED, + current_version, + message=FRAMEWORK_SOURCE_REPO_MESSAGE, + ) + return _error(current_version, FRAMEWORK_SOURCE_REPO_MESSAGE) + if ( mode is UpdateMode.AUTOMATIC and not load_map_config(project_path).updates_auto diff --git a/tests/test_auto_update.py b/tests/test_auto_update.py index bbe5f66a..a2af02b7 100644 --- a/tests/test_auto_update.py +++ b/tests/test_auto_update.py @@ -39,6 +39,7 @@ from mapify_cli.update_versions import ReleaseHighlights, StableVersion, VersionTargets NOW = datetime(2026, 8, 13, 12, 0, tzinfo=UTC) +REPO_ROOT = Path(__file__).resolve().parents[1] @pytest.fixture(autouse=True) @@ -441,6 +442,91 @@ def test_source_install_is_silent_skip_automatically_and_error_manually( assert "owner-managed" in manual.message +def _write_framework_fingerprint(project: Path, *, name: str = "mapify-cli") -> None: + (project / "src" / "mapify_cli" / "templates_src").mkdir(parents=True) + (project / "pyproject.toml").write_text( + f'[project]\nname = "{name}"\nversion = "0.0.0"\n', encoding="utf-8" + ) + + +def test_framework_source_repo_is_silent_skip_automatically_and_error_manually( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """#462: never install the shipped templates over the rendered dev trees.""" + _write_framework_fingerprint(tmp_path) + fetch = Mock(side_effect=AssertionError("network must not run")) + monkeypatch.setattr(auto_update, "fetch_version_targets", fetch) + refresh = Mock(side_effect=AssertionError("provider refresh must not run")) + monkeypatch.setattr(auto_update, "refresh_installed_providers", refresh) + + automatic = check_and_update(tmp_path, "3.29.1", UpdateMode.AUTOMATIC, now=NOW) + manual = check_and_update(tmp_path, "3.29.1", UpdateMode.MANUAL, now=NOW) + + assert automatic.status is UpdateStatus.SKIPPED + assert automatic.message == auto_update.FRAMEWORK_SOURCE_REPO_MESSAGE + assert manual.status is UpdateStatus.ERROR + assert manual.message == auto_update.FRAMEWORK_SOURCE_REPO_MESSAGE + assert "make render-templates" in manual.message + fetch.assert_not_called() + refresh.assert_not_called() + # No update state, lock, or provider file is written in either mode. + assert not (tmp_path / ".map").exists() + + +def test_framework_source_repo_skip_ignores_config_and_throttle( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _write_framework_fingerprint(tmp_path) + _write_config(tmp_path, "updates.auto: true\n") + write_update_state(tmp_path, UpdateState(last_attempt_at="2026-08-01T00:00:00Z")) + attempt_before = read_update_state(tmp_path).last_attempt_at + fetch = Mock(side_effect=AssertionError("network must not run")) + monkeypatch.setattr(auto_update, "fetch_version_targets", fetch) + + result = check_and_update(tmp_path, "3.29.1", UpdateMode.AUTOMATIC, now=NOW) + + assert result.status is UpdateStatus.SKIPPED + assert result.message == auto_update.FRAMEWORK_SOURCE_REPO_MESSAGE + assert read_update_state(tmp_path).last_attempt_at == attempt_before + fetch.assert_not_called() + + +def test_this_repository_is_the_framework_source_repo( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The real dev checkout must trip the guard, not only a synthetic fixture.""" + assert auto_update.is_framework_source_repo(REPO_ROOT) + fetch = Mock(side_effect=AssertionError("network must not run")) + monkeypatch.setattr(auto_update, "fetch_version_targets", fetch) + + result = check_and_update(REPO_ROOT, "3.29.1", UpdateMode.AUTOMATIC, now=NOW) + + assert result.status is UpdateStatus.SKIPPED + assert result.message == auto_update.FRAMEWORK_SOURCE_REPO_MESSAGE + fetch.assert_not_called() + + +@pytest.mark.parametrize( + ("pyproject", "templates_src"), + [ + pytest.param('[project]\nname = "mapify-cli"\n', False, id="no-templates-src"), + pytest.param('[project]\nname = "other-package"\n', True, id="other-name"), + pytest.param('[tool.x]\nname = "mapify-cli"\n', True, id="no-project-table"), + pytest.param("name = [unterminated\n", True, id="malformed-toml"), + pytest.param(None, True, id="no-pyproject"), + ], +) +def test_partial_framework_fingerprint_is_an_ordinary_project( + tmp_path: Path, pyproject: str | None, templates_src: bool +) -> None: + if pyproject is not None: + (tmp_path / "pyproject.toml").write_text(pyproject, encoding="utf-8") + if templates_src: + (tmp_path / "src" / "mapify_cli" / "templates_src").mkdir(parents=True) + + assert not auto_update.is_framework_source_repo(tmp_path) + + def test_lock_contention_skips_automatic_and_errors_manual( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_mapify_cli.py b/tests/test_mapify_cli.py index f59b6ac9..444179cb 100644 --- a/tests/test_mapify_cli.py +++ b/tests/test_mapify_cli.py @@ -3591,6 +3591,47 @@ def test_internal_update_symlinked_map_ancestor_obeys_mode_boundary( assert not (outside / "update.lock").exists() assert not (outside / "provider-refresh.lock").exists() + @pytest.mark.parametrize("mode", ["automatic", "manual"]) + def test_internal_update_framework_source_repo_obeys_mode_boundary( + self, tmp_path: Path, mode: str + ) -> None: + """#462: the dev repo's rendered trees are never refreshed by the updater.""" + (tmp_path / "src" / "mapify_cli" / "templates_src").mkdir(parents=True) + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "mapify-cli"\n', encoding="utf-8" + ) + (tmp_path / ".claude" / "skills").mkdir(parents=True) + + with ( + mock.patch( + "mapify_cli.auto_update.detect_install_kind", + return_value=InstallKind.PIP, + ), + mock.patch( + "mapify_cli.auto_update.fetch_version_targets", + side_effect=AssertionError("network must not run"), + ), + mock.patch( + "mapify_cli.auto_update.refresh_installed_providers", + side_effect=AssertionError("provider refresh must not run"), + ), + ): + result = runner.invoke( + app, + ["_update", "--mode", mode, "--project", str(tmp_path)], + ) + + assert result.stderr == "" + payload = json.loads(result.stdout) + assert "make render-templates" in payload["message"] + if mode == "automatic": + assert result.exit_code == 0 + assert payload["status"] == "skipped" + else: + assert result.exit_code == 1 + assert payload["status"] == "error" + assert not (tmp_path / ".map").exists() + @mock.patch("mapify_cli.auto_update.check_and_update") def test_internal_update_automatic_error_is_silent_success( self, mock_update: mock.Mock, tmp_path: Path