From d142a9a97dccae44742fd4e93ded8da7c0c04b4f 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 1b559eef..29cf6d67 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: @@ -390,18 +401,27 @@ def compile( # No apm.yml or parsing error - proceed with auto-detection pass - # 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( @@ -426,26 +446,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 8ed21748..473d2e86 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 9b087881..7bba1c78 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 dfa89cbe..7d145d4a 100644 --- a/tests/unit/compilation/test_compile_target_flag.py +++ b/tests/unit/compilation/test_compile_target_flag.py @@ -996,11 +996,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 @@ -1022,28 +1022,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 a6d6673abcae775e3c4d415c04364d221e54b2a4 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 81e83d1d..7596320b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Remove redundant `seen` set from `_scan_patterns()` discovery walk (#918) +- Correct targeting of compiled artifacts so GEMINI.md is only created if requested (#1019) ## [0.10.0] - 2026-04-27 From 292bda188c96dc9a082dcc1a8da254049217ec8c 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 7d145d4a..cc8049aa 100644 --- a/tests/unit/compilation/test_compile_target_flag.py +++ b/tests/unit/compilation/test_compile_target_flag.py @@ -709,17 +709,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", @@ -730,18 +730,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 fb314d3b42d752c86c7bc2808eb58e9230fb4d46 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: type precision, reason constant, frozenset family validation - Parameterize CompileTargetType as Union[str, frozenset[str]] for precision - Extract REASON_NO_TARGET_FOLDER constant in target_detection; replace brittle substring match in compile CLI with equality comparison. User-facing log message is unchanged. - Add explicit family validation in AgentsCompiler when config.target is a frozenset. Previously the frozenset branch silently bypassed _KNOWN_TARGETS validation; an invalid family (e.g. via direct API use) could silently produce partial output. Now fails explicitly with a clear error listing the bad value and accepted families. - Add regression test test_unknown_frozenset_target_family_returns_failure. Docs sync: verified docs/src/content/docs/reference/cli-commands.md and packages/apm-guide/.apm/skills/apm-usage/commands.md already document the correct (post-fix) multi-target behavior; no edits needed. Refs microsoft/apm#1020 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/apm_cli/commands/compile/cli.py | 12 +++++++-- src/apm_cli/compilation/agents_compiler.py | 25 +++++++++++++++++++ src/apm_cli/core/target_detection.py | 9 +++++-- .../compilation/test_compile_target_flag.py | 15 +++++++++++ 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/apm_cli/commands/compile/cli.py b/src/apm_cli/commands/compile/cli.py index 29cf6d67..39767715 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 config_target = None @@ -461,7 +465,11 @@ def compile( 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: + 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 473d2e86..e48deb63 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 7bba1c78..1d5528af 100644 --- a/src/apm_cli/core/target_detection.py +++ b/src/apm_cli/core/target_detection.py @@ -31,7 +31,12 @@ # 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[str, frozenset[str]] + +# 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 +129,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 cc8049aa..bbf2eb46 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."""