From cd599c4ece69cc38dccace34c572246a7168d5c5 Mon Sep 17 00:00:00 2001 From: Travis Illig Date: Tue, 28 Apr 2026 08:50:05 -0700 Subject: [PATCH 1/4] Fix #1019: Improve compile target specification --- src/apm_cli/commands/compile/cli.py | 91 ++++++++++------- src/apm_cli/compilation/agents_compiler.py | 40 ++++---- src/apm_cli/core/target_detection.py | 29 ++++-- .../compilation/test_compile_target_flag.py | 98 ++++++++++++++++--- 4 files changed, 186 insertions(+), 72 deletions(-) diff --git a/src/apm_cli/commands/compile/cli.py b/src/apm_cli/commands/compile/cli.py index d857eda13..55bd3d7a3 100644 --- a/src/apm_cli/commands/compile/cli.py +++ b/src/apm_cli/commands/compile/cli.py @@ -164,31 +164,42 @@ def _get_validation_suggestion(error_msg): def _resolve_compile_target(target): - """Map CLI target input to compiler-understood target string. + """Map CLI target input to a compiler-understood target. - The compiler understands ``"vscode"``, ``"claude"``, ``"gemini"``, - and ``"all"``. Multi-target lists are mapped to the narrowest - equivalent; any combination of two or more distinct compiler - families collapses to ``"all"``. + The compiler understands single-string targets (``"vscode"``, + ``"claude"``, ``"gemini"``, ``"all"``) and ``frozenset`` targets + containing compiler-family names (``"agents"``, ``"claude"``, + ``"gemini"``). + + Multi-target lists are mapped to the narrowest representation: + a single string when only one compiler family is needed, or a + ``frozenset`` of families when multiple are needed. This avoids + collapsing to ``"all"`` (which would incorrectly generate files + for every family). Args: target: A single target string, a list of target strings, or ``None``. Returns: - A single string (or ``None``) suitable for :func:`detect_target`. + A single string, a ``frozenset`` of compiler families, or ``None``. """ if target is None: return None # will trigger detect_target() auto-detection if isinstance(target, list): target_set = set(target) - has_agents_family = bool( - target_set & {"copilot", "vscode", "agents", "cursor", "opencode", "codex"} - ) + agents_family = {"copilot", "vscode", "agents", "cursor", "opencode", "codex"} + has_agents_family = bool(target_set & agents_family) has_claude = "claude" in target_set has_gemini = "gemini" in target_set - distinct = sum([has_agents_family, has_claude, has_gemini]) - if distinct >= 2: - return "all" + families = set() + if has_agents_family: + families.add("agents") + if has_claude: + families.add("claude") + if has_gemini: + families.add("gemini") + if len(families) >= 2: + return frozenset(families) elif has_claude: return "claude" elif has_gemini: @@ -393,18 +404,27 @@ def compile( apm_pkg = APMPackage.from_apm_yml(apm_yml_path) config_target = apm_pkg.target - # Resolve list targets to compiler-understood string + # Resolve list targets to compiler-understood value compile_target = _resolve_compile_target(target) # Also handle config_target being a list (from apm.yml target: [claude, copilot]) compile_config_target = _resolve_compile_target(config_target) - detected_target, detection_reason = detect_target( - project_root=Path("."), - explicit_target=compile_target, - config_target=compile_config_target, - ) - # Map 'minimal' to 'vscode' for the compiler (AGENTS.md only, no folder integration) - effective_target = detected_target if detected_target != "minimal" else "vscode" + # A frozenset means multiple compiler families were explicitly + # requested -- bypass detect_target() since it only handles strings. + if isinstance(compile_target, frozenset): + effective_target = compile_target + detection_reason = "explicit --target flag" + elif isinstance(compile_config_target, frozenset) and compile_target is None: + effective_target = compile_config_target + detection_reason = "apm.yml target" + else: + detected_target, detection_reason = detect_target( + project_root=Path("."), + explicit_target=compile_target, + config_target=compile_config_target, + ) + # Map 'minimal' to 'vscode' for the compiler (AGENTS.md only, no folder integration) + effective_target = detected_target if detected_target != "minimal" else "vscode" # Build config with distributed compilation flags (Task 7) config = CompilationConfig.from_apm_yml( @@ -429,26 +449,29 @@ def compile( if isinstance(target, list): # Multi-target list: show what the compiler will produce _target_label = ",".join(target) - if effective_target == "all": - logger.progress( - f"Compiling for AGENTS.md + CLAUDE.md (--target {_target_label})" - ) - elif effective_target == "claude": - logger.progress( - f"Compiling for CLAUDE.md (--target {_target_label})" - ) - else: - logger.progress( - f"Compiling for AGENTS.md (--target {_target_label})" - ) - elif detected_target == "minimal": + from ...core.target_detection import ( + should_compile_agents_md, + should_compile_claude_md, + should_compile_gemini_md, + ) + _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") + logger.progress( + f"Compiling for {' + '.join(_parts)} (--target {_target_label})" + ) + elif isinstance(effective_target, str) and effective_target == "vscode" and "no target" in detection_reason: logger.progress(f"Compiling for AGENTS.md only ({detection_reason})") logger.progress( " Create .github/, .claude/, .codex/, .opencode/ or .cursor/ folder for full integration", symbol="light_bulb", ) else: - description = get_target_description(detected_target) + description = get_target_description(effective_target) logger.progress( f"Compiling for {description} - {detection_reason}" ) diff --git a/src/apm_cli/compilation/agents_compiler.py b/src/apm_cli/compilation/agents_compiler.py index 8ed217486..473d2e862 100644 --- a/src/apm_cli/compilation/agents_compiler.py +++ b/src/apm_cli/compilation/agents_compiler.py @@ -21,7 +21,7 @@ ) from .link_resolver import resolve_markdown_links, validate_link_targets from ..utils.paths import portable_relpath -from ..core.target_detection import should_compile_agents_md, should_compile_claude_md, should_compile_gemini_md +from ..core.target_detection import should_compile_agents_md, should_compile_claude_md, should_compile_gemini_md, CompileTargetType _logger = logging.getLogger(__name__) @@ -47,7 +47,8 @@ class CompilationConfig: # "vscode" or "agents" -> AGENTS.md + .github/ # "claude" -> CLAUDE.md + .claude/ # "all" -> both targets - target: str = "all" + # frozenset({"agents","claude"}) -> AGENTS.md + CLAUDE.md (multi-target) + target: CompileTargetType = "all" # Distributed compilation settings (Task 7) strategy: str = "distributed" # "distributed" or "single-file" @@ -214,24 +215,27 @@ def compile(self, config: CompilationConfig, primitives: Optional[PrimitiveColle # Use target_detection helpers as the single source of truth so # new targets (codex, opencode, cursor, minimal, ...) route # correctly without touching this method again. - routing_target = ( - "vscode" if config.target in _VSCODE_TARGET_ALIASES else config.target - ) - - if routing_target not in _KNOWN_TARGETS and config.target not in _KNOWN_TARGETS: - self.errors.append( - f"Unknown compilation target: {config.target!r}. " - f"Expected one of: {', '.join(sorted(set(_KNOWN_TARGETS)))}" - ) - return CompilationResult( - success=False, - output_path="", - content="", - warnings=self.warnings.copy(), - errors=self.errors.copy(), - stats={}, + if isinstance(config.target, frozenset): + routing_target = config.target + else: + routing_target = ( + "vscode" if config.target in _VSCODE_TARGET_ALIASES else config.target ) + if routing_target not in _KNOWN_TARGETS and config.target not in _KNOWN_TARGETS: + self.errors.append( + f"Unknown compilation target: {config.target!r}. " + f"Expected one of: {', '.join(sorted(set(_KNOWN_TARGETS)))}" + ) + return CompilationResult( + success=False, + output_path="", + content="", + warnings=self.warnings.copy(), + errors=self.errors.copy(), + stats={}, + ) + results: List[CompilationResult] = [] if should_compile_agents_md(routing_target): diff --git a/src/apm_cli/core/target_detection.py b/src/apm_cli/core/target_detection.py index ac963afb3..ae2a0502f 100644 --- a/src/apm_cli/core/target_detection.py +++ b/src/apm_cli/core/target_detection.py @@ -29,6 +29,10 @@ # Valid target values (internal canonical form) TargetType = Literal["vscode", "claude", "cursor", "opencode", "codex", "gemini", "all", "minimal"] +# Compile target: either a single TargetType string or a frozenset of compiler +# families ({"agents", "claude", "gemini"}) for multi-target lists. +CompileTargetType = Union[str, frozenset] + # User-facing target values (includes aliases accepted by CLI) UserTargetType = Literal["copilot", "vscode", "agents", "claude", "cursor", "opencode", "codex", "gemini", "all", "minimal"] @@ -123,42 +127,51 @@ def detect_target( return "minimal", "no target folder found" -def should_compile_agents_md(target: TargetType) -> bool: +def should_compile_agents_md(target: CompileTargetType) -> bool: """Check if AGENTS.md should be compiled. AGENTS.md is generated for vscode, codex, gemini, all, and minimal targets. Gemini needs it because GEMINI.md imports AGENTS.md. - + Args: - target: The detected or configured target - + target: The detected or configured target. May be a string or a + frozenset of compiler families for multi-target lists. + Returns: bool: True if AGENTS.md should be generated """ + if isinstance(target, frozenset): + return "agents" in target or "gemini" in target return target in ("vscode", "opencode", "codex", "gemini", "all", "minimal") -def should_compile_claude_md(target: TargetType) -> bool: +def should_compile_claude_md(target: CompileTargetType) -> bool: """Check if CLAUDE.md should be compiled. Args: - target: The detected or configured target + target: The detected or configured target. May be a string or a + frozenset of compiler families for multi-target lists. Returns: bool: True if CLAUDE.md should be generated """ + if isinstance(target, frozenset): + return "claude" in target return target in ("claude", "all") -def should_compile_gemini_md(target: TargetType) -> bool: +def should_compile_gemini_md(target: CompileTargetType) -> bool: """Check if GEMINI.md should be compiled. Args: - target: The detected or configured target + target: The detected or configured target. May be a string or a + frozenset of compiler families for multi-target lists. Returns: bool: True if GEMINI.md should be generated """ + if isinstance(target, frozenset): + return "gemini" in target return target in ("gemini", "all") diff --git a/tests/unit/compilation/test_compile_target_flag.py b/tests/unit/compilation/test_compile_target_flag.py index 915363abe..a3b6e8285 100644 --- a/tests/unit/compilation/test_compile_target_flag.py +++ b/tests/unit/compilation/test_compile_target_flag.py @@ -1048,11 +1048,11 @@ def test_single_string_passthrough(self): assert _resolve_compile_target("all") == "all" assert _resolve_compile_target("copilot") == "copilot" - def test_list_claude_and_copilot_returns_all(self): + def test_list_claude_and_copilot_returns_agents_claude_set(self): from apm_cli.commands.compile.cli import _resolve_compile_target - assert _resolve_compile_target(["claude", "vscode"]) == "all" - assert _resolve_compile_target(["claude", "copilot"]) == "all" + assert _resolve_compile_target(["claude", "vscode"]) == frozenset({"agents", "claude"}) + assert _resolve_compile_target(["claude", "copilot"]) == frozenset({"agents", "claude"}) def test_list_claude_only_returns_claude(self): from apm_cli.commands.compile.cli import _resolve_compile_target @@ -1074,28 +1074,102 @@ def test_list_agents_family_without_claude_returns_vscode(self): assert _resolve_compile_target(["codex"]) == "vscode" assert _resolve_compile_target(["cursor", "opencode"]) == "vscode" - def test_list_cursor_and_claude_returns_all(self): + def test_list_cursor_and_claude_returns_agents_claude_set(self): from apm_cli.commands.compile.cli import _resolve_compile_target - assert _resolve_compile_target(["cursor", "claude"]) == "all" - assert _resolve_compile_target(["codex", "claude"]) == "all" + assert _resolve_compile_target(["cursor", "claude"]) == frozenset({"agents", "claude"}) + assert _resolve_compile_target(["codex", "claude"]) == frozenset({"agents", "claude"}) def test_list_gemini_only_returns_gemini(self): from apm_cli.commands.compile.cli import _resolve_compile_target assert _resolve_compile_target(["gemini"]) == "gemini" - def test_list_gemini_and_claude_returns_all(self): + def test_list_gemini_and_claude_returns_claude_gemini_set(self): from apm_cli.commands.compile.cli import _resolve_compile_target - assert _resolve_compile_target(["gemini", "claude"]) == "all" + assert _resolve_compile_target(["gemini", "claude"]) == frozenset({"claude", "gemini"}) - def test_list_gemini_and_copilot_returns_all(self): + def test_list_gemini_and_copilot_returns_agents_gemini_set(self): from apm_cli.commands.compile.cli import _resolve_compile_target - assert _resolve_compile_target(["gemini", "vscode"]) == "all" + assert _resolve_compile_target(["gemini", "vscode"]) == frozenset({"agents", "gemini"}) - def test_list_all_targets_returns_all(self): + def test_list_all_three_families_returns_full_set(self): from apm_cli.commands.compile.cli import _resolve_compile_target - assert _resolve_compile_target(["claude", "vscode", "cursor"]) == "all" + assert _resolve_compile_target(["claude", "vscode", "gemini"]) == frozenset({"agents", "claude", "gemini"}) + assert _resolve_compile_target(["claude", "vscode", "cursor"]) == frozenset({"agents", "claude"}) + + +class TestMultiTargetDoesNotGenerateUnrequestedFiles: + """Regression tests: multi-target lists must not generate files for families not requested.""" + + def test_claude_codex_does_not_compile_gemini(self): + from apm_cli.commands.compile.cli import _resolve_compile_target + from apm_cli.core.target_detection import ( + should_compile_agents_md, + should_compile_claude_md, + should_compile_gemini_md, + ) + + resolved = _resolve_compile_target(["claude", "codex"]) + assert should_compile_agents_md(resolved) is True + assert should_compile_claude_md(resolved) is True + assert should_compile_gemini_md(resolved) is False + + def test_claude_cursor_does_not_compile_gemini(self): + from apm_cli.commands.compile.cli import _resolve_compile_target + from apm_cli.core.target_detection import ( + should_compile_agents_md, + should_compile_claude_md, + should_compile_gemini_md, + ) + + resolved = _resolve_compile_target(["claude", "cursor"]) + assert should_compile_agents_md(resolved) is True + assert should_compile_claude_md(resolved) is True + assert should_compile_gemini_md(resolved) is False + + def test_gemini_codex_does_not_compile_claude(self): + from apm_cli.commands.compile.cli import _resolve_compile_target + from apm_cli.core.target_detection import ( + should_compile_agents_md, + should_compile_claude_md, + should_compile_gemini_md, + ) + + resolved = _resolve_compile_target(["gemini", "codex"]) + assert should_compile_agents_md(resolved) is True + assert should_compile_claude_md(resolved) is False + assert should_compile_gemini_md(resolved) is True + + def test_all_string_still_compiles_everything(self): + from apm_cli.core.target_detection import ( + should_compile_agents_md, + should_compile_claude_md, + should_compile_gemini_md, + ) + + assert should_compile_agents_md("all") is True + assert should_compile_claude_md("all") is True + assert should_compile_gemini_md("all") is True + + def test_single_target_strings_unchanged(self): + from apm_cli.core.target_detection import ( + should_compile_agents_md, + should_compile_claude_md, + should_compile_gemini_md, + ) + + assert should_compile_agents_md("vscode") is True + assert should_compile_claude_md("vscode") is False + assert should_compile_gemini_md("vscode") is False + + assert should_compile_agents_md("claude") is False + assert should_compile_claude_md("claude") is True + assert should_compile_gemini_md("claude") is False + + assert should_compile_agents_md("gemini") is True + assert should_compile_claude_md("gemini") is False + assert should_compile_gemini_md("gemini") is True From de6e84c69341e1116a8fcf42af0f5f1a05d845a0 Mon Sep 17 00:00:00 2001 From: Travis Illig Date: Tue, 28 Apr 2026 08:52:50 -0700 Subject: [PATCH 2/4] Add change to changelog. Co-authored-by: Copilot --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b047cfc96..fd77deecb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `apm marketplace build` now respects `GITHUB_HOST` for GitHub Enterprise repos -- ref resolution, token lookup, and metadata fetch all use the configured host instead of hardcoded `github.com`. `git ls-remote` is authenticated so private repos work without separate credential setup. (#1008) - `apm marketplace build` now accepts multiple Git URL forms (GitHub, GHES, GitLab, Bitbucket, ADO, SSH) for `type: url` parsing via `DependencyReference.parse()`. Host resolution is still driven by `GITHUB_HOST`, so non-`github.com` hosts require `GITHUB_HOST` to be set accordingly. (#1008) - **ADO Entra ID auth path no longer silently fails.** Bearer tokens from `az account get-access-token` are now correctly plumbed through validation (auth scheme, git env). Auth failures raise a typed `AuthenticationError` with an actionable 4-case diagnostic instead of the ambiguous "not accessible or doesn't exist" message. `apm install --update` runs a pre-flight auth check before modifying any files -- on failure it aborts with "No files were modified". (#1015) +- Correct targeting of compiled artifacts so GEMINI.md is only created if requested (#1019) ## [0.10.0] - 2026-04-27 From 6b5008a09a3aadded2e48a17da9678b3293d2c79 Mon Sep 17 00:00:00 2001 From: Travis Illig Date: Tue, 28 Apr 2026 08:55:06 -0700 Subject: [PATCH 3/4] Ensure --all still generates everything. --- .../compilation/test_compile_target_flag.py | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/tests/unit/compilation/test_compile_target_flag.py b/tests/unit/compilation/test_compile_target_flag.py index a3b6e8285..b4dde97c5 100644 --- a/tests/unit/compilation/test_compile_target_flag.py +++ b/tests/unit/compilation/test_compile_target_flag.py @@ -761,17 +761,17 @@ def temp_project(self): yield temp_path shutil.rmtree(temp_dir, ignore_errors=True) - def test_all_target_creates_both_files(self, temp_project): - """Test that --target all creates both AGENTS.md and CLAUDE.md.""" + def test_all_target_creates_all_files(self, temp_project): + """Test that --target all creates AGENTS.md, CLAUDE.md, and GEMINI.md.""" config = CompilationConfig( target="all", dry_run=False, single_agents=True # Use single-file for simpler verification ) - + compiler = AgentsCompiler(str(temp_project)) primitives = PrimitiveCollection() - + instruction = Instruction( name="test", file_path=temp_project / ".apm/instructions/test.instructions.md", @@ -782,18 +782,20 @@ def test_all_target_creates_both_files(self, temp_project): source="local" ) primitives.add_primitive(instruction) - + result = compiler.compile(config, primitives) - + # Should succeed assert result.success - - # Both files should be created + + # All three files should be created agents_md = temp_project / "AGENTS.md" claude_md = temp_project / "CLAUDE.md" - + gemini_md = temp_project / "GEMINI.md" + assert agents_md.exists(), "AGENTS.md should be created for target='all'" assert claude_md.exists(), "CLAUDE.md should be created for target='all'" + assert gemini_md.exists(), "GEMINI.md should be created for target='all'" def test_all_target_result_references_both(self, temp_project): """Test that --target all result references both outputs.""" From cea1d17c3c7116a557e7d65e82fe7943b8c240e5 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 29 Apr 2026 13:50:39 +0200 Subject: [PATCH 4/4] Address PR #1020 review nits Round 1 (Review Panel optional follow-ups): - Tighten CompileTargetType to Union[TargetType, frozenset[CompileFamily]] using a narrow CompileFamily Literal; mypy/pyright can now catch invalid families flowing into should_compile_*. - Extract REASON_NO_TARGET_FOLDER constant; replace brittle 'no target' in detection_reason substring match with equality. User-facing log message unchanged. - Add explicit _KNOWN_COMPILE_FAMILIES validation in AgentsCompiler so direct API callers passing an invalid frozenset family fail with a clear error instead of silently no-op'ing. Round 2 (Copilot reviewer findings on follow-up commit): - Fix latent bug: config-driven multi-target (apm.yml target: [a, b]) fell through to get_target_description() and logged 'unknown target'. Switch the multi-target log branch to isinstance(effective_target, frozenset) so CLI and config paths produce the same accurate output. - Honor detect_target()'s Optional[str] contract by passing config_target only when it is a string (frozenset case is already handled by the branch above). Tests: - test_unknown_frozenset_target_family_returns_failure - TestMultiTargetLogOutput.test_cli_multi_target_log_message - TestMultiTargetLogOutput.test_config_multi_target_log_message_does_not_say_unknown Refs microsoft/apm#1020 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/apm_cli/commands/compile/cli.py | 31 +++++++-- src/apm_cli/compilation/agents_compiler.py | 25 +++++++ src/apm_cli/core/target_detection.py | 14 +++- .../compilation/test_compile_target_flag.py | 67 +++++++++++++++++++ 4 files changed, 128 insertions(+), 9 deletions(-) diff --git a/src/apm_cli/commands/compile/cli.py b/src/apm_cli/commands/compile/cli.py index 55bd3d7a3..e1e01ad1b 100644 --- a/src/apm_cli/commands/compile/cli.py +++ b/src/apm_cli/commands/compile/cli.py @@ -388,7 +388,11 @@ def compile( logger.start("Starting context compilation...", symbol="cogs") # Auto-detect target if not explicitly provided - from ...core.target_detection import detect_target, get_target_description + from ...core.target_detection import ( + REASON_NO_TARGET_FOLDER, + detect_target, + get_target_description, + ) # Get config target from apm.yml if available. When the file is # absent we proceed with auto-detection; when it is present but @@ -418,10 +422,13 @@ def compile( effective_target = compile_config_target detection_reason = "apm.yml target" else: + # Pass config_target only when it's a string -- detect_target() is + # typed for Optional[str], and a frozenset config_target is already + # handled by the branch above. detected_target, detection_reason = detect_target( project_root=Path("."), explicit_target=compile_target, - config_target=compile_config_target, + config_target=compile_config_target if isinstance(compile_config_target, str) else None, ) # Map 'minimal' to 'vscode' for the compiler (AGENTS.md only, no folder integration) effective_target = detected_target if detected_target != "minimal" else "vscode" @@ -446,9 +453,15 @@ def compile( # Show target-aware message with detection reason. Use # get_target_description() so any future target added to # target_detection shows up here automatically. - if isinstance(target, list): - # Multi-target list: show what the compiler will produce - _target_label = ",".join(target) + if isinstance(effective_target, frozenset): + # Multi-target compile (from CLI `--target a,b` OR apm.yml + # `target: [a, b]`): show what the compiler will produce. + if isinstance(target, list): + _target_label = f"--target {','.join(target)}" + elif isinstance(config_target, list): + _target_label = f"apm.yml target: [{', '.join(config_target)}]" + else: + _target_label = "multi-target" from ...core.target_detection import ( should_compile_agents_md, should_compile_claude_md, @@ -462,9 +475,13 @@ def compile( if should_compile_gemini_md(effective_target): _parts.append("GEMINI.md") logger.progress( - f"Compiling for {' + '.join(_parts)} (--target {_target_label})" + f"Compiling for {' + '.join(_parts)} ({_target_label})" ) - elif isinstance(effective_target, str) and effective_target == "vscode" and "no target" in detection_reason: + elif ( + isinstance(effective_target, str) + and effective_target == "vscode" + and detection_reason == REASON_NO_TARGET_FOLDER + ): logger.progress(f"Compiling for AGENTS.md only ({detection_reason})") logger.progress( " Create .github/, .claude/, .codex/, .opencode/ or .cursor/ folder for full integration", diff --git a/src/apm_cli/compilation/agents_compiler.py b/src/apm_cli/compilation/agents_compiler.py index 473d2e862..e48deb63c 100644 --- a/src/apm_cli/compilation/agents_compiler.py +++ b/src/apm_cli/compilation/agents_compiler.py @@ -33,6 +33,11 @@ "vscode", "claude", "cursor", "opencode", "codex", "gemini", "all", "minimal", ) + _VSCODE_TARGET_ALIASES +# Compiler families allowed inside a multi-target frozenset (built by +# _resolve_compile_target() from CLI-validated target names). Kept narrow +# because the frozenset path bypasses _KNOWN_TARGETS validation. +_KNOWN_COMPILE_FAMILIES = frozenset({"agents", "claude", "gemini"}) + @dataclass class CompilationConfig: @@ -216,6 +221,26 @@ def compile(self, config: CompilationConfig, primitives: Optional[PrimitiveColle # new targets (codex, opencode, cursor, minimal, ...) route # correctly without touching this method again. if isinstance(config.target, frozenset): + # Multi-target lists are normalized by _resolve_compile_target() + # into compiler families only. Validate defensively for direct + # API callers so invalid families do not silently produce + # partial output or a successful no-op. + invalid_families = config.target - _KNOWN_COMPILE_FAMILIES + if invalid_families: + self.errors.append( + "Unknown compilation target family in multi-target set: " + f"{', '.join(sorted(invalid_families))}. " + "Expected subset of: " + f"{', '.join(sorted(_KNOWN_COMPILE_FAMILIES))}" + ) + return CompilationResult( + success=False, + output_path="", + content="", + warnings=self.warnings.copy(), + errors=self.errors.copy(), + stats={}, + ) routing_target = config.target else: routing_target = ( diff --git a/src/apm_cli/core/target_detection.py b/src/apm_cli/core/target_detection.py index ae2a0502f..552cc07a1 100644 --- a/src/apm_cli/core/target_detection.py +++ b/src/apm_cli/core/target_detection.py @@ -29,9 +29,19 @@ # Valid target values (internal canonical form) TargetType = Literal["vscode", "claude", "cursor", "opencode", "codex", "gemini", "all", "minimal"] +# Compiler families used inside a multi-target frozenset. Narrower than +# TargetType because the families are produced by _resolve_compile_target() +# (in the compile CLI) from CLI-validated target names. +CompileFamily = Literal["agents", "claude", "gemini"] + # Compile target: either a single TargetType string or a frozenset of compiler # families ({"agents", "claude", "gemini"}) for multi-target lists. -CompileTargetType = Union[str, frozenset] +CompileTargetType = Union[TargetType, frozenset[CompileFamily]] + +# Detection reason returned by detect_target() when no integration folder is +# present. Exported as a constant so consumers can compare with equality +# instead of substring matching. +REASON_NO_TARGET_FOLDER = "no target folder found" # User-facing target values (includes aliases accepted by CLI) UserTargetType = Literal["copilot", "vscode", "agents", "claude", "cursor", "opencode", "codex", "gemini", "all", "minimal"] @@ -124,7 +134,7 @@ def detect_target( elif gemini_exists: return "gemini", "detected .gemini/ folder" else: - return "minimal", "no target folder found" + return "minimal", REASON_NO_TARGET_FOLDER def should_compile_agents_md(target: CompileTargetType) -> bool: diff --git a/tests/unit/compilation/test_compile_target_flag.py b/tests/unit/compilation/test_compile_target_flag.py index b4dde97c5..dcffbdb91 100644 --- a/tests/unit/compilation/test_compile_target_flag.py +++ b/tests/unit/compilation/test_compile_target_flag.py @@ -245,6 +245,21 @@ def test_unknown_target_returns_failure(self, temp_project, sample_primitives): assert result.success is False assert any("Unknown compilation target" in e for e in result.errors) + def test_unknown_frozenset_target_family_returns_failure(self, temp_project, sample_primitives): + """Unknown multi-target family must fail explicitly instead of silently no-oping.""" + config = CompilationConfig( + target=frozenset({"agents", "not-a-real-family"}), + dry_run=True, + single_agents=True, + ) + + compiler = AgentsCompiler(str(temp_project)) + result = compiler.compile(config, sample_primitives) + + assert result.success is False + assert any("Unknown compilation target family" in e for e in result.errors) + assert any("not-a-real-family" in e for e in result.errors) + class TestMergeResults: """Tests for _merge_results() method.""" @@ -1175,3 +1190,55 @@ def test_single_target_strings_unchanged(self): assert should_compile_agents_md("gemini") is True assert should_compile_claude_md("gemini") is False assert should_compile_gemini_md("gemini") is True + + +class TestMultiTargetLogOutput: + """Regression tests for the 'Compiling for ...' log line on multi-target compiles.""" + + @pytest.fixture + def runner(self): + return CliRunner() + + @pytest.fixture + def empty_project(self): + temp_dir = tempfile.mkdtemp() + temp_path = Path(temp_dir) + (temp_path / "apm.yml").write_text("name: test-project\nversion: 0.1.0\n") + apm_dir = temp_path / ".apm" / "instructions" + apm_dir.mkdir(parents=True) + (apm_dir / "good.instructions.md").write_text( + "---\napplyTo: '**/*.py'\n---\nFollow PEP 8.\n" + ) + yield temp_path + shutil.rmtree(temp_dir, ignore_errors=True) + + def test_cli_multi_target_log_message(self, runner, empty_project): + original_dir = os.getcwd() + try: + os.chdir(empty_project) + result = runner.invoke( + cli, ["compile", "--target", "claude,codex", "--dry-run"] + ) + assert "Compiling for" in result.output + assert "AGENTS.md" in result.output and "CLAUDE.md" in result.output + assert "GEMINI.md" not in result.output.split("Compiling for", 1)[1].split("\n", 1)[0] + assert "--target claude,codex" in result.output + finally: + os.chdir(original_dir) + + def test_config_multi_target_log_message_does_not_say_unknown(self, runner, empty_project): + """Regression: apm.yml multi-target list must not log 'unknown target'.""" + (empty_project / "apm.yml").write_text( + "name: test-project\nversion: 0.1.0\ntarget: [claude, codex]\n" + ) + original_dir = os.getcwd() + try: + os.chdir(empty_project) + result = runner.invoke(cli, ["compile", "--dry-run"]) + assert "unknown target" not in result.output.lower(), ( + f"Config-driven multi-target should not log 'unknown target'. Got:\n{result.output}" + ) + assert "Compiling for" in result.output + assert "AGENTS.md" in result.output and "CLAUDE.md" in result.output + finally: + os.chdir(original_dir)