Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions src/apm_cli/commands/compile/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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()
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
68 changes: 43 additions & 25 deletions src/apm_cli/compilation/agents_compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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:
Comment thread
Copilot marked this conversation as resolved.
"""--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
Loading