From 784aa776a141af447454006b31f8e43da8008119 Mon Sep 17 00:00:00 2001 From: Travis Illig Date: Wed, 29 Apr 2026 09:50:03 -0700 Subject: [PATCH 01/11] Change log entry for fix. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38b11d843..9ecb2634f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `apm pack` (marketplace producer) now accepts multiple Git URL forms (GitHub, GHES, GitLab, Bitbucket, ADO, SSH) for `type: url` parsing via `DependencyReference.parse()`. Host resolution is still driven by `GITHUB_HOST`, so non-`github.com` hosts require `GITHUB_HOST` to be set accordingly. (#1008) - **ADO Entra ID auth path no longer silently fails.** Bearer tokens from `az account get-access-token` are now correctly plumbed through validation (auth scheme, git env). Auth failures raise a typed `AuthenticationError` with an actionable 4-case diagnostic instead of the ambiguous "not accessible or doesn't exist" message. `apm install --update` runs a pre-flight auth check before modifying any files -- on failure it aborts with "No files were modified". (#1015) - Correct targeting of compiled artifacts so GEMINI.md is only created if requested (#1019) +- `apm compile --targets claude` no longer lists `@apm_modules/{owner}/{package}/CLAUDE.md` dependencies for packages that don't have a CLAUDE.md file on disk (#1047) ## [0.10.0] - 2026-04-27 From ad39924960ac93a2e721d43b2fa9abf8a569e9cf Mon Sep 17 00:00:00 2001 From: Travis Illig Date: Wed, 29 Apr 2026 09:52:27 -0700 Subject: [PATCH 02/11] Test for CLAUDE.md existence before adding it as a dependency. --- src/apm_cli/compilation/claude_formatter.py | 204 ++++++++------- .../unit/compilation/test_claude_formatter.py | 244 +++++++++--------- 2 files changed, 233 insertions(+), 215 deletions(-) diff --git a/src/apm_cli/compilation/claude_formatter.py b/src/apm_cli/compilation/claude_formatter.py index fe78fddac..c36f1ab39 100644 --- a/src/apm_cli/compilation/claude_formatter.py +++ b/src/apm_cli/compilation/claude_formatter.py @@ -56,18 +56,18 @@ class ClaudeCompilationResult: class ClaudeFormatter: """Formatter for generating CLAUDE.md files from APM primitives. - + Generates CLAUDE.md files following Claude's Memory format with: - @import syntax for dependencies - Grouped project standards from instructions - + Note: Agents/workflows are handled separately as .github/agents/ files, not included in CLAUDE.md (same as AGENTS.md behavior). """ - + def __init__(self, base_dir: str = "."): """Initialize the Claude formatter. - + Args: base_dir (str): Base directory for compilation. """ @@ -75,10 +75,10 @@ def __init__(self, base_dir: str = "."): self.base_dir = Path(base_dir).resolve() except (OSError, FileNotFoundError): self.base_dir = Path(base_dir).absolute() - + self.warnings: List[str] = [] self.errors: List[str] = [] - + def format_distributed( self, primitives: PrimitiveCollection, @@ -86,38 +86,38 @@ def format_distributed( config: Optional[dict] = None ) -> ClaudeCompilationResult: """Format primitives into distributed CLAUDE.md files. - + Args: primitives (PrimitiveCollection): Collection of primitives to compile. placement_map (Dict[Path, List[Instruction]]): Directory to instructions mapping. config (Optional[dict]): Configuration options. - + Returns: ClaudeCompilationResult: Result of the CLAUDE.md compilation. """ self.warnings.clear() self.errors.clear() - + try: config = config or {} source_attribution = config.get('source_attribution', True) - + # Generate Claude placements from the placement map placements = self._generate_placements( placement_map, primitives, source_attribution=source_attribution ) - + # Generate content for each placement content_map = {} for placement in placements: content = self._generate_claude_content(placement, primitives) content_map[placement.claude_path] = content - + # Compile statistics stats = self._compile_stats(placements, primitives) - + return ClaudeCompilationResult( success=len(self.errors) == 0, placements=placements, @@ -126,7 +126,7 @@ def format_distributed( errors=self.errors.copy(), stats=stats ) - + except Exception as e: self.errors.append(f"CLAUDE.md formatting failed: {str(e)}") return ClaudeCompilationResult( @@ -137,7 +137,7 @@ def format_distributed( errors=self.errors.copy(), stats={} ) - + def _generate_placements( self, placement_map: Dict[Path, List[Instruction]], @@ -145,17 +145,17 @@ def _generate_placements( source_attribution: bool = True ) -> List[ClaudePlacement]: """Generate CLAUDE.md file placements from the placement map. - + Args: placement_map (Dict[Path, List[Instruction]]): Directory to instructions mapping. primitives (PrimitiveCollection): Full primitive collection. source_attribution (bool): Whether to include source attribution. - + Returns: List[ClaudePlacement]: List of placement results. """ placements = [] - + # Handle empty placement map with constitution if not placement_map: constitution = read_constitution(self.base_dir) @@ -175,26 +175,26 @@ def _generate_placements( # Determine which directories get which agents (chatmodes) # Root directory gets all agents root_agents = list(primitives.chatmodes) - + for dir_path, instructions in placement_map.items(): claude_path = dir_path / "CLAUDE.md" - + # Build source attribution map if enabled source_map = {} if source_attribution: for instruction in instructions: source_info = getattr(instruction, 'source', 'local') source_map[str(instruction.file_path)] = source_info - + # Extract coverage patterns patterns = set() for instruction in instructions: if instruction.apply_to: patterns.add(instruction.apply_to) - + # Root directory gets agents and dependencies is_root = dir_path == self.base_dir - + placement = ClaudePlacement( claude_path=claude_path, instructions=instructions, @@ -203,69 +203,73 @@ def _generate_placements( coverage_patterns=patterns, source_attribution=source_map ) - + placements.append(placement) - + return placements - + def _collect_dependencies(self) -> List[str]: """Collect @import paths for apm_modules dependencies. - + Returns: List[str]: List of @import paths for dependencies. """ dependencies = [] apm_modules_dir = self.base_dir / "apm_modules" - + if not apm_modules_dir.exists(): return dependencies - + # Scan for CLAUDE.md files in apm_modules # Structure: apm_modules/{owner}/{package}/CLAUDE.md for owner_dir in apm_modules_dir.iterdir(): if not owner_dir.is_dir() or owner_dir.name.startswith('.'): continue - + for package_dir in owner_dir.iterdir(): if not package_dir.is_dir() or package_dir.name.startswith('.'): continue - + + claude_md_path = package_dir / "CLAUDE.md" + if not claude_md_path.exists(): + continue + # Build the @import path import_path = f"@apm_modules/{owner_dir.name}/{package_dir.name}/CLAUDE.md" dependencies.append(import_path) - + return sorted(dependencies) - + def _generate_claude_content( self, placement: ClaudePlacement, primitives: PrimitiveCollection ) -> str: """Generate CLAUDE.md content for a specific placement. - + Args: placement (ClaudePlacement): Placement result with instructions. primitives (PrimitiveCollection): Full primitive collection. - + Returns: str: Generated CLAUDE.md content. """ sections = [] - + # Header sections.append("# CLAUDE.md") sections.append(CLAUDE_HEADER) sections.append(BUILD_ID_PLACEHOLDER) sections.append(f"") sections.append("") - + # Dependencies section (only for root CLAUDE.md) if placement.dependencies: sections.append("# Dependencies") for dep in placement.dependencies: sections.append(dep) sections.append("") - + # Constitution section (only for root CLAUDE.md) is_root = placement.claude_path.parent == self.base_dir if is_root: @@ -275,22 +279,22 @@ def _generate_claude_content( sections.append("") sections.append(constitution.strip()) sections.append("") - + # Project Standards section (grouped by pattern) if placement.instructions: sections.append("# Project Standards") sections.append("") - + # Group instructions by pattern pattern_groups: Dict[str, List[Instruction]] = defaultdict(list) for instruction in placement.instructions: if instruction.apply_to: pattern_groups[instruction.apply_to].append(instruction) - + for pattern, pattern_instructions in sorted(pattern_groups.items()): sections.append(f"## Files matching `{pattern}`") sections.append("") - + for instruction in sorted( pattern_instructions, key=lambda i: portable_relpath(i.file_path, self.base_dir), @@ -303,42 +307,42 @@ def _generate_claude_content( str(instruction.file_path), 'local' ) rel_path = portable_relpath(instruction.file_path, self.base_dir) - + sections.append(f"") - + sections.append(content) sections.append("") - + # Note: CLAUDE.md only contains instructions (Project Standards). # Agents/workflows are NOT included - they go to .github/agents/ as separate files. # This matches AGENTS.md behavior which also only contains instructions. - + # Footer sections.append("---") sections.append("*This file was generated by APM CLI. Do not edit manually.*") sections.append("*To regenerate: `apm compile`*") sections.append("") - + return "\n".join(sections) - + def _compile_stats( self, placements: List[ClaudePlacement], primitives: PrimitiveCollection ) -> Dict[str, float]: """Compile statistics about the CLAUDE.md compilation. - + Args: placements (List[ClaudePlacement]): Generated placements. primitives (PrimitiveCollection): Full primitive collection. - + Returns: Dict[str, float]: Compilation statistics. """ total_instructions = sum(len(p.instructions) for p in placements) total_patterns = sum(len(p.coverage_patterns) for p in placements) total_deps = sum(len(p.dependencies) for p in placements) - + return { "claude_files_generated": len(placements), "total_instructions_placed": total_instructions, @@ -346,21 +350,21 @@ def _compile_stats( "total_dependencies": total_deps, "primitives_found": primitives.count(), } - + def generate_commands( self, prompt_files: List[Path], dry_run: bool = False ) -> "CommandGenerationResult": """Generate .claude/commands/ from APM prompt files. - + Transforms .prompt.md files into Claude Code custom slash commands. Each prompt becomes a command file in .claude/commands/{name}.md. - + Args: prompt_files (List[Path]): List of .prompt.md file paths to transform. dry_run (bool): If True, preview without writing files. - + Returns: CommandGenerationResult: Result of the command generation. """ @@ -368,20 +372,20 @@ def generate_commands( generated_commands: Dict[Path, str] = {} warnings: List[str] = [] errors: List[str] = [] - + for prompt_path in prompt_files: try: # Parse the prompt file command_name, content, parse_warnings = self._transform_prompt_to_command(prompt_path) warnings.extend(parse_warnings) - + if content: command_path = commands_dir / f"{command_name}.md" generated_commands[command_path] = content - + except Exception as e: errors.append(f"Failed to transform {prompt_path.name}: {str(e)}") - + # Write files if not dry run files_written = 0 critical_security_found = False @@ -389,7 +393,7 @@ def generate_commands( try: from ..security.gate import WARN_POLICY, SecurityGate commands_dir.mkdir(parents=True, exist_ok=True) - + for command_path, content in generated_commands.items(): # Defense-in-depth: scan compiled command before writing verdict = SecurityGate.scan_text( @@ -405,10 +409,10 @@ def generate_commands( ) command_path.write_text(content, encoding='utf-8') files_written += 1 - + except Exception as e: errors.append(f"Failed to write commands: {str(e)}") - + return CommandGenerationResult( success=len(errors) == 0, commands_generated=generated_commands, @@ -418,24 +422,24 @@ def generate_commands( errors=errors, has_critical_security=critical_security_found, ) - + def _transform_prompt_to_command( self, prompt_path: Path ) -> Tuple[str, str, List[str]]: """Transform a single .prompt.md file into Claude command format. - + Args: prompt_path (Path): Path to the .prompt.md file. - + Returns: Tuple[str, str, List[str]]: (command_name, content, warnings) """ warnings: List[str] = [] - + # Parse the prompt file with frontmatter post = frontmatter.load(prompt_path) - + # Extract command name from filename # e.g., "code-review.prompt.md" -> "code-review" # e.g., "security/audit.prompt.md" -> "audit" (flatten nested paths) @@ -444,65 +448,65 @@ def _transform_prompt_to_command( command_name = filename[:-len('.prompt.md')] else: command_name = prompt_path.stem - + # Build Claude command frontmatter claude_frontmatter = {} - + # Map APM frontmatter to Claude frontmatter # Claude supports: description, allowed-tools, model, argument-hint if 'description' in post.metadata: claude_frontmatter['description'] = post.metadata['description'] - + if 'allowed-tools' in post.metadata: claude_frontmatter['allowed-tools'] = post.metadata['allowed-tools'] elif 'allowedTools' in post.metadata: # Support camelCase variant claude_frontmatter['allowed-tools'] = post.metadata['allowedTools'] - + if 'model' in post.metadata: claude_frontmatter['model'] = post.metadata['model'] - + if 'argument-hint' in post.metadata: claude_frontmatter['argument-hint'] = post.metadata['argument-hint'] elif 'argumentHint' in post.metadata: claude_frontmatter['argument-hint'] = post.metadata['argumentHint'] - + # Get the prompt content content = post.content.strip() - + # Check if content already has $ARGUMENTS or positional args has_arguments_placeholder = bool( re.search(r'\$ARGUMENTS|\$\d+', content) ) - + # Append $ARGUMENTS placeholder if not present if not has_arguments_placeholder: content = content + "\n\n$ARGUMENTS" warnings.append( f"Added $ARGUMENTS placeholder to {prompt_path.name}" ) - + # Build the final command file content command_content = self._build_command_content(claude_frontmatter, content) - + return command_name, command_content, warnings - + def _build_command_content( self, frontmatter_dict: Dict[str, str], content: str ) -> str: """Build the final command file content with frontmatter. - + Args: frontmatter_dict (Dict[str, str]): Frontmatter key-value pairs. content (str): The command content. - + Returns: str: Complete command file content. """ sections = [] - + # Add frontmatter if we have any metadata if frontmatter_dict: sections.append("---") @@ -514,46 +518,46 @@ def _build_command_content( sections.append(f"{key}: {value}") sections.append("---") sections.append("") - + # Add the content sections.append(content) sections.append("") - + return "\n".join(sections) - + def discover_prompt_files(self) -> List[Path]: """Discover all .prompt.md files in the project. - + Searches in standard APM locations: - .apm/prompts/ - .github/prompts/ - apm_modules/*/prompts/ (installed dependencies) - + Returns: List[Path]: List of discovered prompt file paths. """ prompt_files: List[Path] = [] - + # Search in .apm/prompts/ apm_prompts = self.base_dir / ".apm" / "prompts" if apm_prompts.exists(): prompt_files.extend(apm_prompts.rglob("*.prompt.md")) - + # Search in .github/prompts/ github_prompts = self.base_dir / ".github" / "prompts" if github_prompts.exists(): prompt_files.extend(github_prompts.rglob("*.prompt.md")) - + # Search in root directory prompt_files.extend(self.base_dir.glob("*.prompt.md")) - + # Search in apm_modules (installed dependencies) apm_modules = self.base_dir / "apm_modules" if apm_modules.exists(): for package_dir in apm_modules.rglob("prompts"): if package_dir.is_dir(): prompt_files.extend(package_dir.glob("*.prompt.md")) - + # Remove duplicates while preserving order seen = set() unique_files = [] @@ -562,7 +566,7 @@ def discover_prompt_files(self) -> List[Path]: if abs_path not in seen: seen.add(abs_path) unique_files.append(f) - + return unique_files @@ -585,13 +589,13 @@ def format_claude_md( config: Optional[dict] = None ) -> ClaudeCompilationResult: """Convenience function to format CLAUDE.md files. - + Args: primitives (PrimitiveCollection): Collection of primitives. placement_map (Dict[Path, List[Instruction]]): Directory to instructions mapping. base_dir (str): Base directory for compilation. config (Optional[dict]): Configuration options. - + Returns: ClaudeCompilationResult: Result of the CLAUDE.md compilation. """ @@ -605,18 +609,18 @@ def generate_claude_commands( dry_run: bool = False ) -> CommandGenerationResult: """Convenience function to generate .claude/commands/ from prompts. - + Transforms APM .prompt.md files into Claude Code custom slash commands. - + Args: base_dir (str): Base directory for compilation. prompt_files (Optional[List[Path]]): Specific prompt files to transform. If None, discovers prompts automatically. dry_run (bool): If True, preview without writing files. - + Returns: CommandGenerationResult: Result of the command generation. - + Example: >>> result = generate_claude_commands(".", dry_run=True) >>> print(f"Would generate {len(result.commands_generated)} commands") @@ -624,8 +628,8 @@ def generate_claude_commands( ... print(f" /{path.stem}: {len(content)} bytes") """ formatter = ClaudeFormatter(base_dir) - + if prompt_files is None: prompt_files = formatter.discover_prompt_files() - + return formatter.generate_commands(prompt_files, dry_run=dry_run) diff --git a/tests/unit/compilation/test_claude_formatter.py b/tests/unit/compilation/test_claude_formatter.py index 2884cf6f9..baa729319 100644 --- a/tests/unit/compilation/test_claude_formatter.py +++ b/tests/unit/compilation/test_claude_formatter.py @@ -55,7 +55,7 @@ def temp_project(self): def sample_primitives(self, temp_project): """Create sample primitives for testing.""" primitives = PrimitiveCollection() - + # Add instructions instruction1 = Instruction( name="python-style", @@ -66,7 +66,7 @@ def sample_primitives(self, temp_project): author="test", source="local" ) - + instruction2 = Instruction( name="js-style", file_path=temp_project / ".github/instructions/js.instructions.md", @@ -76,22 +76,22 @@ def sample_primitives(self, temp_project): author="test", source="local" ) - + primitives.add_primitive(instruction1) primitives.add_primitive(instruction2) - + return primitives def test_format_generates_header(self, temp_project, sample_primitives): """Test that CLAUDE.md contains correct header format.""" formatter = ClaudeFormatter(str(temp_project)) - + placement_map = {temp_project: list(sample_primitives.instructions)} result = formatter.format_distributed(sample_primitives, placement_map) - + assert result.success assert len(result.content_map) == 1 - + content = result.content_map[temp_project / "CLAUDE.md"] assert "# CLAUDE.md" in content assert CLAUDE_HEADER in content @@ -101,10 +101,10 @@ def test_format_generates_header(self, temp_project, sample_primitives): def test_format_generates_footer(self, temp_project, sample_primitives): """Test that CLAUDE.md contains correct footer.""" formatter = ClaudeFormatter(str(temp_project)) - + placement_map = {temp_project: list(sample_primitives.instructions)} result = formatter.format_distributed(sample_primitives, placement_map) - + content = result.content_map[temp_project / "CLAUDE.md"] assert "*This file was generated by APM CLI. Do not edit manually.*" in content assert "*To regenerate: `apm compile`*" in content @@ -112,12 +112,12 @@ def test_format_generates_footer(self, temp_project, sample_primitives): def test_format_groups_by_apply_to_patterns(self, temp_project, sample_primitives): """Test that instructions are grouped by applyTo patterns.""" formatter = ClaudeFormatter(str(temp_project)) - + placement_map = {temp_project: list(sample_primitives.instructions)} result = formatter.format_distributed(sample_primitives, placement_map) - + content = result.content_map[temp_project / "CLAUDE.md"] - + # Check pattern grouping assert "## Files matching `**/*.py`" in content assert "## Files matching `**/*.js`" in content @@ -127,32 +127,32 @@ def test_format_groups_by_apply_to_patterns(self, temp_project, sample_primitive def test_format_includes_project_standards_section(self, temp_project, sample_primitives): """Test that Project Standards section is generated.""" formatter = ClaudeFormatter(str(temp_project)) - + placement_map = {temp_project: list(sample_primitives.instructions)} result = formatter.format_distributed(sample_primitives, placement_map) - + content = result.content_map[temp_project / "CLAUDE.md"] assert "# Project Standards" in content def test_format_includes_source_attribution(self, temp_project, sample_primitives): """Test that source attribution comments are included.""" formatter = ClaudeFormatter(str(temp_project)) - + placement_map = {temp_project: list(sample_primitives.instructions)} config = {'source_attribution': True} result = formatter.format_distributed(sample_primitives, placement_map, config) - + content = result.content_map[temp_project / "CLAUDE.md"] assert "`) so users can self-serve recovery. **Migration:** to adopt APM management of an existing file, either delete or rename it and re-run `apm compile`, or prepend the marker line to the top of the file and re-run `apm compile`. (#1048) - Generated footer in `.github/copilot-instructions.md` now reads `apm compile` (was `specify apm compile`). (#1048) - `apm compile --targets claude` no longer lists `@apm_modules/{owner}/{package}/CLAUDE.md` dependencies for packages that don't have a `CLAUDE.md` file on disk (#1047) diff --git a/README.md b/README.md index cb1f6d94c..d84b8bb82 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,14 @@ apm install vercel-labs/agent-skills --skill deploy-to-vercel # one skill, per Same install gesture. You also get a [manifest, lockfile, and reproducibility](https://microsoft.github.io/apm/reference/package-types/#skill-collection-skillsnameskillmd). +**Zero-config Copilot:** + +```bash +apm compile -t copilot # writes .github/copilot-instructions.md +``` + +One command, no configuration -- VS Code and GitHub Copilot read the file automatically. APM dogfoods this target on its own repository. + ## The three promises ### 1. Portable by manifest diff --git a/docs/src/content/docs/getting-started/quick-start.md b/docs/src/content/docs/getting-started/quick-start.md index 7b772d040..ca0c780f1 100644 --- a/docs/src/content/docs/getting-started/quick-start.md +++ b/docs/src/content/docs/getting-started/quick-start.md @@ -113,6 +113,16 @@ dependencies: - microsoft/apm-sample-package#v1.0.0 ``` +## Get Copilot reading your packages in under a minute + +Run one more command: + +```bash +apm compile -t copilot +``` + +APM assembles every global instruction it just installed into `.github/copilot-instructions.md` -- the file VS Code and GitHub Copilot read automatically. No configuration, no extra setup; open the project in VS Code and Copilot is already grounded in your packages' standards. + ## That's it Open your editor. GitHub Copilot, Claude, Cursor, and OpenCode pick up the new context immediately -- no extra configuration, no compile step, no restart. The agent now knows your project's design standards, can run your prompt templates, and follows the conventions defined in the package. diff --git a/docs/src/content/docs/reference/cli-commands.md b/docs/src/content/docs/reference/cli-commands.md index 602051ed7..9cff933e1 100644 --- a/docs/src/content/docs/reference/cli-commands.md +++ b/docs/src/content/docs/reference/cli-commands.md @@ -1768,7 +1768,7 @@ apm compile --no-constitution Compiled output is scanned for hidden Unicode characters before writing to disk. Critical findings cause `apm compile` to exit with code 1 — defense-in-depth since source files are already scanned during `apm install`. **`.github/copilot-instructions.md` generation:** -When the resolved target is `copilot` (alias `vscode`), `all`, or any multi-target list containing `copilot`, `apm compile` assembles all *global* instructions (entries in `.apm/instructions/` without an `apply_to` field) into `.github/copilot-instructions.md` -- the file VS Code and GitHub Copilot read automatically with zero user configuration. Generated content is wrapped with an APM-only marker. Switching to a non-Copilot target (e.g. `apm compile -t claude`) cleans up the file only when the marker is present; a hand-authored `.github/copilot-instructions.md` is left untouched on both write and cleanup paths. To adopt APM management of an existing hand-authored file, delete it and re-run `apm compile`, or prepend the APM marker line manually. +When the resolved target is `copilot` (alias `vscode`), `all`, or any multi-target list containing `copilot`, `apm compile` assembles all *global* instructions (entries in `.apm/instructions/` without an `apply_to` field) into `.github/copilot-instructions.md` -- the file VS Code and GitHub Copilot read automatically with zero user configuration. Generated content is wrapped with an APM-only marker (literal first line: ``). Switching to a non-Copilot target (e.g. `apm compile -t claude`) cleans up the file only when the marker is present; a hand-authored `.github/copilot-instructions.md` is left untouched on both write and cleanup paths. To adopt APM management of an existing hand-authored file, delete (or rename) it and re-run `apm compile`, or prepend the marker line `` to the top of the file and re-run `apm compile`. **Configuration Integration:** The compile command supports configuration via `apm.yml`: diff --git a/src/apm_cli/compilation/agents_compiler.py b/src/apm_cli/compilation/agents_compiler.py index d2fcf7e34..e8ee41700 100644 --- a/src/apm_cli/compilation/agents_compiler.py +++ b/src/apm_cli/compilation/agents_compiler.py @@ -922,10 +922,45 @@ def _maybe_emit_copilot_root_instructions( result.stats["copilot_root_instructions_generated"] = 1 result.stats.setdefault("copilot_root_instructions_skipped", 0) result.stats.setdefault("copilot_root_instructions_removed", 0) + result.stats.setdefault("copilot_root_instructions_written", 0) + result.stats.setdefault("copilot_root_instructions_unchanged", 0) + + # Inspect any existing file BEFORE the dry-run early-exit so that + # `--dry-run` faithfully reports what a real run would do (skip vs + # write vs unchanged). Reading the file here is safe in dry-run mode + # because we never mutate it. + try: + existing = output_path.read_text(encoding="utf-8") if output_path.exists() else None + except OSError as exc: + message = f"Failed to read {output_path}: {exc}" + self.errors.append(message) + result.errors.append(message) + result.success = False + return result + + if existing is not None and _COPILOT_ROOT_GENERATED_MARKER not in existing: + rel_path = portable_relpath(output_path, self.base_dir) + result.warnings.append( + f"Skipped {rel_path}: hand-authored file will not be overwritten. " + "To regenerate, either delete or rename it, or prepend the line " + f"'{_COPILOT_ROOT_GENERATED_MARKER}' to the top of the file. " + "Then re-run 'apm compile'." + ) + # The file was never compared to new content; record as + # 'skipped', not 'unchanged'. Also reset 'generated' since no + # output was actually emitted (or would be, on a real run). + result.stats["copilot_root_instructions_generated"] = 0 + result.stats["copilot_root_instructions_written"] = 0 + result.stats["copilot_root_instructions_skipped"] = 1 + result.stats["copilot_root_instructions_unchanged"] = 0 + return result + + if existing == content: + result.stats["copilot_root_instructions_written"] = 0 + result.stats["copilot_root_instructions_unchanged"] = 1 + return result if config.dry_run: - result.stats.setdefault("copilot_root_instructions_written", 0) - result.stats.setdefault("copilot_root_instructions_unchanged", 0) return result from ..security.gate import WARN_POLICY, SecurityGate @@ -942,27 +977,6 @@ def _maybe_emit_copilot_root_instructions( try: output_path.parent.mkdir(parents=True, exist_ok=True) - existing = output_path.read_text(encoding="utf-8") if output_path.exists() else None - if existing is not None and _COPILOT_ROOT_GENERATED_MARKER not in existing: - rel_path = portable_relpath(output_path, self.base_dir) - result.warnings.append( - f"Skipped {rel_path}: file exists without an APM marker and " - "will not be overwritten. Remove or rename it, then re-run " - "'apm compile' to regenerate." - ) - # The file was never compared to new content; record as - # 'skipped', not 'unchanged'. Also reset 'generated' since no - # output was actually emitted. - result.stats["copilot_root_instructions_generated"] = 0 - result.stats["copilot_root_instructions_written"] = 0 - result.stats["copilot_root_instructions_skipped"] = 1 - result.stats.setdefault("copilot_root_instructions_unchanged", 0) - return result - if existing == content: - result.stats["copilot_root_instructions_written"] = 0 - result.stats["copilot_root_instructions_unchanged"] = 1 - return result - output_path.write_text(content, encoding="utf-8") result.stats["copilot_root_instructions_written"] = 1 result.stats["copilot_root_instructions_unchanged"] = 0 diff --git a/tests/unit/compilation/test_compile_target_flag.py b/tests/unit/compilation/test_compile_target_flag.py index 8d79eed8d..c3525d2d1 100644 --- a/tests/unit/compilation/test_compile_target_flag.py +++ b/tests/unit/compilation/test_compile_target_flag.py @@ -414,7 +414,10 @@ def test_write_path_preserves_unmanaged_manual_copilot_root_instructions(self, t assert result.success assert root_file.read_text(encoding="utf-8") == manual_content assert result.stats["copilot_root_instructions_written"] == 0 - assert any("APM marker" in w for w in result.warnings) + assert any("hand-authored file will not be overwritten" in w for w in result.warnings) + assert any( + "" in w for w in result.warnings + ), "warning must disclose the literal marker string for self-service recovery" def test_multi_target_claude_copilot_emits_copilot_root_instructions(self, temp_project): """`apm compile -t claude,copilot` (frozenset path) must emit copilot-instructions.md. @@ -599,7 +602,7 @@ def test_skip_warning_uses_relative_path_and_concrete_action(self, temp_project) ) skip_warnings = [ - w for w in result.warnings if "without an APM marker" in w or "APM marker" in w + w for w in result.warnings if "hand-authored file will not be overwritten" in w ] assert skip_warnings, "expected a skip warning to be emitted" warning = skip_warnings[0] @@ -608,7 +611,55 @@ def test_skip_warning_uses_relative_path_and_concrete_action(self, temp_project) "warning must not embed the absolute project path (use a relative path)" ) assert "apm compile" in warning, "warning must tell the user the next command to run" - assert "Remove or rename" in warning, "warning must give a concrete action" + assert "delete or rename" in warning, "warning must give a concrete action" + assert "" in warning, ( + "warning must disclose the literal marker string so users can self-serve recovery" + ) + + def test_dry_run_respects_hand_authored_guard(self, temp_project): + """Round-4 regression: `--dry-run` must mirror the hand-authored guard + the real run applies, not silently report `generated=1` for a file the + real run would skip. CI scripts that use `--dry-run` as a preview gate + depend on the simulation being faithful.""" + root_file = temp_project / ".github" / "copilot-instructions.md" + root_file.parent.mkdir(parents=True) + manual_content = "# Hand-authored\n\nNo APM marker present.\n" + root_file.write_text(manual_content, encoding="utf-8") + + primitives = PrimitiveCollection() + primitives.add_primitive( + Instruction( + name="contributing", + file_path=temp_project / ".apm/instructions/contributing.instructions.md", + description="General contributing guidance", + apply_to="", + content="# Contributing\n\nRun focused tests first.", + author="test", + source="local", + ) + ) + + compiler = AgentsCompiler(str(temp_project)) + result = compiler.compile( + CompilationConfig(target="vscode", dry_run=True, single_agents=True), + primitives, + ) + + assert result.success + # Dry-run must not mutate the file. + assert root_file.read_text(encoding="utf-8") == manual_content + # Stats must mirror what a real run would record. + assert result.stats["copilot_root_instructions_generated"] == 0, ( + "dry-run must not claim generation when the real run would skip" + ) + assert result.stats["copilot_root_instructions_written"] == 0 + assert result.stats["copilot_root_instructions_skipped"] == 1, ( + "dry-run must surface the skip the real run would record" + ) + assert result.stats["copilot_root_instructions_unchanged"] == 0 + assert any( + "hand-authored file will not be overwritten" in w for w in result.warnings + ), "dry-run must surface the same warning a real run would emit" def test_generated_footer_uses_apm_compile_not_specify(self, temp_project): """Generated footer must read `apm compile`, not `specify apm compile`."""