diff --git a/src/apm_cli/commands/compile/cli.py b/src/apm_cli/commands/compile/cli.py index 4977e88fc..f9e03c640 100644 --- a/src/apm_cli/commands/compile/cli.py +++ b/src/apm_cli/commands/compile/cli.py @@ -431,12 +431,20 @@ def _display_user_path(path: Path) -> str: return f"~/{rel.as_posix()}" -def _validate_project(logger: CommandLogger, dry_run: bool, source_root: Path) -> None: +def _validate_project( + logger: CommandLogger, + dry_run: bool, + source_root: Path, + *, + allow_empty: bool = False, +) -> None: """Check APM project exists and has content. Calls ``sys.exit(1)`` on fatal errors. In dry-run mode the function emits diagnostic messages but does *not* exit so callers can test the - full compile path even without real content. + full compile path even without real content. ``allow_empty`` lets + ``compile --clean`` reach the compiler's APM-owned orphan cleanup; + callers must keep validation and watch modes on the content-required path. """ from ...compilation.constitution import find_constitution @@ -458,6 +466,9 @@ def _validate_project(logger: CommandLogger, dry_run: bool, source_root: Path) - # If no primitive sources exist, check deeper to provide better feedback if not apm_modules_exists and not local_apm_has_content and not constitution_exists: + if allow_empty: + return + # Check if .apm directories exist but are empty has_empty_apm = ( apm_dir.exists() @@ -744,6 +755,9 @@ def _coerce_provenance_targets(value): "Compilation completed successfully!", symbol="check", ) + elif clean and result.stats.get("claude_empty_due_to_no_primitives"): + # The compiler already reported the expected cleanup outcome. + pass else: # Zero-output compile is the silent-success failure # mode #820 guards against. Don't claim success; @@ -1127,7 +1141,12 @@ def compile( # noqa: PLR0913 -- Click handler # from. Equals $PWD unless --root redirects writes elsewhere. source_root = get_source_root(InstallScope.PROJECT) - _validate_project(logger, dry_run, source_root) + _validate_project( + logger, + dry_run, + source_root, + allow_empty=clean and not validate and not watch, + ) if validate: _run_validation_mode(logger, verbose, source_root) diff --git a/src/apm_cli/compilation/agents_compiler.py b/src/apm_cli/compilation/agents_compiler.py index 52a6c1c76..9a0c07ed5 100644 --- a/src/apm_cli/compilation/agents_compiler.py +++ b/src/apm_cli/compilation/agents_compiler.py @@ -309,14 +309,18 @@ def is_apm_managed(self) -> bool: def _hand_authored_claude_skip_message( - rel: str, *, dry_run: bool = False, preview: bool = False + rel: str, + *, + dry_run: bool = False, + preview: bool = False, + duplicate_context: bool = True, ) -> str: """Build consistent skip guidance for hand-authored CLAUDE.md files.""" prefix = "[dry-run] would skip removal" if preview or dry_run else "Skipped removal" - return ( - f"{prefix} of {rel}: hand-authored file will not be deleted." - " Delete or rename it manually if duplicate context is unwanted." - ) + message = f"{prefix} of {rel}: hand-authored file will not be deleted." + if duplicate_context: + message += " Delete or rename it manually if duplicate context is unwanted." + return message class AgentsCompiler: @@ -872,11 +876,14 @@ def _compile_claude_md( all_warnings = self.warnings + claude_result.warnings all_errors = self.errors + claude_result.errors - # would_emit_no_claude_md is True when the formatter produced no CLAUDE.md - # files because skip_instructions fired (all content already in .claude/rules/). - # Used symmetrically in the dry-run preview block and the live-removal block so - # both paths share a single, precise emptiness signal. - would_emit_no_claude_md = len(claude_result.content_map) == 0 and skip_instructions + # Fires when content moved to .claude/rules/ or no source primitives remain. + # Dry-run preview, live removal, and formatter suppression share this signal. + would_emit_no_claude_md = len(claude_result.content_map) == 0 + orphan_reason = ( + "instructions now live in .claude/rules/" + if skip_instructions + else "no source primitives remain" + ) # Handle dry-run mode if config.dry_run: @@ -913,10 +920,7 @@ def _compile_claude_md( # result.content -- issue #1729 fix). self._log("warning", det.read_error) elif det.is_apm_managed: - removal_msg = ( - f"[dry-run] would remove stale {det.rel} -- instructions now" - " live in .claude/rules/" - ) + removal_msg = f"[dry-run] would remove stale {det.rel} -- {orphan_reason}" preview_lines.append(f" {removal_msg}") # Emit the preview through the logger so it reaches the terminal # on the distributed dry-run path (cli.py does `pass` on dry-run @@ -931,11 +935,17 @@ def _compile_claude_md( # would skip deletion and warn. Surface the same intent in # dry-run so users are not surprised by the live outcome. hand_authored_preview = _hand_authored_claude_skip_message( - det.rel, preview=True + det.rel, + preview=True, + duplicate_context=skip_instructions, ) preview_lines.append(f" {hand_authored_preview}") all_warnings.append( - _hand_authored_claude_skip_message(det.rel, dry_run=True) + _hand_authored_claude_skip_message( + det.rel, + dry_run=True, + duplicate_context=skip_instructions, + ) ) # Emit via logger so it surfaces on the distributed dry-run path # (cli.py does `pass` on dry-run success and never prints @@ -994,14 +1004,19 @@ def _compile_claude_md( # Update stats stats = claude_result.stats.copy() stats["claude_files_written"] = files_written + stats["claude_empty_due_to_no_primitives"] = ( + would_emit_no_claude_md and not skip_instructions + ) if would_emit_no_claude_md: - self._log( - "progress", - "CLAUDE.md not generated -- Claude Code reads .claude/rules/ directly," - " no further action needed", - symbol="info", - ) + if skip_instructions: + no_output_message = ( + "CLAUDE.md not generated -- Claude Code reads .claude/rules/" + " directly, no further action needed" + ) + else: + no_output_message = "CLAUDE.md not generated -- no source primitives remain" + self._log("progress", no_output_message, symbol="info") # Remove a stale APM-generated CLAUDE.md when --clean is set. # A hand-authored file (no CLAUDE_HEADER marker) is never deleted; # a warning is emitted instead to match the Copilot-root convention. @@ -1023,14 +1038,17 @@ def _compile_claude_md( # needed (unlike the progress() calls elsewhere here). self._log( "success", - f"Removed stale {det.rel} -- instructions now live in .claude/rules/", + f"Removed stale {det.rel} -- {orphan_reason}", ) except (OSError, PathTraversalError) as exc: warning = f"Could not remove {det.rel}: {exc!s}" all_warnings.append(warning) self._log("warning", warning) else: - warning = _hand_authored_claude_skip_message(det.rel) + warning = _hand_authored_claude_skip_message( + det.rel, + duplicate_context=skip_instructions, + ) all_warnings.append(warning) self._log("warning", warning) elif distributed_compiler is None and files_written > 0 and not config.dry_run: @@ -1056,7 +1074,7 @@ def _compile_claude_md( if distributed_compiler is not None else None ) - if compilation_results and not (skip_instructions and files_written == 0): + if compilation_results and not would_emit_no_claude_md: # Update target name for CLAUDE.md output formatter_results = CompilationResults( project_analysis=compilation_results.project_analysis, diff --git a/tests/unit/compilation/test_compile_clean_last_primitive_2130.py b/tests/unit/compilation/test_compile_clean_last_primitive_2130.py new file mode 100644 index 000000000..dffabe87a --- /dev/null +++ b/tests/unit/compilation/test_compile_clean_last_primitive_2130.py @@ -0,0 +1,78 @@ +"""Regression test for orphan cleanup after the last primitive is removed.""" + +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from apm_cli.cli import cli + + +def test_clean_removes_claude_md_after_last_primitive_is_removed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """--clean reaches orphan removal when the project has no primitives left.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "apm.yml").write_text( + "name: clean-last-primitive\nversion: 1.0.0\ntargets:\n - claude\n", + encoding="utf-8", + ) + instructions_dir = tmp_path / ".apm" / "instructions" + instructions_dir.mkdir(parents=True) + instruction = instructions_dir / "base.instructions.md" + instruction.write_text( + "---\ndescription: Test instruction\n---\nKeep responses concise.\n", + encoding="utf-8", + ) + + runner = CliRunner() + initial = runner.invoke(cli, ["compile", "--target", "claude"]) + assert initial.exit_code == 0, initial.output + claude_md = tmp_path / "CLAUDE.md" + assert claude_md.is_file() + + instruction.unlink() + cleaned = runner.invoke(cli, ["compile", "--target", "claude", "--clean"]) + + assert cleaned.exit_code == 0, cleaned.output + assert not claude_md.exists() + assert "no source primitives remain" in cleaned.output + assert "produced no output files" not in cleaned.output + + +def test_clean_validate_still_rejects_project_without_primitives( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """--validate keeps requiring project content when combined with --clean.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "apm.yml").write_text( + "name: clean-validate-empty\nversion: 1.0.0\ntargets:\n - claude\n", + encoding="utf-8", + ) + (tmp_path / ".apm" / "instructions").mkdir(parents=True) + + result = CliRunner().invoke(cli, ["compile", "--target", "claude", "--clean", "--validate"]) + + assert result.exit_code == 1 + assert "No instruction files found in .apm/ directory" in result.output + + +def test_clean_preserves_hand_authored_claude_md_without_duplicate_guidance( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Empty-project cleanup preserves user content without irrelevant advice.""" + monkeypatch.chdir(tmp_path) + (tmp_path / "apm.yml").write_text( + "name: clean-hand-authored\nversion: 1.0.0\ntargets:\n - claude\n", + encoding="utf-8", + ) + (tmp_path / ".apm" / "instructions").mkdir(parents=True) + claude_md = tmp_path / "CLAUDE.md" + claude_md.write_text("# User-authored context\n", encoding="utf-8") + + result = CliRunner().invoke(cli, ["compile", "--target", "claude", "--clean"]) + + assert result.exit_code == 0, result.output + assert claude_md.read_text(encoding="utf-8") == "# User-authored context\n" + assert "hand-authored file will not be deleted" in result.output + assert "duplicate context" not in result.output