diff --git a/CHANGELOG.md b/CHANGELOG.md index 22c086933..95b36f79a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `apm compile --watch` now honors `targets: [claude, cursor]` (and every other multi-target / single-target configuration) on every recompile. Previously the watch path bypassed the resolver the one-shot path uses and let `CompilationConfig.from_apm_yml` fall back to the all-families default, silently regenerating `GEMINI.md` after every file edit. The watch path now resolves the effective target via the same helper the one-shot path uses and forwards it as `target=` into both the initial compile and every debounced recompile. (#1345) - Fixed hook commands using relative paths that break for Claude target (#1310) - Fixed target not propagating to intermediate CompilationConfig during compilation (#765) - Fixed direct GitHub API and ADO/GHES `git ls-remote` calls not respecting `PROXY_REGISTRY_ONLY` mode; all four validation code paths (virtual package, GitHub.com API, ADO/GHES git, and parse-failure fallback) now skip outbound network probes and return `True` when proxy-only mode is active. (#615) diff --git a/src/apm_cli/commands/compile/cli.py b/src/apm_cli/commands/compile/cli.py index 530ff3dd3..5dd10cf8f 100644 --- a/src/apm_cli/commands/compile/cli.py +++ b/src/apm_cli/commands/compile/cli.py @@ -1,10 +1,16 @@ """APM compile command CLI.""" +from __future__ import annotations + import sys -from pathlib import Path # noqa: F401 +from pathlib import Path +from typing import TYPE_CHECKING import click +if TYPE_CHECKING: + from ...core.target_detection import CompileTargetType + from ...compilation import AgentsCompiler, CompilationConfig from ...constants import AGENTS_MD_FILENAME, APM_DIR, APM_MODULES_DIR, APM_YML_FILENAME from ...core.command_logger import CommandLogger @@ -252,6 +258,66 @@ def _family_of(name: str) -> str | None: return target # single string pass-through +def _resolve_effective_target( + target: str | list[str] | None, +) -> tuple[CompileTargetType, str, str | list[str] | None]: + """Resolve the CLI --target arg to the compiler-understood effective target. + + Mirrors the resolution the one-shot compile path performs (load + apm.yml ``target:`` / ``targets:``, run :func:`_resolve_compile_target` + on both, fall back to :func:`detect_target` for the auto-detect case) + so the watch path can build ``CompilationConfig`` with the same + ``target=`` value the one-shot path uses (#1345). + + Args: + target: The raw ``--target`` CLI argument (None, str, or list). + + Returns: + Tuple ``(effective_target, detection_reason, config_target)`` where + ``effective_target`` is what to pass as ``target=`` to + :meth:`CompilationConfig.from_apm_yml`, ``detection_reason`` is the + provenance label, and ``config_target`` is the raw apm.yml value + (str | list | None) for user-facing label rendering. + """ + from ...core.target_detection import detect_target + from ...models.apm_package import APMPackage + + config_target = None + apm_yml_path = Path(APM_YML_FILENAME) + if apm_yml_path.exists(): + apm_pkg = APMPackage.from_apm_yml(apm_yml_path) + config_target = apm_pkg.target + if config_target is None: + try: + from ...core.apm_yml import parse_targets_field + from ...utils.yaml_io import load_yaml + + _raw = load_yaml(apm_yml_path) + if isinstance(_raw, dict): + _yaml_targets = parse_targets_field(_raw) + if _yaml_targets: + config_target = ( + _yaml_targets[0] if len(_yaml_targets) == 1 else _yaml_targets + ) + except Exception: + pass + + compile_target = _resolve_compile_target(target) + compile_config_target = _resolve_compile_target(config_target) + + if isinstance(compile_target, frozenset): + return compile_target, "explicit --target flag", config_target + if isinstance(compile_config_target, frozenset) and compile_target is None: + return compile_config_target, "apm.yml target", config_target + + detected_target, detection_reason = detect_target( + project_root=Path("."), + explicit_target=compile_target, + config_target=compile_config_target if isinstance(compile_config_target, str) else None, + ) + return detected_target, detection_reason, config_target + + @click.command(help="Compile APM context into distributed AGENTS.md files") @click.option( "--output", @@ -455,7 +521,20 @@ def compile( # Watch mode if watch: - _watch_mode(output, chatmode, no_links, dry_run, verbose=verbose) + # 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). + effective_target, _detection_reason, config_target = _resolve_effective_target(target) + _watch_mode( + output, + chatmode, + no_links, + dry_run, + verbose=verbose, + effective_target=effective_target, + target_label_user=target, + target_label_config=config_target, + ) return logger.start("Starting context compilation...", symbol="cogs") diff --git a/src/apm_cli/commands/compile/watcher.py b/src/apm_cli/commands/compile/watcher.py index f5b0253a9..612fd4cfb 100644 --- a/src/apm_cli/commands/compile/watcher.py +++ b/src/apm_cli/commands/compile/watcher.py @@ -1,112 +1,185 @@ """APM compile watch mode.""" +from __future__ import annotations + import time +from typing import TYPE_CHECKING, Any from ...compilation import AgentsCompiler, CompilationConfig from ...constants import AGENTS_MD_FILENAME, APM_DIR, APM_YML_FILENAME from ...core.command_logger import CommandLogger +if TYPE_CHECKING: + from ...core.target_detection import CompileTargetType + + +def _format_target_label( + effective_target: CompileTargetType | None, + target_label_user: str | list[str] | None, + target_label_config: str | list[str] | None, +) -> str | None: + """Render a one-shot-parity 'Compiling for ...' label for the watch path. + + Mirrors the family-aware label the one-shot compile path emits so the + user sees the same string in watch mode (#1345). + """ + from ...core.target_detection import ( + get_target_description, + should_compile_agents_md, + should_compile_claude_md, + should_compile_gemini_md, + ) + + if isinstance(effective_target, frozenset): + if isinstance(target_label_user, list): + source = f"--target {','.join(target_label_user)}" + elif isinstance(target_label_config, list): + source = f"apm.yml target: [{', '.join(target_label_config)}]" + else: + source = "multi-target" + parts = [] + if should_compile_agents_md(effective_target): + parts.append("AGENTS.md") + if should_compile_claude_md(effective_target): + parts.append("CLAUDE.md") + if should_compile_gemini_md(effective_target): + parts.append("GEMINI.md") + return f"Compiling for {' + '.join(parts)} ({source})" + if effective_target is None: + return None + return f"Compiling for {get_target_description(effective_target)}" + + +class APMFileHandler: + """Watchdog file-system handler that recompiles APM context on edits. + + Defined at module scope (rather than inside ``_watch_mode``) so unit + tests can instantiate it without spinning up a watchdog ``Observer`` + -- the regression for #1345 lives entirely in the ``from_apm_yml`` + call site this class owns. + """ + + def __init__( + self, + output: str, + chatmode: str | None, + no_links: bool, + dry_run: bool, + logger: CommandLogger, + effective_target: CompileTargetType | None = None, + ) -> None: + self.output = output + self.chatmode = chatmode + self.no_links = no_links + self.dry_run = dry_run + self.logger = logger + self.effective_target = effective_target + self.last_compile = 0.0 + self.debounce_delay = 1.0 # 1 second debounce + + def on_modified(self, event: Any) -> None: + if getattr(event, "is_directory", False): + return + src_path = getattr(event, "src_path", "") + if not src_path.endswith(".md") and not src_path.endswith(APM_YML_FILENAME): + return + current_time = time.time() + if current_time - self.last_compile < self.debounce_delay: + return + self.last_compile = current_time + self._recompile(src_path) -def _watch_mode(output, chatmode, no_links, dry_run, verbose=False): - """Watch for changes in .apm/ directories and auto-recompile.""" + def _recompile(self, changed_file: str) -> None: + """Recompile after a file change, honoring the resolved target.""" + try: + self.logger.progress(f"File changed: {changed_file}", symbol="eyes") + self.logger.progress("Recompiling...", symbol="gear") + + 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, + ) + + compiler = AgentsCompiler(".") + result = compiler.compile(config, logger=self.logger) + + if result.success: + if self.dry_run: + self.logger.success("Recompilation successful (dry run)", symbol="sparkles") + else: + self.logger.success(f"Recompiled to {result.output_path}", symbol="sparkles") + else: + self.logger.error("Recompilation failed") + for error in result.errors: + self.logger.error(f" {error}") + + except Exception as e: + self.logger.error(f"Error during recompilation: {e}") + + +def _watch_mode( + output: str, + chatmode: str | None, + no_links: bool, + dry_run: bool, + verbose: bool = False, + effective_target: CompileTargetType | None = None, + target_label_user: str | list[str] | None = None, + target_label_config: 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). + """ logger = CommandLogger("compile-watch", verbose=verbose, dry_run=dry_run) try: - # Try to import watchdog for file system monitoring from pathlib import Path from watchdog.events import FileSystemEventHandler from watchdog.observers import Observer - class APMFileHandler(FileSystemEventHandler): - def __init__(self, output, chatmode, no_links, dry_run, logger): - self.output = output - self.chatmode = chatmode - self.no_links = no_links - self.dry_run = dry_run - self.logger = logger - self.last_compile = 0 - self.debounce_delay = 1.0 # 1 second debounce - - def on_modified(self, event): - if event.is_directory: - return - # Only react to relevant files - if not event.src_path.endswith(".md") and not event.src_path.endswith( - APM_YML_FILENAME - ): - return - # Debounce rapid changes - current_time = time.time() - if current_time - self.last_compile < self.debounce_delay: - return - - self.last_compile = current_time - self._recompile(event.src_path) - - def _recompile(self, changed_file): - """Recompile after file change.""" - try: - self.logger.progress(f"File changed: {changed_file}", symbol="eyes") - self.logger.progress("Recompiling...", symbol="gear") - - # Create configuration from apm.yml with overrides - 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, - ) - - # Create compiler and compile - compiler = AgentsCompiler(".") - result = compiler.compile(config, logger=self.logger) - - if result.success: - if self.dry_run: - self.logger.success( - "Recompilation successful (dry run)", symbol="sparkles" - ) - else: - self.logger.success( - f"Recompiled to {result.output_path}", symbol="sparkles" - ) - else: - self.logger.error("Recompilation failed") - for error in result.errors: - self.logger.error(f" {error}") - - except Exception as e: - self.logger.error(f"Error during recompilation: {e}") - - # Set up file watching - event_handler = APMFileHandler(output, chatmode, no_links, dry_run, logger) + # Adapt the test-friendly module-level handler to watchdog's + # FileSystemEventHandler base so Observer.schedule() accepts it. + class _WatchdogAdapter(APMFileHandler, FileSystemEventHandler): + pass + + event_handler = _WatchdogAdapter( + output, + chatmode, + no_links, + dry_run, + logger, + effective_target=effective_target, + ) observer = Observer() - # Watch patterns for APM files watch_paths = [] - # Check for .apm directory if Path(APM_DIR).exists(): observer.schedule(event_handler, APM_DIR, recursive=True) watch_paths.append(f"{APM_DIR}/") - # Check for .github/instructions and agents/chatmodes if Path(".github/instructions").exists(): observer.schedule(event_handler, ".github/instructions", recursive=True) watch_paths.append(".github/instructions/") - # Watch .github/agents/ (new standard) if Path(".github/agents").exists(): observer.schedule(event_handler, ".github/agents", recursive=True) watch_paths.append(".github/agents/") - # Watch .github/chatmodes/ (legacy) if Path(".github/chatmodes").exists(): observer.schedule(event_handler, ".github/chatmodes", recursive=True) watch_paths.append(".github/chatmodes/") - # Watch apm.yml if it exists if Path(APM_YML_FILENAME).exists(): observer.schedule(event_handler, ".", recursive=False) watch_paths.append(APM_YML_FILENAME) @@ -116,12 +189,17 @@ def _recompile(self, changed_file): logger.progress("Run 'apm init' to create an APM project") return - # Start watching observer.start() logger.progress(f" Watching for changes in: {', '.join(watch_paths)}", symbol="eyes") logger.progress("Press Ctrl+C to stop watching...", symbol="info") - # Do initial compilation + # Surface the same family-aware label the one-shot path prints so + # users see at-a-glance which AGENTS / CLAUDE / GEMINI files watch + # mode will (re)write (#1345). + label = _format_target_label(effective_target, target_label_user, target_label_config) + if label: + logger.progress(label, symbol="gear") + logger.progress("Performing initial compilation...", symbol="gear") config = CompilationConfig.from_apm_yml( @@ -129,6 +207,7 @@ def _recompile(self, changed_file): chatmode=chatmode, resolve_links=not no_links if no_links else None, dry_run=dry_run, + target=effective_target, ) compiler = AgentsCompiler(".") diff --git a/tests/unit/commands/compile/__init__.py b/tests/unit/commands/compile/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/commands/compile/test_watch_target_forwarding.py b/tests/unit/commands/compile/test_watch_target_forwarding.py new file mode 100644 index 000000000..816f2193f --- /dev/null +++ b/tests/unit/commands/compile/test_watch_target_forwarding.py @@ -0,0 +1,121 @@ +"""Regression tests for #1345: watch mode must forward the resolved target. + +The non-watch ``apm compile`` path correctly omits ``GEMINI.md`` when +``apm.yml`` declares ``targets: [claude, cursor]`` (#1019/#1074). Before +this fix, the watch path bypassed target resolution and let +``CompilationConfig.from_apm_yml`` fall back to the all-families default, +silently regenerating ``GEMINI.md`` on every recompile. + +These tests pin the contract: + +1. ``APMFileHandler._recompile`` calls ``CompilationConfig.from_apm_yml`` + with ``target=``. +2. The captured target is the exact frozenset the resolver produced -- + and ``should_compile_gemini_md`` returns False for it, so the bug + cannot silently come back. +3. When no target is configured (``effective_target=None``), the watcher + forwards ``None`` and lets ``from_apm_yml`` keep its dataclass default. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from apm_cli.commands.compile.watcher import APMFileHandler +from apm_cli.core.target_detection import should_compile_gemini_md + + +@pytest.fixture +def fake_logger(): + return SimpleNamespace( + progress=MagicMock(), + success=MagicMock(), + error=MagicMock(), + warning=MagicMock(), + ) + + +def _make_handler(fake_logger, effective_target): + return APMFileHandler( + output="AGENTS.md", + chatmode=None, + no_links=False, + dry_run=False, + logger=fake_logger, + effective_target=effective_target, + ) + + +def test_recompile_forwards_frozenset_target(fake_logger): + """`targets: [claude, cursor]` -> frozenset({'claude','agents'}) is forwarded.""" + effective = frozenset({"claude", "agents"}) + handler = _make_handler(fake_logger, effective) + + with ( + 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("dummy.md") + + assert mock_from_apm_yml.call_count == 1 + captured_kwargs = mock_from_apm_yml.call_args.kwargs + assert "target" in captured_kwargs, ( + "Watcher must forward target= to CompilationConfig.from_apm_yml; " + "missing it is the #1345 regression." + ) + assert captured_kwargs["target"] == effective + + # Outcome assertion: no GEMINI.md is emitted for this target. If a + # future change reintroduces the all-families fanout, this fails. + assert should_compile_gemini_md(captured_kwargs["target"]) is False + + +def test_recompile_forwards_none_when_no_target_configured(fake_logger): + """Auto-detect / unset target case: forward None, no surprise override.""" + handler = _make_handler(fake_logger, None) + + with ( + 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("dummy.md") + + assert mock_from_apm_yml.call_args.kwargs["target"] is None + + +def test_recompile_forwards_single_string_target(fake_logger): + """Single-target case (e.g. `--target claude`) is forwarded verbatim.""" + handler = _make_handler(fake_logger, "claude") + + with ( + 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("dummy.md") + + assert mock_from_apm_yml.call_args.kwargs["target"] == "claude" + assert should_compile_gemini_md("claude") is False