From 7f42588f956cd5ffc64ce10710a469fd927ac997 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Fri, 10 Jul 2026 20:20:20 +0200 Subject: [PATCH 1/4] fix(compile): clean after last primitive removal Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/apm_cli/commands/compile/cli.py | 21 +++++++++-- src/apm_cli/compilation/agents_compiler.py | 19 +++++----- .../test_compile_clean_last_primitive_2130.py | 37 +++++++++++++++++++ 3 files changed, 65 insertions(+), 12 deletions(-) create mode 100644 tests/unit/compilation/test_compile_clean_last_primitive_2130.py diff --git a/src/apm_cli/commands/compile/cli.py b/src/apm_cli/commands/compile/cli.py index 4977e88fc..f00c96d49 100644 --- a/src/apm_cli/commands/compile/cli.py +++ b/src/apm_cli/commands/compile/cli.py @@ -431,12 +431,19 @@ 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. """ from ...compilation.constitution import find_constitution @@ -458,6 +465,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() @@ -1127,7 +1137,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..f9b6f808a 100644 --- a/src/apm_cli/compilation/agents_compiler.py +++ b/src/apm_cli/compilation/agents_compiler.py @@ -872,11 +872,15 @@ 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 + # both paths share a single, precise emptiness signal. This also covers + # --clean after the final primitive is removed. + 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 +917,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 @@ -1023,7 +1024,7 @@ 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}" 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..d5d9e7029 --- /dev/null +++ b/tests/unit/compilation/test_compile_clean_last_primitive_2130.py @@ -0,0 +1,37 @@ +"""Regression test for orphan cleanup after the last primitive is removed.""" + +from pathlib import Path + +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 +) -> 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() From 959127c0b56337dcab492a2e9de1eca900001152 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Fri, 10 Jul 2026 21:49:12 +0200 Subject: [PATCH 2/4] fix(compile): fold cleanup review findings Correct the zero-primitives progress message, type the pytest fixture, and lock the validate boundary with a mutation-proven regression test. Addresses panel and Copilot follow-ups. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/apm_cli/compilation/agents_compiler.py | 14 +++++++------ .../test_compile_clean_last_primitive_2130.py | 20 ++++++++++++++++++- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/apm_cli/compilation/agents_compiler.py b/src/apm_cli/compilation/agents_compiler.py index f9b6f808a..0d14c1f2c 100644 --- a/src/apm_cli/compilation/agents_compiler.py +++ b/src/apm_cli/compilation/agents_compiler.py @@ -997,12 +997,14 @@ def _compile_claude_md( stats["claude_files_written"] = files_written 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. diff --git a/tests/unit/compilation/test_compile_clean_last_primitive_2130.py b/tests/unit/compilation/test_compile_clean_last_primitive_2130.py index d5d9e7029..5c2df1e99 100644 --- a/tests/unit/compilation/test_compile_clean_last_primitive_2130.py +++ b/tests/unit/compilation/test_compile_clean_last_primitive_2130.py @@ -2,13 +2,14 @@ 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 + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """--clean reaches orphan removal when the project has no primitives left.""" monkeypatch.chdir(tmp_path) @@ -35,3 +36,20 @@ def test_clean_removes_claude_md_after_last_primitive_is_removed( assert cleaned.exit_code == 0, cleaned.output assert not claude_md.exists() + + +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 From d366a8c22ce3e8545b1e150a2012d8b1e5b3100e Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Fri, 10 Jul 2026 22:01:35 +0200 Subject: [PATCH 3/4] fix(compile): align empty-output guards Reuse the broadened empty-output signal for formatter suppression and document the validation/watch caller boundary. Addresses the final Python architecture panel follow-up. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/apm_cli/commands/compile/cli.py | 3 ++- src/apm_cli/compilation/agents_compiler.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/apm_cli/commands/compile/cli.py b/src/apm_cli/commands/compile/cli.py index f00c96d49..83c2b00b1 100644 --- a/src/apm_cli/commands/compile/cli.py +++ b/src/apm_cli/commands/compile/cli.py @@ -443,7 +443,8 @@ def _validate_project( 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. ``allow_empty`` lets - ``compile --clean`` reach the compiler's APM-owned orphan cleanup. + ``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 diff --git a/src/apm_cli/compilation/agents_compiler.py b/src/apm_cli/compilation/agents_compiler.py index 0d14c1f2c..aab54d9bf 100644 --- a/src/apm_cli/compilation/agents_compiler.py +++ b/src/apm_cli/compilation/agents_compiler.py @@ -1059,7 +1059,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, From 077fd204af2df04f50ab7cf727ec552d0fc6ff3e Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Fri, 10 Jul 2026 22:16:57 +0200 Subject: [PATCH 4/4] fix(compile): clarify clean-only completion Suppress the contradictory zero-output warning for expected orphan cleanup, preserve hand-authored files without irrelevant duplicate-context advice, and add mutation-proven message regressions. Addresses the terminal logging and architecture panel findings. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/apm_cli/commands/compile/cli.py | 3 ++ src/apm_cli/compilation/agents_compiler.py | 37 +++++++++++++------ .../test_compile_clean_last_primitive_2130.py | 23 ++++++++++++ 3 files changed, 52 insertions(+), 11 deletions(-) diff --git a/src/apm_cli/commands/compile/cli.py b/src/apm_cli/commands/compile/cli.py index 83c2b00b1..f9e03c640 100644 --- a/src/apm_cli/commands/compile/cli.py +++ b/src/apm_cli/commands/compile/cli.py @@ -755,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; diff --git a/src/apm_cli/compilation/agents_compiler.py b/src/apm_cli/compilation/agents_compiler.py index aab54d9bf..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,9 +876,8 @@ def _compile_claude_md( all_warnings = self.warnings + claude_result.warnings all_errors = self.errors + claude_result.errors - # Used symmetrically in the dry-run preview block and the live-removal block so - # both paths share a single, precise emptiness signal. This also covers - # --clean after the final primitive is removed. + # 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/" @@ -932,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 @@ -995,6 +1004,9 @@ 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: if skip_instructions: @@ -1033,7 +1045,10 @@ def _compile_claude_md( 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: diff --git a/tests/unit/compilation/test_compile_clean_last_primitive_2130.py b/tests/unit/compilation/test_compile_clean_last_primitive_2130.py index 5c2df1e99..dffabe87a 100644 --- a/tests/unit/compilation/test_compile_clean_last_primitive_2130.py +++ b/tests/unit/compilation/test_compile_clean_last_primitive_2130.py @@ -36,6 +36,8 @@ def test_clean_removes_claude_md_after_last_primitive_is_removed( 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( @@ -53,3 +55,24 @@ def test_clean_validate_still_rejects_project_without_primitives( 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