-
Notifications
You must be signed in to change notification settings - Fork 151
fix(compile): emit and clean up copilot root instructions #930
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ | |
| primitives & constitution are unchanged. | ||
| """ | ||
|
|
||
| import hashlib | ||
| from dataclasses import dataclass | ||
| from pathlib import Path | ||
| from typing import List, Optional, Dict, Any | ||
|
|
@@ -20,7 +21,13 @@ | |
| ) | ||
| from .link_resolver import resolve_markdown_links, validate_link_targets | ||
| from ..utils.paths import portable_relpath | ||
| from ..core.target_detection import should_compile_agents_md, should_compile_claude_md, should_compile_gemini_md | ||
| from ..core.target_detection import ( | ||
| should_compile_agents_md, | ||
| should_compile_claude_md, | ||
| should_compile_copilot_instructions_md, | ||
| should_compile_gemini_md, | ||
| ) | ||
| from .constants import BUILD_ID_PLACEHOLDER | ||
|
|
||
|
|
||
| # User-facing target aliases that map to the canonical "vscode" target. | ||
|
|
@@ -29,6 +36,7 @@ | |
| _KNOWN_TARGETS = ( | ||
| "vscode", "claude", "cursor", "opencode", "codex", "gemini", "all", "minimal", | ||
| ) + _VSCODE_TARGET_ALIASES | ||
| _COPILOT_ROOT_GENERATED_MARKER = "<!-- Generated by APM CLI from .apm/ primitives -->" | ||
|
|
||
|
|
||
| @dataclass | ||
|
|
@@ -279,10 +287,12 @@ def _compile_agents_md(self, config: CompilationConfig, primitives: PrimitiveCol | |
| """ | ||
| # Handle distributed compilation (Task 7 - new default behavior) | ||
| if config.strategy == "distributed" and not config.single_agents: | ||
| return self._compile_distributed(config, primitives) | ||
| result = self._compile_distributed(config, primitives) | ||
| else: | ||
| # Traditional single-file compilation (backward compatibility) | ||
| return self._compile_single_file(config, primitives) | ||
| result = self._compile_single_file(config, primitives) | ||
|
|
||
| return self._maybe_emit_copilot_root_instructions(config, primitives, result) | ||
|
|
||
| def _compile_distributed(self, config: CompilationConfig, primitives: PrimitiveCollection) -> CompilationResult: | ||
| """Compile using distributed AGENTS.md approach (Task 7). | ||
|
|
@@ -801,6 +811,152 @@ def _generate_template_data(self, primitives: PrimitiveCollection, config: Compi | |
| version=version, | ||
| chatmode_content=chatmode_content | ||
| ) | ||
|
|
||
| def _maybe_emit_copilot_root_instructions( | ||
| self, | ||
| config: CompilationConfig, | ||
| primitives: PrimitiveCollection, | ||
| result: CompilationResult, | ||
| ) -> CompilationResult: | ||
| """Generate .github/copilot-instructions.md for Copilot-capable targets.""" | ||
| routing_target = "vscode" if config.target in _VSCODE_TARGET_ALIASES else config.target | ||
| output_path = self.base_dir / ".github" / "copilot-instructions.md" | ||
| if not should_compile_copilot_instructions_md(routing_target): | ||
| if not config.dry_run: | ||
| self._cleanup_copilot_root_instructions(output_path, result) | ||
| result.stats.setdefault("copilot_root_instructions_generated", 0) | ||
| result.stats.setdefault("copilot_root_instructions_written", 0) | ||
| result.stats.setdefault("copilot_root_instructions_unchanged", 0) | ||
| result.stats.setdefault("copilot_root_instructions_removed", 0) | ||
| return result | ||
|
|
||
| global_instructions = sorted( | ||
| [instruction for instruction in primitives.instructions if not instruction.apply_to], | ||
| key=lambda instruction: portable_relpath(instruction.file_path, self.base_dir), | ||
| ) | ||
| if not global_instructions: | ||
| if not config.dry_run: | ||
| self._cleanup_copilot_root_instructions(output_path, result) | ||
| result.stats.setdefault("copilot_root_instructions_generated", 0) | ||
| result.stats.setdefault("copilot_root_instructions_written", 0) | ||
| result.stats.setdefault("copilot_root_instructions_unchanged", 0) | ||
| result.stats.setdefault("copilot_root_instructions_removed", 0) | ||
| return result | ||
|
|
||
| content = self._generate_copilot_root_instructions_content(global_instructions, config) | ||
|
|
||
| result.stats["copilot_root_instructions_generated"] = 1 | ||
| result.stats.setdefault("copilot_root_instructions_removed", 0) | ||
|
|
||
| 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 | ||
|
|
||
| verdict = SecurityGate.scan_text( | ||
| content, str(output_path), policy=WARN_POLICY | ||
| ) | ||
| actionable = verdict.critical_count + verdict.warning_count | ||
| if actionable: | ||
| if verdict.has_critical: | ||
| result.has_critical_security = True | ||
| result.warnings.append( | ||
| f"copilot-instructions.md contains {actionable} hidden character(s) " | ||
| f"-- run 'apm audit --file {output_path}' to inspect" | ||
| ) | ||
|
|
||
| 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 == 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 | ||
|
Comment on lines
+875
to
+879
|
||
| result.stats["copilot_root_instructions_unchanged"] = 0 | ||
| return result | ||
| except OSError as exc: | ||
| message = f"Failed to write {output_path}: {exc}" | ||
| self.errors.append(message) | ||
| result.errors.append(message) | ||
| result.success = False | ||
| result.stats["copilot_root_instructions_written"] = 0 | ||
| result.stats.setdefault("copilot_root_instructions_unchanged", 0) | ||
| return result | ||
|
|
||
| def _generate_copilot_root_instructions_content( | ||
| self, | ||
| instructions, | ||
| config: CompilationConfig, | ||
| ) -> str: | ||
|
Comment on lines
+891
to
+895
|
||
| """Generate root Copilot instructions content from global instruction primitives.""" | ||
| sections = [ | ||
| _COPILOT_ROOT_GENERATED_MARKER, | ||
| BUILD_ID_PLACEHOLDER, | ||
| f"<!-- APM Version: {get_version()} -->", | ||
| "", | ||
| ] | ||
|
|
||
| for instruction in instructions: | ||
| rel_path = portable_relpath(instruction.file_path, self.base_dir) | ||
| sections.append(f"<!-- Source: {rel_path} -->") | ||
| sections.append(instruction.content.strip()) | ||
| sections.append(f"<!-- End source: {rel_path} -->") | ||
| sections.append("") | ||
|
|
||
| sections.append("---") | ||
| sections.append("*This file was generated by APM CLI. Do not edit manually.*") | ||
| sections.append("*To regenerate: `specify apm compile`*") | ||
| sections.append("") | ||
|
|
||
| content = "\n".join(sections) | ||
| if config.resolve_links: | ||
| content = resolve_markdown_links(content, self.base_dir) | ||
| return self._finalize_build_id(content) | ||
|
|
||
| def _finalize_build_id(self, content: str) -> str: | ||
| """Replace the build-id placeholder with a deterministic content hash.""" | ||
| lines = content.splitlines() | ||
| try: | ||
| idx = lines.index(BUILD_ID_PLACEHOLDER) | ||
| except ValueError: | ||
| return content | ||
|
|
||
| hash_input_lines = [line for i, line in enumerate(lines) if i != idx] | ||
| build_id = hashlib.sha256("\n".join(hash_input_lines).encode("utf-8")).hexdigest()[:12] | ||
| lines[idx] = f"<!-- Build ID: {build_id} -->" | ||
| return "\n".join(lines) + ("\n" if content.endswith("\n") else "") | ||
|
|
||
| def _cleanup_copilot_root_instructions( | ||
| self, | ||
| output_path: Path, | ||
| result: CompilationResult, | ||
| ) -> CompilationResult: | ||
| """Remove stale generated Copilot root instructions when no longer applicable.""" | ||
| if not output_path.exists(): | ||
| result.stats.setdefault("copilot_root_instructions_removed", 0) | ||
| return result | ||
|
|
||
| try: | ||
| existing = output_path.read_text(encoding="utf-8") | ||
| if _COPILOT_ROOT_GENERATED_MARKER not in existing: | ||
| result.stats.setdefault("copilot_root_instructions_removed", 0) | ||
| return result | ||
|
|
||
| output_path.unlink() | ||
| result.stats["copilot_root_instructions_removed"] = 1 | ||
| return result | ||
| except OSError as exc: | ||
| message = f"Failed to remove stale {output_path}: {exc}" | ||
| self.errors.append(message) | ||
| result.errors.append(message) | ||
| result.success = False | ||
| result.stats.setdefault("copilot_root_instructions_removed", 0) | ||
| return result | ||
|
|
||
| def _write_output_file(self, output_path: str, content: str) -> None: | ||
| """Write the generated content to the output file. | ||
|
|
@@ -999,4 +1155,4 @@ def compile_agents_md( | |
| if not result.success: | ||
| raise RuntimeError(f"Compilation failed: {'; '.join(result.errors)}") | ||
|
|
||
| return result.content | ||
| return result.content | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -162,6 +162,18 @@ def should_compile_gemini_md(target: TargetType) -> bool: | |
| return target in ("gemini", "all") | ||
|
|
||
|
|
||
| def should_compile_copilot_instructions_md(target: TargetType) -> bool: | ||
| """Check if .github/copilot-instructions.md should be compiled. | ||
|
|
||
| Args: | ||
| target: The detected or configured target | ||
|
Comment on lines
+165
to
+169
|
||
|
|
||
| Returns: | ||
| bool: True if Copilot root instructions should be generated | ||
| """ | ||
| return target in ("vscode", "all") | ||
|
|
||
|
|
||
| def get_target_description(target: UserTargetType) -> str: | ||
| """Get a human-readable description of what will be generated for a target. | ||
|
|
||
|
|
@@ -176,14 +188,14 @@ def get_target_description(target: UserTargetType) -> str: | |
| # Normalize aliases to internal value for lookup | ||
| normalized = "vscode" if target in ("copilot", "agents") else target | ||
| descriptions = { | ||
| "vscode": "AGENTS.md + .github/prompts/ + .github/agents/", | ||
| "vscode": "AGENTS.md + .github/copilot-instructions.md + .github/prompts/ + .github/agents/", | ||
| "claude": "CLAUDE.md + .claude/commands/ + .claude/agents/ + .claude/skills/", | ||
|
Comment on lines
190
to
192
|
||
| "cursor": ".cursor/agents/ + .cursor/skills/ + .cursor/rules/", | ||
| "opencode": "AGENTS.md + .opencode/agents/ + .opencode/commands/ + .opencode/skills/", | ||
| "codex": "AGENTS.md + .agents/skills/ + .codex/agents/ + .codex/hooks.json", | ||
| "gemini": "GEMINI.md + .gemini/commands/ + .gemini/skills/ + .gemini/settings.json (MCP/hooks)", | ||
| "all": "AGENTS.md + CLAUDE.md + GEMINI.md + .github/ + .claude/ + .cursor/ + .opencode/ + .codex/ + .gemini/ + .agents/", | ||
| "minimal": "AGENTS.md only (create a target folder for full integration)", | ||
| "all": "AGENTS.md + CLAUDE.md + GEMINI.md + .github/copilot-instructions.md + .github/ + .claude/ + .cursor/ + .opencode/ + .codex/ + .gemini/ + .agents/", | ||
| "minimal": "AGENTS.md only (create .github/, .claude/, or .gemini/ for full integration)", | ||
| } | ||
| return descriptions.get(normalized, "unknown target") | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import subprocess | ||
| import sys | ||
| from pathlib import Path | ||
|
|
||
|
|
||
| CLI = [sys.executable, "-m", "apm_cli.cli", "compile", "--target", "copilot", "--single-agents"] | ||
|
|
||
|
|
||
| def run_cli(cwd: Path) -> subprocess.CompletedProcess: | ||
| return subprocess.run(CLI, cwd=str(cwd), capture_output=True, text=True) | ||
|
|
||
|
|
||
| def test_compile_emits_copilot_root_instructions_and_is_idempotent(tmp_path: Path): | ||
| (tmp_path / "apm.yml").write_text("name: test-project\nversion: 0.1.0\n", encoding="utf-8") | ||
| instructions_dir = tmp_path / ".apm" / "instructions" | ||
| instructions_dir.mkdir(parents=True) | ||
| (instructions_dir / "contributing.instructions.md").write_text( | ||
| "---\ndescription: Contributing guide\n---\n\n# Contributing\n\nRun focused tests first.\n", | ||
| encoding="utf-8", | ||
| ) | ||
|
|
||
| first = run_cli(tmp_path) | ||
| assert first.returncode == 0, first.stderr or first.stdout | ||
|
|
||
| copilot_root = tmp_path / ".github" / "copilot-instructions.md" | ||
| assert copilot_root.exists() | ||
| first_content = copilot_root.read_text(encoding="utf-8") | ||
| assert "<!-- Build ID: " in first_content | ||
| assert "# Contributing" in first_content | ||
| assert "Run focused tests first." in first_content | ||
|
|
||
| second = run_cli(tmp_path) | ||
| assert second.returncode == 0, second.stderr or second.stdout | ||
| second_content = copilot_root.read_text(encoding="utf-8") | ||
|
|
||
| assert first_content == second_content | ||
|
|
||
|
|
||
| def test_compile_removes_stale_root_file_when_only_scoped_rules_remain(tmp_path: Path): | ||
| (tmp_path / "apm.yml").write_text("name: test-project\nversion: 0.1.0\n", encoding="utf-8") | ||
| instructions_dir = tmp_path / ".apm" / "instructions" | ||
| instructions_dir.mkdir(parents=True) | ||
| instruction_file = instructions_dir / "contributing.instructions.md" | ||
|
|
||
| instruction_file.write_text( | ||
| "---\ndescription: Contributing guide\n---\n\n# Contributing\n\nRun focused tests first.\n", | ||
| encoding="utf-8", | ||
| ) | ||
| first = run_cli(tmp_path) | ||
| assert first.returncode == 0, first.stderr or first.stdout | ||
|
|
||
| copilot_root = tmp_path / ".github" / "copilot-instructions.md" | ||
| assert copilot_root.exists() | ||
|
|
||
| instruction_file.write_text( | ||
| "---\napplyTo: \"**/*.py\"\ndescription: Python guide\n---\n\nUse type hints.\n", | ||
| encoding="utf-8", | ||
| ) | ||
| second = run_cli(tmp_path) | ||
| assert second.returncode == 0, second.stderr or second.stdout | ||
|
|
||
| assert not copilot_root.exists() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Now that
effective_targetcan remainminimal, make sure every subsequent compile pass in this command uses the same target. In the legacy--single-agentspath, an intermediateCompilationConfig(...)is constructed withouttarget=...(so it defaults toall), which can unexpectedly route through CLAUDE compilation and potentially alter what gets injected/written to AGENTS.md. Propagatingeffective_target(or avoiding the second compile) would keep minimal-mode semantics intact.