diff --git a/CHANGELOG.md b/CHANGELOG.md index a5cc53e42..62d252dd1 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 `apm.yml` `target:` / `targets:` and the CLI `--target` flag, restoring parity with one-shot `apm compile`. Previously the watcher rebuilt `CompilationConfig` without going through target resolution, so `targets: [claude, cursor]` still emitted a stub `GEMINI.md @./AGENTS.md` redirect on every recompile and `apm compile --watch --target claude` was silently ignored (regression of #1019 / #1074 in the watch path). The compile body now routes through a shared `_run_compile_once` helper called by both surfaces; `apm.yml` is re-read on every recompile so mid-session edits to `targets:` take effect on the next file event, and `apm compile --watch --clean` warns instead of silently dropping the flag. (#1345) - MCP server installation now respects the `targets:` whitelist exactly like `apm install`: drop a non-listed runtime even when its `.cursor/`, `.codex/`, or other on-disk signal exists. Previously the MCP install path called `active_targets()` reading the singular `target:` key only, so projects whitelisting `targets: [copilot]` could still receive `~/.codex/config.toml` and `.cursor/mcp.json` writes from foreign signals. The fix audits both paths: (a) the call site at `local_bundle_handler.py` now forwards the canonical plural list; (b) the gate now delegates to the same `resolve_targets` resolver that backs `apm install` skills, so a malformed `targets:` field (conflicting `target:` + `targets:`, `targets: []`, or unknown target name) fails closed with the same `[x]` red error voice + remediation block. The same delegation closes a related asymmetry: a greenfield project (no `targets:`, no `--target` flag, no detected signals) used to silently fall back to `[copilot]` for MCP-only invocations, while `apm install` raised `NoHarnessError` on the same input -- both surfaces now error consistently. Drop lines now use the `[i] Skipped MCP config for X (active targets: Y)` format mirroring the canonical `Targets: X (source: Y)` provenance line. The `-g`/`--global` carve-out is unchanged: `apm install -g --mcp NAME` writes to user-scope (`~/.config/...`, `~/.codex/`, etc.) bypassing the project-scope gate by design. (#1335) - Gemini CLI: `apm install -g --mcp NAME` now correctly writes to `~/.gemini/settings.json` (user scope) and `apm install` from outside the target project writes to `/.gemini/settings.json` instead of `cwd`. Previously `--global` had no effect on Gemini and project-scope writes silently landed in the wrong directory. The matching opt-in gate and cleanup paths in `MCPIntegrator` are aligned in the same change. (#1299) - `apm install --target claude` now preserves self-defined stdio MCP `env` values from `apm.yml` and writes non-string values such as `PORT: 3000` and `DEBUG: false` as MCP-compatible strings. (#1222) diff --git a/src/apm_cli/commands/compile/cli.py b/src/apm_cli/commands/compile/cli.py index 9e871c7ce..fddf96b13 100644 --- a/src/apm_cli/commands/compile/cli.py +++ b/src/apm_cli/commands/compile/cli.py @@ -1,11 +1,11 @@ """APM compile command CLI.""" import sys -from pathlib import Path # noqa: F401 +from pathlib import Path import click -from ...compilation import AgentsCompiler, CompilationConfig +from ...compilation import AgentsCompiler, CompilationConfig, CompilationResult from ...constants import AGENTS_MD_FILENAME, APM_DIR, APM_MODULES_DIR, APM_YML_FILENAME from ...core.command_logger import CommandLogger from ...core.target_detection import TargetParamType @@ -252,6 +252,365 @@ def _family_of(name: str) -> str | None: return target # single string pass-through +def _run_compile_once( + *, + target: str | list[str] | None, + output: str, + chatmode: str | None, + no_links: bool, + dry_run: bool, + single_agents: bool, + verbose: bool, + local_only: bool, + clean: bool, + with_constitution: bool, + logger: CommandLogger, +) -> CompilationResult: + """Run a single compile pass - shared body for one-shot and watch paths. + + Why: prior to #1345 the watch path reimplemented config-building + without target resolution, so apm.yml `targets:` and CLI `--target` + were silently ignored under --watch, regressing the #1019/#1074 fixes. + Routing both paths through this function makes structural drift + impossible. + + apm.yml is read fresh on every call so watch-mode reflects mid-session + edits to `targets:`. + + Returns the CompilationResult. Caller inspects ``result.errors`` and + ``result.has_critical_security`` to decide on exit code; this function + never calls ``sys.exit`` so the watcher can keep running after a + failed recompile. + """ + logger.start("Starting context compilation...", symbol="cogs") + + # Auto-detect target if not explicitly provided + 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 + # malformed we let the parse error surface so users see exactly + # what is wrong (e.g. ``target: opencode,bogus`` -> a ValueError + # naming the bad token), rather than silently falling through to + # auto-detect. See #820. + 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 + # Parity with `apm install`: also honor canonical plural + # `targets:` key (#1154). APMPackage only reads singular + # `target:`; parse_targets_field handles both keys, raises + # ConflictingTargetsError when both appear, and validates + # tokens against CANONICAL_TARGETS. When only `targets:` is + # present, apm_pkg.target is None and we promote the plural + # list here so compile sees the same schema install sees. + 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 + + # 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) + + # 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: + # 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 if isinstance(compile_config_target, str) else None, + ) + # Keep the detected target intact so the compiler can preserve + # minimal-mode semantics (AGENTS.md only, no .github side outputs). + effective_target = detected_target + + # Emit canonical provenance line BEFORE compilation -- mirrors + # `apm install` so users see the same `[i] Targets: ... + # (source: ...)` line on both surfaces. Use the user-facing + # source values (target / config_target) NOT the compiler-family + # expansion in effective_target -- install shows the schema names + # the user wrote (e.g. "copilot"), so compile must too, otherwise + # parity drifts (compile would print "agents, vscode" for the + # same input). + from ...core.target_detection import ResolvedTargets, format_provenance + from ...utils.console import _rich_info + + def _coerce_provenance_targets(value): + if value is None: + return [] + if isinstance(value, str): + return [t.strip() for t in value.split(",") if t.strip()] + if isinstance(value, list): + return [str(t) for t in value] + if isinstance(value, frozenset): + return sorted(value) + return [] + + if detection_reason == "explicit --target flag": + _provenance_targets = _coerce_provenance_targets(target) + _provenance_source = "--target flag" + elif detection_reason == "apm.yml target": + _provenance_targets = _coerce_provenance_targets(config_target) + _provenance_source = "apm.yml" + else: + if isinstance(effective_target, frozenset): + _provenance_targets = sorted(effective_target) + elif isinstance(effective_target, str): + _provenance_targets = [effective_target] + else: + _provenance_targets = [] + _provenance_source = f"auto-detect ({detection_reason})" + + if _provenance_targets: + _rich_info( + format_provenance( + ResolvedTargets( + targets=sorted(set(_provenance_targets)), + source=_provenance_source, + auto_create=True, + ) + ), + symbol="info", + ) + + # Build config with distributed compilation flags (Task 7) + config = CompilationConfig.from_apm_yml( + output_path=output if output != AGENTS_MD_FILENAME else None, + chatmode=chatmode, + resolve_links=not no_links if no_links else None, + dry_run=dry_run, + single_agents=single_agents, + trace=verbose, + local_only=local_only, + debug=verbose, + clean_orphaned=clean, + target=effective_target, + ) + config.with_constitution = with_constitution + + # Handle distributed vs single-file compilation + if config.strategy == "distributed" and not single_agents: + # 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(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, + 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_label})") + 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", + symbol="light_bulb", + ) + else: + description = get_target_description(effective_target) + logger.progress(f"Compiling for {description} - {detection_reason}") + + if dry_run: + logger.dry_run_notice("showing placement without writing files") + if verbose: + logger.verbose_detail("Verbose mode: showing source attribution and optimizer analysis") + else: + logger.progress("Using single-file compilation (legacy mode)", symbol="page") + + # Perform compilation + compiler = AgentsCompiler(".") + result = compiler.compile(config, logger=logger) + + if result.success: + # Handle different compilation modes + if config.strategy == "distributed" and not single_agents: + # Distributed compilation results - output already shown by professional formatter + # Just show final success message + if dry_run: + # Success message for dry run already included in formatter output + pass + else: + # Defense-in-depth (#820): don't claim "completed + # successfully" when zero files were emitted. With + # parse_target_field as the upstream gatekeeper this is + # unreachable in normal flow, but silent zero-effect + # success is the worst-case package-manager DX. + # + # Pattern-based stat scan (instead of a hardcoded key + # list) so new compile-time targets pick up the guard + # automatically: any stat ending in ``_files_written`` + # or ``_files_generated`` contributes to the total. + _files_written = sum( + int(v or 0) + for k, v in result.stats.items() + if k.endswith(("_files_written", "_files_generated")) + ) + if _files_written > 0: + logger.success( + "Compilation completed successfully!", + symbol="check", + ) + else: + # Zero-output compile is the silent-success failure + # mode #820 guards against. Don't claim success; + # surface what the user can act on. The cause is + # usually one of: target dirs not present (auto- + # detect found nothing), explicit target rejected + # by policy, or no primitives in the project. + logger.warning( + "Compilation completed but produced no output " + "files. Check that target directories exist " + "(e.g. .github/, .claude/) or set 'target:' " + "in apm.yml / pass --target explicitly." + ) + + else: + # Traditional single-file compilation - keep existing logic + # Perform initial compilation in dry-run to get generated body (without constitution) + intermediate_config = CompilationConfig( + output_path=config.output_path, + chatmode=config.chatmode, + resolve_links=config.resolve_links, + dry_run=True, # force + with_constitution=config.with_constitution, + strategy="single-file", + ) + intermediate_result = compiler.compile(intermediate_config) + + if intermediate_result.success: + # Perform constitution injection / preservation + from ...compilation.injector import ConstitutionInjector + + injector = ConstitutionInjector(base_dir=".") + output_path = Path(config.output_path) + final_content, c_status, c_hash = injector.inject( + intermediate_result.content, + with_constitution=config.with_constitution, + output_path=output_path, + ) + + if not dry_run: + # Only rewrite when content materially changes (creation, update, missing constitution case) + if c_status in ("CREATED", "UPDATED", "MISSING"): + # Defense-in-depth: scan compiled output before writing + from ...security.gate import WARN_POLICY, SecurityGate + + verdict = SecurityGate.scan_text( + final_content, str(output_path), policy=WARN_POLICY + ) + if verdict.has_findings: + actionable = verdict.critical_count + verdict.warning_count + if verdict.has_critical: + result.has_critical_security = True + if actionable: + logger.warning( + f"Compiled output contains {actionable} hidden character(s) " + f"-- run 'apm audit --file {output_path}' to inspect" + ) + from ...compilation.output_writer import CompiledOutputWriter + + # OSError propagates: one-shot caller's outer + # except converts to sys.exit(1); watcher's + # per-recompile except logs and keeps watching. + CompiledOutputWriter().write(output_path, final_content) + else: + logger.progress( + "No changes detected; preserving existing AGENTS.md for idempotency" + ) + + # Report success at the top + if dry_run: + logger.success( + "Context compilation completed successfully (dry run)", + symbol="check", + ) + else: + logger.success( + f"Context compiled successfully to {output_path}", + ) + + stats = ( + intermediate_result.stats + ) # timestamp removed; stats remain version + counts + + # Add spacing before summary table + _rich_blank_line() + + _display_single_file_summary(stats, c_status, c_hash, output_path, dry_run) + + if dry_run: + preview = final_content[:500] + ("..." if len(final_content) > 500 else "") + _rich_panel(preview, title=" Generated Content Preview", style="cyan") + else: + _display_next_steps(output) + + # Display warnings for all compilation modes + if result.warnings: + logger.warning(f"Compilation completed with {len(result.warnings)} warning(s):") + for warning in result.warnings: + logger.warning(f" {warning}") + + if result.errors: + logger.error(f"Compilation failed with {len(result.errors)} errors:") + for error in result.errors: + logger.error(f" {error}") + + # `result.has_critical_security` is the authoritative signal -- + # single-file mode may have flipped it in-place above when its + # post-injection security scan found critical findings. Caller + # (one-shot: sys.exit 1; watcher: log & keep watching) reads it + # directly off the returned result. + return result + + @click.command(help="Compile APM context into distributed AGENTS.md files") @click.option( "--output", @@ -455,335 +814,45 @@ def compile( # Watch mode if watch: - _watch_mode(output, chatmode, no_links, dry_run, verbose=verbose) - return - - logger.start("Starting context compilation...", symbol="cogs") - - # Auto-detect target if not explicitly provided - 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 - # malformed we let the parse error surface so users see exactly - # what is wrong (e.g. ``target: opencode,bogus`` -> a ValueError - # naming the bad token), rather than silently falling through to - # auto-detect. See #820. - 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 - # Parity with `apm install`: also honor canonical plural - # `targets:` key (#1154). APMPackage only reads singular - # `target:`; parse_targets_field handles both keys, raises - # ConflictingTargetsError when both appear, and validates - # tokens against CANONICAL_TARGETS. When only `targets:` is - # present, apm_pkg.target is None and we promote the plural - # list here so compile sees the same schema install sees. - 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 - - # 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) - - # 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: - # 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 - if isinstance(compile_config_target, str) - else None, - ) - # Keep the detected target intact so the compiler can preserve - # minimal-mode semantics (AGENTS.md only, no .github side outputs). - effective_target = detected_target - - # Emit canonical provenance line BEFORE compilation -- mirrors - # `apm install` so users see the same `[i] Targets: ... - # (source: ...)` line on both surfaces. Use the user-facing - # source values (target / config_target) NOT the compiler-family - # expansion in effective_target -- install shows the schema names - # the user wrote (e.g. "copilot"), so compile must too, otherwise - # parity drifts (compile would print "agents, vscode" for the - # same input). - from ...core.target_detection import ResolvedTargets, format_provenance - from ...utils.console import _rich_info - - def _coerce_provenance_targets(value): - if value is None: - return [] - if isinstance(value, str): - return [t.strip() for t in value.split(",") if t.strip()] - if isinstance(value, list): - return [str(t) for t in value] - if isinstance(value, frozenset): - return sorted(value) - return [] - - if detection_reason == "explicit --target flag": - _provenance_targets = _coerce_provenance_targets(target) - _provenance_source = "--target flag" - elif detection_reason == "apm.yml target": - _provenance_targets = _coerce_provenance_targets(config_target) - _provenance_source = "apm.yml" - else: - if isinstance(effective_target, frozenset): - _provenance_targets = sorted(effective_target) - elif isinstance(effective_target, str): - _provenance_targets = [effective_target] - else: - _provenance_targets = [] - _provenance_source = f"auto-detect ({detection_reason})" - - if _provenance_targets: - _rich_info( - format_provenance( - ResolvedTargets( - targets=sorted(set(_provenance_targets)), - source=_provenance_source, - auto_create=True, - ) - ), - symbol="info", + if clean: + # `--clean` removes orphaned outputs. Running it on + # every recompile would surprise users mid-session, so + # the watcher hard-codes clean=False. Surface that + # decision instead of silently dropping the flag. + logger.warning( + "--clean is ignored in watch mode; " + "run 'apm compile --clean' separately to remove orphaned outputs." + ) + _watch_mode( + target=target, + output=output, + chatmode=chatmode, + no_links=no_links, + dry_run=dry_run, + single_agents=single_agents, + verbose=verbose, + local_only=local_only, + with_constitution=with_constitution, ) + return - # Build config with distributed compilation flags (Task 7) - config = CompilationConfig.from_apm_yml( - output_path=output if output != AGENTS_MD_FILENAME else None, + # One-shot compile: share the body with the watch path (#1345) so + # target resolution can't drift between the two surfaces. + result = _run_compile_once( + target=target, + output=output, chatmode=chatmode, - resolve_links=not no_links if no_links else None, + no_links=no_links, dry_run=dry_run, single_agents=single_agents, - trace=verbose, + verbose=verbose, local_only=local_only, - debug=verbose, - clean_orphaned=clean, - target=effective_target, + clean=clean, + with_constitution=with_constitution, + logger=logger, ) - config.with_constitution = with_constitution - - # Handle distributed vs single-file compilation - if config.strategy == "distributed" and not single_agents: - # 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(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, - 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_label})") - 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", - symbol="light_bulb", - ) - else: - description = get_target_description(effective_target) - logger.progress(f"Compiling for {description} - {detection_reason}") - - if dry_run: - logger.dry_run_notice("showing placement without writing files") - if verbose: - logger.verbose_detail( - "Verbose mode: showing source attribution and optimizer analysis" - ) - else: - logger.progress("Using single-file compilation (legacy mode)", symbol="page") - - # Perform compilation - compiler = AgentsCompiler(".") - result = compiler.compile(config, logger=logger) - compile_has_critical = result.has_critical_security - - if result.success: - # Handle different compilation modes - if config.strategy == "distributed" and not single_agents: - # Distributed compilation results - output already shown by professional formatter - # Just show final success message - if dry_run: - # Success message for dry run already included in formatter output - pass - else: - # Defense-in-depth (#820): don't claim "completed - # successfully" when zero files were emitted. With - # parse_target_field as the upstream gatekeeper this is - # unreachable in normal flow, but silent zero-effect - # success is the worst-case package-manager DX. - # - # Pattern-based stat scan (instead of a hardcoded key - # list) so new compile-time targets pick up the guard - # automatically: any stat ending in ``_files_written`` - # or ``_files_generated`` contributes to the total. - _files_written = sum( - int(v or 0) - for k, v in result.stats.items() - if k.endswith(("_files_written", "_files_generated")) - ) - if _files_written > 0: - logger.success( - "Compilation completed successfully!", - symbol="check", - ) - else: - # Zero-output compile is the silent-success failure - # mode #820 guards against. Don't claim success; - # surface what the user can act on. The cause is - # usually one of: target dirs not present (auto- - # detect found nothing), explicit target rejected - # by policy, or no primitives in the project. - logger.warning( - "Compilation completed but produced no output " - "files. Check that target directories exist " - "(e.g. .github/, .claude/) or set 'target:' " - "in apm.yml / pass --target explicitly." - ) - - else: - # Traditional single-file compilation - keep existing logic - # Perform initial compilation in dry-run to get generated body (without constitution) - intermediate_config = CompilationConfig( - output_path=config.output_path, - chatmode=config.chatmode, - resolve_links=config.resolve_links, - dry_run=True, # force - with_constitution=config.with_constitution, - strategy="single-file", - ) - intermediate_result = compiler.compile(intermediate_config) - - if intermediate_result.success: - # Perform constitution injection / preservation - from ...compilation.injector import ConstitutionInjector - - injector = ConstitutionInjector(base_dir=".") - output_path = Path(config.output_path) - final_content, c_status, c_hash = injector.inject( - intermediate_result.content, - with_constitution=config.with_constitution, - output_path=output_path, - ) - - if not dry_run: - # Only rewrite when content materially changes (creation, update, missing constitution case) - if c_status in ("CREATED", "UPDATED", "MISSING"): - # Defense-in-depth: scan compiled output before writing - from ...security.gate import WARN_POLICY, SecurityGate - - verdict = SecurityGate.scan_text( - final_content, str(output_path), policy=WARN_POLICY - ) - if verdict.has_findings: - actionable = verdict.critical_count + verdict.warning_count - if verdict.has_critical: - compile_has_critical = True - if actionable: - logger.warning( - f"Compiled output contains {actionable} hidden character(s) " - f"-- run 'apm audit --file {output_path}' to inspect" - ) - try: - from ...compilation.output_writer import CompiledOutputWriter - - CompiledOutputWriter().write(output_path, final_content) - except OSError as e: - logger.error(f"Failed to write final AGENTS.md: {e}") - sys.exit(1) - else: - logger.progress( - "No changes detected; preserving existing AGENTS.md for idempotency" - ) - - # Report success at the top - if dry_run: - logger.success( - "Context compilation completed successfully (dry run)", - symbol="check", - ) - else: - logger.success( - f"Context compiled successfully to {output_path}", - ) - - stats = ( - intermediate_result.stats - ) # timestamp removed; stats remain version + counts - - # Add spacing before summary table - _rich_blank_line() - - _display_single_file_summary(stats, c_status, c_hash, output_path, dry_run) - - if dry_run: - preview = final_content[:500] + ("..." if len(final_content) > 500 else "") - _rich_panel(preview, title=" Generated Content Preview", style="cyan") - else: - _display_next_steps(output) - - # Display warnings for all compilation modes - if result.warnings: - logger.warning(f"Compilation completed with {len(result.warnings)} warning(s):") - for warning in result.warnings: - logger.warning(f" {warning}") if result.errors: - logger.error(f"Compilation failed with {len(result.errors)} errors:") - for error in result.errors: - logger.error(f" {error}") sys.exit(1) # Check for orphaned packages after successful compilation @@ -802,7 +871,7 @@ def _coerce_provenance_targets(value): # Hard-fail when critical security findings were detected in compiled # output. Consistent with apm install and apm unpack behavior. - if compile_has_critical: + if result.has_critical_security: logger.error( "Compiled output contains critical hidden characters" " -- run 'apm audit' to inspect, 'apm audit --strip' to clean" diff --git a/src/apm_cli/commands/compile/watcher.py b/src/apm_cli/commands/compile/watcher.py index f5b0253a9..0b63816c9 100644 --- a/src/apm_cli/commands/compile/watcher.py +++ b/src/apm_cli/commands/compile/watcher.py @@ -2,29 +2,66 @@ import time -from ...compilation import AgentsCompiler, CompilationConfig -from ...constants import AGENTS_MD_FILENAME, APM_DIR, APM_YML_FILENAME +from ...constants import APM_DIR, APM_YML_FILENAME from ...core.command_logger import CommandLogger -def _watch_mode(output, chatmode, no_links, dry_run, verbose=False): - """Watch for changes in .apm/ directories and auto-recompile.""" +def _watch_mode( + *, + target, + output, + chatmode, + no_links, + dry_run, + single_agents=False, + verbose=False, + local_only=False, + with_constitution=True, +): + """Watch for changes in .apm/ directories and auto-recompile. + + Each compile pass is delegated to ``_run_compile_once`` (cli.py), which + is the same function the non-watch ``apm compile`` calls. Sharing that + body is what prevents the watch path from re-introducing the target- + resolution drift fixed in #1019/#1074 (see #1345 for the regression). + """ + # Lazy import: cli.py imports _watch_mode from here, so importing + # _run_compile_once at module load would create a cycle. + from .cli import _run_compile_once + logger = CommandLogger("compile-watch", verbose=verbose, dry_run=dry_run) + def _do_compile(): + """One compile pass; swallows exceptions so the watcher keeps running.""" + try: + _run_compile_once( + target=target, + output=output, + chatmode=chatmode, + no_links=no_links, + dry_run=dry_run, + single_agents=single_agents, + verbose=verbose, + local_only=local_only, + # `--clean` removes orphaned outputs. Running it on + # every recompile would surprise users mid-session; + # keep watcher recompiles non-destructive. + clean=False, + with_constitution=with_constitution, + logger=logger, + ) + except Exception as e: + logger.error(f"Error during recompilation: {e}") + 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 + def __init__(self, on_change): + self._on_change = on_change self.last_compile = 0 self.debounce_delay = 1.0 # 1 second debounce @@ -42,45 +79,11 @@ def on_modified(self, event): 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}") + logger.progress(f"File changed: {event.src_path}", symbol="eyes") + self._on_change() # Set up file watching - event_handler = APMFileHandler(output, chatmode, no_links, dry_run, logger) + event_handler = APMFileHandler(_do_compile) observer = Observer() # Watch patterns for APM files @@ -122,30 +125,7 @@ def _recompile(self, changed_file): logger.progress("Press Ctrl+C to stop watching...", symbol="info") # Do initial compilation - logger.progress("Performing initial compilation...", symbol="gear") - - config = CompilationConfig.from_apm_yml( - output_path=output if output != AGENTS_MD_FILENAME else None, - chatmode=chatmode, - resolve_links=not no_links if no_links else None, - dry_run=dry_run, - ) - - compiler = AgentsCompiler(".") - result = compiler.compile(config) - - if result.success: - if dry_run: - logger.success("Initial compilation successful (dry run)", symbol="sparkles") - else: - logger.success( - f"Initial compilation complete: {result.output_path}", - symbol="sparkles", - ) - else: - logger.error("Initial compilation failed") - for error in result.errors: - logger.error(f" [x] {error}") + _do_compile() try: while True: diff --git a/tests/unit/compilation/test_compile_watch_target_regression.py b/tests/unit/compilation/test_compile_watch_target_regression.py new file mode 100644 index 000000000..8566d49b2 --- /dev/null +++ b/tests/unit/compilation/test_compile_watch_target_regression.py @@ -0,0 +1,192 @@ +"""Regression tests for #1345: `apm compile --watch` must honor target inputs. + +Before the fix, `_watch_mode` rebuilt CompilationConfig without going +through target resolution, so apm.yml `targets:` and CLI `--target` were +silently ignored under --watch. Initial-emission and every recompile +fell back to `target="all"` (the CompilationConfig default), generating +GEMINI.md regardless of what the user asked for. + +These tests lock the structural fix: +- `_run_compile_once` (the shared body) honors apm.yml `targets:`. +- `_watch_mode` delegates to `_run_compile_once` instead of building + CompilationConfig itself. +""" + +from __future__ import annotations + +import inspect +from pathlib import Path + +import pytest + + +def _setup_project(root: Path, *, targets_yaml_block: str) -> None: + """Write a minimal APM project with the given `targets:` block.""" + (root / "apm.yml").write_text( + f"name: test-1345\nversion: 1.0.0\n{targets_yaml_block}", + encoding="utf-8", + ) + instructions = root / ".apm" / "instructions" + instructions.mkdir(parents=True) + (instructions / "sample.instructions.md").write_text( + '---\ndescription: regression fixture\napplyTo: "**/*.md"\n---\n\nbody\n', + encoding="utf-8", + ) + + +class TestRunCompileOnceRespectsApmYmlTargets: + """The shared compile body must read apm.yml `targets:` correctly. + + This is the contract the watch path now depends on. Before #1345 the + watcher bypassed this path entirely; if a future change makes the + shared body stop honoring `targets:`, both surfaces regress. + """ + + def test_apm_yml_targets_excluding_gemini_does_not_emit_gemini_md(self, tmp_path, monkeypatch): + _setup_project( + tmp_path, + targets_yaml_block="targets:\n- claude\n- cursor\n", + ) + monkeypatch.chdir(tmp_path) + + from apm_cli.commands.compile.cli import _run_compile_once + from apm_cli.core.command_logger import CommandLogger + + _run_compile_once( + target=None, + output="AGENTS.md", + chatmode=None, + no_links=False, + dry_run=False, + single_agents=False, + verbose=False, + local_only=False, + clean=False, + with_constitution=True, + logger=CommandLogger("test", verbose=False, dry_run=False), + ) + + assert (tmp_path / "AGENTS.md").exists(), "cursor family emits AGENTS.md" + assert (tmp_path / "CLAUDE.md").exists(), "claude family emits CLAUDE.md" + assert not (tmp_path / "GEMINI.md").exists(), ( + "GEMINI.md must not be emitted when apm.yml targets: excludes gemini (#1345)" + ) + + def test_cli_target_claude_does_not_emit_gemini_md(self, tmp_path, monkeypatch): + """CLI `--target` must override apm.yml; if apm.yml asks for gemini + but --target says claude only, no GEMINI.md.""" + _setup_project( + tmp_path, + targets_yaml_block="targets:\n- claude\n- gemini\n", + ) + monkeypatch.chdir(tmp_path) + + from apm_cli.commands.compile.cli import _run_compile_once + from apm_cli.core.command_logger import CommandLogger + + _run_compile_once( + target="claude", # CLI -t claude + output="AGENTS.md", + chatmode=None, + no_links=False, + dry_run=False, + single_agents=False, + verbose=False, + local_only=False, + clean=False, + with_constitution=True, + logger=CommandLogger("test", verbose=False, dry_run=False), + ) + + assert (tmp_path / "CLAUDE.md").exists() + assert not (tmp_path / "GEMINI.md").exists(), ( + "CLI --target claude must override apm.yml `targets:` (#1345)" + ) + + def test_apm_yml_targets_including_gemini_does_emit_gemini_md(self, tmp_path, monkeypatch): + """Companion assertion: when `gemini` IS in targets, GEMINI.md + must be emitted. Without this, the "exclude" assertion above is + a tautology (it would also pass if `_run_compile_once` were + completely broken and never produced GEMINI.md).""" + _setup_project( + tmp_path, + targets_yaml_block="targets:\n- claude\n- gemini\n", + ) + monkeypatch.chdir(tmp_path) + + from apm_cli.commands.compile.cli import _run_compile_once + from apm_cli.core.command_logger import CommandLogger + + _run_compile_once( + target=None, + output="AGENTS.md", + chatmode=None, + no_links=False, + dry_run=False, + single_agents=False, + verbose=False, + local_only=False, + clean=False, + with_constitution=True, + logger=CommandLogger("test", verbose=False, dry_run=False), + ) + + assert (tmp_path / "GEMINI.md").exists(), ( + "GEMINI.md must be emitted when apm.yml targets: includes gemini" + ) + + +class TestWatchModeDelegatesToSharedCompileBody: + """Structural guard: `_watch_mode` must route through `_run_compile_once`. + + The whole point of the #1345 fix is to eliminate the parallel compile + path inside the watcher. If somebody re-introduces a direct + `CompilationConfig.from_apm_yml(...)` call in watcher.py, target + resolution will silently drift again -- this test catches that + structurally so the regression can't sneak in via a future refactor. + """ + + def test_watcher_imports_run_compile_once(self): + from apm_cli.commands.compile import watcher + + source = inspect.getsource(watcher._watch_mode) + assert "_run_compile_once" in source, ( + "Watcher must delegate to _run_compile_once (cli.py). See " + "#1345 for why the parallel CompilationConfig path was " + "removed." + ) + + def test_watcher_does_not_build_compilation_config_directly(self): + """Forbid `CompilationConfig.from_apm_yml(...)` in watcher.py. + + That call without target resolution is precisely the #1345 bug. + The shared `_run_compile_once` is the only sanctioned config + builder for the compile surface. + """ + from apm_cli.commands.compile import watcher + + source = inspect.getsource(watcher) + assert "CompilationConfig.from_apm_yml" not in source, ( + "Watcher must not build CompilationConfig directly -- that " + "was the #1345 bug. Route compile work through " + "_run_compile_once instead." + ) + + def test_watcher_does_not_instantiate_compiler_directly(self): + """Forbid `AgentsCompiler(...)` instantiation in watcher.py. + + Same reasoning as the CompilationConfig check: the compiler must + only be driven from the shared body so target resolution applies + uniformly. + """ + from apm_cli.commands.compile import watcher + + source = inspect.getsource(watcher) + assert "AgentsCompiler(" not in source, ( + "Watcher must not call AgentsCompiler directly -- route " + "through _run_compile_once instead (#1345)." + ) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])