From 86a071fb500e7a118577de99f2db2603fe927a15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A8=B1=E5=85=83=E8=B1=AA?= <146086744+edenfunf@users.noreply.github.com> Date: Wed, 20 May 2026 00:00:22 +0800 Subject: [PATCH 1/2] fix(compile): live-reload apm.yml and warn on --clean --watch Two behaviors #1349 left on the table: 1. `apm compile --watch` re-runs target resolution against the current `apm.yml` when `apm.yml` itself is the file event source. Pre-fix the value resolved at startup was reused for every recompile, so mid-session edits to `target:` / `targets:` did nothing until the watcher was restarted. Re-resolution is gated to the file that can change the answer (basename match -- not `endswith` -- so a stray `backup_apm.yml` cannot masquerade as the project root manifest); instruction-file edits keep using the startup snapshot. 2. `apm compile --watch --clean` prints an explicit warning that `--clean` is ignored in watch mode, then continues. Pre-fix the flag was silently dropped. CLI `--target X` still outranks mid-session `apm.yml` edits, matching the one-shot path's priority order: the resolver receives the raw `cli_target` on every re-run and applies the same precedence rules. Lazy `from .cli import _resolve_effective_target` inside `_recompile` to break the cli -> watcher -> cli import cycle. --- CHANGELOG.md | 5 + src/apm_cli/commands/compile/cli.py | 12 + src/apm_cli/commands/compile/watcher.py | 36 ++- ...est_watch_live_reload_and_clean_warning.py | 285 ++++++++++++++++++ 4 files changed, 334 insertions(+), 4 deletions(-) create mode 100644 tests/unit/commands/compile/test_watch_live_reload_and_clean_warning.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eba73a6d..6f71c9ef8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `apm install` honors the SSH user portion of dependency URLs (`ssh://user@host/...` and scp shorthand `user@host:org/repo`) instead of hardcoding `git@`; unblocks EMU accounts and other non-`git` SSH identities. User values are validated against a strict allowlist before composing the clone URL. (#1385, closes #1383) +### Changed + +- `apm compile --watch` picks up mid-session edits to `apm.yml`'s `target:` / `targets:` on the next file event instead of caching the resolved target until the watcher is restarted; previously the value resolved at startup was reused on every recompile. Follow-up to #1349. +- `apm compile --watch --clean` prints an explicit `[!]` warning that `--clean` is ignored in watch mode and continues; previously the flag was silently dropped. Run `apm compile --clean` separately between watch sessions to remove orphaned outputs. + ## [0.14.0] - 2026-05-18 ### Breaking diff --git a/src/apm_cli/commands/compile/cli.py b/src/apm_cli/commands/compile/cli.py index 5dd10cf8f..c499b0de9 100644 --- a/src/apm_cli/commands/compile/cli.py +++ b/src/apm_cli/commands/compile/cli.py @@ -521,6 +521,17 @@ def compile( # Watch mode if watch: + # --clean removes orphaned outputs from a previous targets: + # configuration and would surprise users if run on every + # recompile mid-session; running it only on the initial + # compile would re-introduce a watcher-specific code path. + # Surface that --clean is ignored here so users can run + # `apm compile --clean` separately between watch sessions. + if clean: + logger.warning( + "--clean is ignored in watch mode; run 'apm compile --clean' " + "separately to remove orphaned outputs." + ) # Resolve the same effective target the one-shot path uses so # `targets: [claude, cursor]` does not silently regress to the # all-families fanout on every recompile (#1345). @@ -534,6 +545,7 @@ def compile( effective_target=effective_target, target_label_user=target, target_label_config=config_target, + cli_target=target, ) return diff --git a/src/apm_cli/commands/compile/watcher.py b/src/apm_cli/commands/compile/watcher.py index 612fd4cfb..fe4286d94 100644 --- a/src/apm_cli/commands/compile/watcher.py +++ b/src/apm_cli/commands/compile/watcher.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import time from typing import TYPE_CHECKING, Any @@ -67,6 +68,7 @@ def __init__( dry_run: bool, logger: CommandLogger, effective_target: CompileTargetType | None = None, + cli_target: str | list[str] | None = None, ) -> None: self.output = output self.chatmode = chatmode @@ -74,6 +76,11 @@ def __init__( self.dry_run = dry_run self.logger = logger self.effective_target = effective_target + # Raw --target CLI argument retained so ``_recompile`` can + # re-run :func:`_resolve_effective_target` against the + # current apm.yml on every recompile, letting mid-session + # ``targets:`` edits take effect on the next file event. + self.cli_target = cli_target self.last_compile = 0.0 self.debounce_delay = 1.0 # 1 second debounce @@ -95,12 +102,27 @@ def _recompile(self, changed_file: str) -> None: self.logger.progress(f"File changed: {changed_file}", symbol="eyes") self.logger.progress("Recompiling...", symbol="gear") + # When apm.yml itself was the trigger, re-resolve so a + # mid-session edit to ``target:`` / ``targets:`` takes + # effect on this recompile. For instruction-file edits + # the startup snapshot remains correct, so we skip the + # extra resolver round-trip. Match on basename rather + # than ``endswith`` so a stray ``backup_apm.yml`` cannot + # masquerade as the project root manifest. + effective_target = self.effective_target + if os.path.basename(changed_file) == APM_YML_FILENAME: + from .cli import _resolve_effective_target + + effective_target, _reason, _config_target = _resolve_effective_target( + self.cli_target + ) + config = CompilationConfig.from_apm_yml( output_path=self.output if self.output != AGENTS_MD_FILENAME else None, chatmode=self.chatmode, resolve_links=not self.no_links if self.no_links else None, dry_run=self.dry_run, - target=self.effective_target, + target=effective_target, ) compiler = AgentsCompiler(".") @@ -129,15 +151,20 @@ def _watch_mode( effective_target: CompileTargetType | None = None, target_label_user: str | list[str] | None = None, target_label_config: str | list[str] | None = None, + cli_target: str | list[str] | None = None, ) -> None: """Watch for changes in .apm/ directories and auto-recompile. ``effective_target`` is the compiler-understood target resolved by :func:`apm_cli.commands.compile.cli._resolve_effective_target` (the same resolver the one-shot path uses) and is forwarded as ``target=`` - into every :meth:`CompilationConfig.from_apm_yml` call so watch mode - honors ``targets: [claude, cursor]`` instead of silently fanning out - to all families on every recompile (#1345). + into the initial compile so the startup label matches the one-shot + path (#1345). + + ``cli_target`` is the raw ``--target`` argument; recompiles re-run + the resolver against the current apm.yml so mid-session edits to + ``targets:`` take effect on the next file event without restarting + the watcher. """ logger = CommandLogger("compile-watch", verbose=verbose, dry_run=dry_run) @@ -159,6 +186,7 @@ class _WatchdogAdapter(APMFileHandler, FileSystemEventHandler): dry_run, logger, effective_target=effective_target, + cli_target=cli_target, ) observer = Observer() diff --git a/tests/unit/commands/compile/test_watch_live_reload_and_clean_warning.py b/tests/unit/commands/compile/test_watch_live_reload_and_clean_warning.py new file mode 100644 index 000000000..3856ef5b9 --- /dev/null +++ b/tests/unit/commands/compile/test_watch_live_reload_and_clean_warning.py @@ -0,0 +1,285 @@ +"""Follow-ups from #1351: mid-session apm.yml reload and --clean --watch warning. + +Two behaviors that #1349 left on the table when it closed #1345: + +1. ``apm compile --watch`` re-runs target resolution against the *current* + ``apm.yml`` when ``apm.yml`` itself is the file event source -- so a + mid-session edit to ``targets:`` takes effect on the next file event + without restarting the watcher. Pre-fix the startup snapshot was + reused on every recompile, so editing ``apm.yml`` mid-watch did + nothing until the user killed and restarted the process. + +2. ``apm compile --watch --clean`` now prints an explicit warning that + ``--clean`` is ignored in watch mode (running it on every recompile + would surprise users by deleting orphans mid-session). Pre-fix the + flag was silently dropped. + +Both tests are toggle-verified: reverting the fix on ``main`` makes them +fail with assertion messages that point at this PR. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +from click.testing import CliRunner + +from apm_cli.commands.compile.cli import compile as compile_cmd +from apm_cli.commands.compile.watcher import APMFileHandler + + +@pytest.fixture +def fake_logger(): + return SimpleNamespace( + progress=MagicMock(), + success=MagicMock(), + error=MagicMock(), + warning=MagicMock(), + ) + + +# --------------------------------------------------------------------------- +# 1) Mid-session apm.yml reload +# --------------------------------------------------------------------------- + + +def test_recompile_on_apm_yml_change_reresolves_against_current_file(fake_logger): + """Editing ``apm.yml`` mid-watch must reflect on the next recompile. + + The handler is constructed with the startup snapshot ``effective_target`` + = ``"claude"`` (mimicking ``targets: [claude]`` at startup). The user + then edits ``apm.yml`` to ``targets: [claude, gemini]`` and the watchdog + delivers an ``apm.yml`` modification event. ``_recompile`` must invoke + ``_resolve_effective_target`` (re-reading the live ``apm.yml``) and + forward the *fresh* value -- not the snapshot -- to + ``CompilationConfig.from_apm_yml``. + """ + snapshot = "claude" + fresh = frozenset({"claude", "gemini"}) + + handler = APMFileHandler( + output="AGENTS.md", + chatmode=None, + no_links=False, + dry_run=False, + logger=fake_logger, + effective_target=snapshot, + cli_target=None, # apm.yml is the source of truth -- no --target flag + ) + + with ( + patch( + "apm_cli.commands.compile.cli._resolve_effective_target", + return_value=(fresh, "apm.yml target", ["claude", "gemini"]), + ) as mock_resolver, + patch( + "apm_cli.commands.compile.watcher.CompilationConfig.from_apm_yml" + ) as mock_from_apm_yml, + patch("apm_cli.commands.compile.watcher.AgentsCompiler") as mock_compiler_cls, + ): + mock_from_apm_yml.return_value = MagicMock() + mock_compiler_cls.return_value.compile.return_value = SimpleNamespace( + success=True, output_path="AGENTS.md", errors=[] + ) + + handler._recompile("apm.yml") + + assert mock_resolver.call_count == 1, ( + "When apm.yml changes, _recompile must call _resolve_effective_target " + "to pick up mid-session targets: edits." + ) + assert mock_from_apm_yml.call_args.kwargs["target"] == fresh, ( + "Watcher forwarded the startup snapshot instead of the fresh " + "resolver output -- mid-session apm.yml edits will not take effect." + ) + + +def test_recompile_on_instruction_file_change_uses_snapshot(fake_logger): + """Non-apm.yml events keep using the startup snapshot (no extra resolver work). + + Re-running the resolver on every ``.instructions.md`` edit would do + nothing useful (those files cannot affect ``target:`` resolution) and + would re-read ``apm.yml`` on every keystroke-triggered recompile. + Scope the re-resolution to the file that can change the answer. + """ + snapshot = "claude" + handler = APMFileHandler( + output="AGENTS.md", + chatmode=None, + no_links=False, + dry_run=False, + logger=fake_logger, + effective_target=snapshot, + cli_target=None, + ) + + with ( + patch("apm_cli.commands.compile.cli._resolve_effective_target") as mock_resolver, + patch( + "apm_cli.commands.compile.watcher.CompilationConfig.from_apm_yml" + ) as mock_from_apm_yml, + patch("apm_cli.commands.compile.watcher.AgentsCompiler") as mock_compiler_cls, + ): + mock_from_apm_yml.return_value = MagicMock() + mock_compiler_cls.return_value.compile.return_value = SimpleNamespace( + success=True, output_path="AGENTS.md", errors=[] + ) + + handler._recompile(".apm/instructions/style.instructions.md") + + assert mock_resolver.call_count == 0, ( + "Resolver should only re-run when apm.yml itself triggers the recompile." + ) + assert mock_from_apm_yml.call_args.kwargs["target"] == snapshot + + +def test_recompile_on_lookalike_filename_does_not_reresolve(fake_logger): + """A file named ``backup_apm.yml`` must NOT trigger re-resolution. + + Pre-fix the gate was ``changed_file.endswith(APM_YML_FILENAME)`` which + spuriously matches any path ending in the seven characters ``apm.yml`` + (``backup_apm.yml``, ``.apm/configs/legacy_apm.yml``, etc.). Such a + file would silently re-read the project root ``apm.yml`` and replace + the startup snapshot, which is wrong: those files are not the + project's resolution input. The basename match pins the gate to + the exact filename so look-alikes use the snapshot path. + """ + snapshot = "claude" + handler = APMFileHandler( + output="AGENTS.md", + chatmode=None, + no_links=False, + dry_run=False, + logger=fake_logger, + effective_target=snapshot, + cli_target=None, + ) + + with ( + patch("apm_cli.commands.compile.cli._resolve_effective_target") as mock_resolver, + patch( + "apm_cli.commands.compile.watcher.CompilationConfig.from_apm_yml" + ) as mock_from_apm_yml, + patch("apm_cli.commands.compile.watcher.AgentsCompiler") as mock_compiler_cls, + ): + mock_from_apm_yml.return_value = MagicMock() + mock_compiler_cls.return_value.compile.return_value = SimpleNamespace( + success=True, output_path="AGENTS.md", errors=[] + ) + + # All three end with "apm.yml" but none are the project root file. + for lookalike in ( + "backup_apm.yml", + ".apm/configs/legacy_apm.yml", + "vendor/some-apm.yml", + ): + handler._recompile(lookalike) + + assert mock_resolver.call_count == 0, ( + "Re-resolution must be scoped to the exact ``apm.yml`` filename; " + "an ``endswith`` gate would falsely trigger on look-alike paths." + ) + # All three recompiles forwarded the snapshot, not a resolver result. + for call in mock_from_apm_yml.call_args_list: + assert call.kwargs["target"] == snapshot + + +def test_recompile_on_apm_yml_change_with_cli_target_keeps_cli_priority(fake_logger): + """Explicit ``--target`` on the CLI wins over mid-session apm.yml edits. + + If the user launched watch mode with ``--target claude``, editing + ``apm.yml``'s ``targets:`` mid-session should *not* override the CLI + flag -- that matches the one-shot path's priority order. The + re-resolver receives the original CLI target and returns ``"claude"`` + again because ``--target`` outranks ``apm.yml`` in + ``_resolve_effective_target``. + """ + cli_target = "claude" + handler = APMFileHandler( + output="AGENTS.md", + chatmode=None, + no_links=False, + dry_run=False, + logger=fake_logger, + effective_target="claude", + cli_target=cli_target, + ) + + with ( + patch( + "apm_cli.commands.compile.cli._resolve_effective_target", + return_value=("claude", "explicit --target flag", ["claude", "gemini"]), + ) as mock_resolver, + patch( + "apm_cli.commands.compile.watcher.CompilationConfig.from_apm_yml" + ) as mock_from_apm_yml, + patch("apm_cli.commands.compile.watcher.AgentsCompiler") as mock_compiler_cls, + ): + mock_from_apm_yml.return_value = MagicMock() + mock_compiler_cls.return_value.compile.return_value = SimpleNamespace( + success=True, output_path="AGENTS.md", errors=[] + ) + + handler._recompile("apm.yml") + + # Resolver is called with the original CLI target so it can enforce + # the explicit-flag-beats-config-file priority order. + assert mock_resolver.call_args.args == (cli_target,) + assert mock_from_apm_yml.call_args.kwargs["target"] == "claude" + + +# --------------------------------------------------------------------------- +# 2) --clean --watch warning +# --------------------------------------------------------------------------- + + +def _write_minimal_apm_project(tmp_path): + (tmp_path / "apm.yml").write_text( + "name: Repro\nversion: 1.0.0\ntargets:\n- claude\n", encoding="utf-8" + ) + instructions = tmp_path / ".apm" / "instructions" + instructions.mkdir(parents=True) + (instructions / "style.instructions.md").write_text( + '---\ndescription: style\napplyTo: "**/*.py"\n---\n\nsnake_case.\n', + encoding="utf-8", + ) + + +def test_clean_watch_emits_warning_and_does_not_run_clean(tmp_path, monkeypatch): + """``apm compile --watch --clean`` must warn and proceed without --clean. + + Pre-fix ``--clean`` was silently swallowed on the watch path: there + was no kwarg to forward it into ``_watch_mode``, so the user got no + cleanup AND no signal that the flag was ignored. This pins both + halves: the warning fires AND the watcher still launches. + """ + monkeypatch.chdir(tmp_path) + _write_minimal_apm_project(tmp_path) + + with patch("apm_cli.commands.compile.cli._watch_mode") as mock_watch: + runner = CliRunner() + result = runner.invoke(compile_cmd, ["--watch", "--clean"]) + + assert result.exit_code == 0, f"compile exited with {result.exit_code}: {result.output}" + assert "--clean is ignored in watch mode" in result.output, ( + "Users running `apm compile --watch --clean` must see an explicit " + "warning -- silently dropping the flag is what this PR fixes." + ) + # Critical: the watcher *still* launched. Warning is informational, + # not a fatal error. + assert mock_watch.call_count == 1 + + +def test_watch_without_clean_does_not_emit_clean_warning(tmp_path, monkeypatch): + """Positive control: warning must not appear when ``--clean`` is absent.""" + monkeypatch.chdir(tmp_path) + _write_minimal_apm_project(tmp_path) + + with patch("apm_cli.commands.compile.cli._watch_mode"): + runner = CliRunner() + result = runner.invoke(compile_cmd, ["--watch"]) + + assert result.exit_code == 0, f"compile exited with {result.exit_code}: {result.output}" + assert "--clean is ignored in watch mode" not in result.output From 15027fff8676c404f36c0419293b0e84230985ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A8=B1=E5=85=83=E8=B1=AA?= <146086744+edenfunf@users.noreply.github.com> Date: Wed, 20 May 2026 00:13:42 +0800 Subject: [PATCH 2/2] review: persist re-resolved target, add PR number to CHANGELOG, document watch contract Three review comments from PR #1403: 1. After an apm.yml-driven re-resolve, persist the fresh value to `self.effective_target` so subsequent non-apm.yml events do not silently revert to the startup snapshot. Without this, the sequence `apm.yml edit -> instructions edit` emits the new family set on the first recompile and the wrong family set on the second -- AGENTS.md / GEMINI.md written by the apm.yml event become stale until the next apm.yml edit. New test `test_apm_yml_change_persists_fresh_target_for_subsequent_events` pins the sequence end-to-end. 2. Append (#1403) to both CHANGELOG entries to match the project's one-line-per-PR Keep-a-Changelog convention. 3. Document the two new watch-mode behaviors in the CLI reference: apm.yml `target:` / `targets:` mid-session live-reload (with the CLI `--target` priority note) and the `--clean` warning. --- CHANGELOG.md | 4 +- .../src/content/docs/reference/cli/compile.md | 6 ++ src/apm_cli/commands/compile/watcher.py | 11 ++-- ...est_watch_live_reload_and_clean_warning.py | 63 +++++++++++++++++++ 4 files changed, 77 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f71c9ef8..563ec5a03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- `apm compile --watch` picks up mid-session edits to `apm.yml`'s `target:` / `targets:` on the next file event instead of caching the resolved target until the watcher is restarted; previously the value resolved at startup was reused on every recompile. Follow-up to #1349. -- `apm compile --watch --clean` prints an explicit `[!]` warning that `--clean` is ignored in watch mode and continues; previously the flag was silently dropped. Run `apm compile --clean` separately between watch sessions to remove orphaned outputs. +- `apm compile --watch` picks up mid-session edits to `apm.yml`'s `target:` / `targets:` on the next file event instead of caching the resolved target until the watcher is restarted; previously the value resolved at startup was reused on every recompile. Follow-up to #1349. (#1403) +- `apm compile --watch --clean` prints an explicit `[!]` warning that `--clean` is ignored in watch mode and continues; previously the flag was silently dropped. Run `apm compile --clean` separately between watch sessions to remove orphaned outputs. (#1403) ## [0.14.0] - 2026-05-18 diff --git a/docs/src/content/docs/reference/cli/compile.md b/docs/src/content/docs/reference/cli/compile.md index a037f1145..c45d10a39 100644 --- a/docs/src/content/docs/reference/cli/compile.md +++ b/docs/src/content/docs/reference/cli/compile.md @@ -159,6 +159,12 @@ re-runs compilation automatically. - Watched directories (when present): `.apm/`, `.github/instructions/`, `.github/agents/`, `.github/chatmodes/`. - Triggers on changes to `.md` files and `apm.yml`. +- Editing `apm.yml`'s `target:` / `targets:` mid-session takes effect on + the next file event; no need to restart the watcher. The CLI `--target` + flag, when passed to `apm compile --watch`, still outranks `apm.yml`. +- `--clean` is ignored in watch mode (a `[!]` warning is printed at + startup). Run `apm compile --clean` separately between watch sessions + to remove orphaned outputs. - 1-second debounce to coalesce rapid edits. - Press Ctrl+C to stop. - Combine with `--dry-run` to validate placement on every save without diff --git a/src/apm_cli/commands/compile/watcher.py b/src/apm_cli/commands/compile/watcher.py index fe4286d94..cd8298959 100644 --- a/src/apm_cli/commands/compile/watcher.py +++ b/src/apm_cli/commands/compile/watcher.py @@ -104,11 +104,11 @@ def _recompile(self, changed_file: str) -> None: # When apm.yml itself was the trigger, re-resolve so a # mid-session edit to ``target:`` / ``targets:`` takes - # effect on this recompile. For instruction-file edits - # the startup snapshot remains correct, so we skip the - # extra resolver round-trip. Match on basename rather - # than ``endswith`` so a stray ``backup_apm.yml`` cannot - # masquerade as the project root manifest. + # effect on this recompile, then persist the fresh value + # so subsequent instruction-file edits do not silently + # revert to the startup snapshot. Match on basename + # rather than ``endswith`` so a stray ``backup_apm.yml`` + # cannot masquerade as the project root manifest. effective_target = self.effective_target if os.path.basename(changed_file) == APM_YML_FILENAME: from .cli import _resolve_effective_target @@ -116,6 +116,7 @@ def _recompile(self, changed_file: str) -> None: effective_target, _reason, _config_target = _resolve_effective_target( self.cli_target ) + self.effective_target = effective_target config = CompilationConfig.from_apm_yml( output_path=self.output if self.output != AGENTS_MD_FILENAME else None, diff --git a/tests/unit/commands/compile/test_watch_live_reload_and_clean_warning.py b/tests/unit/commands/compile/test_watch_live_reload_and_clean_warning.py index 3856ef5b9..985f84f84 100644 --- a/tests/unit/commands/compile/test_watch_live_reload_and_clean_warning.py +++ b/tests/unit/commands/compile/test_watch_live_reload_and_clean_warning.py @@ -135,6 +135,69 @@ def test_recompile_on_instruction_file_change_uses_snapshot(fake_logger): assert mock_from_apm_yml.call_args.kwargs["target"] == snapshot +def test_apm_yml_change_persists_fresh_target_for_subsequent_events(fake_logger): + """After an apm.yml-driven re-resolve, the fresh target must persist. + + Without persistence, the sequence ``apm.yml edit -> instructions edit`` + looks like this: the apm.yml event correctly emits the new family set, + but the *next* instructions event uses the original startup snapshot + again and silently reverts to the wrong family set. Outputs written + by the apm.yml-event recompile become stale and the user sees an + inconsistent state with no error. + + This test toggles the failure mode directly: the apm.yml event flips + the snapshot from ``"claude"`` to ``frozenset({"claude", "gemini"})``; + the immediately-following instructions event must reuse the new + value, not the original. + """ + initial_snapshot = "claude" + fresh = frozenset({"claude", "gemini"}) + + handler = APMFileHandler( + output="AGENTS.md", + chatmode=None, + no_links=False, + dry_run=False, + logger=fake_logger, + effective_target=initial_snapshot, + cli_target=None, + ) + + with ( + patch( + "apm_cli.commands.compile.cli._resolve_effective_target", + return_value=(fresh, "apm.yml target", ["claude", "gemini"]), + ), + patch( + "apm_cli.commands.compile.watcher.CompilationConfig.from_apm_yml" + ) as mock_from_apm_yml, + patch("apm_cli.commands.compile.watcher.AgentsCompiler") as mock_compiler_cls, + ): + mock_from_apm_yml.return_value = MagicMock() + mock_compiler_cls.return_value.compile.return_value = SimpleNamespace( + success=True, output_path="AGENTS.md", errors=[] + ) + + # 1. apm.yml edit triggers re-resolve with the fresh value. + handler._recompile("apm.yml") + # 2. Subsequent instructions edit must reuse the fresh value, + # NOT revert to the initial snapshot. + handler._recompile(".apm/instructions/style.instructions.md") + + assert mock_from_apm_yml.call_count == 2 + first_target = mock_from_apm_yml.call_args_list[0].kwargs["target"] + second_target = mock_from_apm_yml.call_args_list[1].kwargs["target"] + assert first_target == fresh, "First recompile (apm.yml event) must use the fresh value." + assert second_target == fresh, ( + "Second recompile (instructions event) must reuse the fresh value persisted " + "from the prior apm.yml event; reverting to the startup snapshot leaves " + "AGENTS.md / GEMINI.md stale until the next apm.yml edit." + ) + assert handler.effective_target == fresh, ( + "self.effective_target must be updated in-place after re-resolution." + ) + + def test_recompile_on_lookalike_filename_does_not_reresolve(fake_logger): """A file named ``backup_apm.yml`` must NOT trigger re-resolution.