diff --git a/.apm/instructions/linting.instructions.md b/.apm/instructions/linting.instructions.md new file mode 100644 index 000000000..9654360c5 --- /dev/null +++ b/.apm/instructions/linting.instructions.md @@ -0,0 +1,46 @@ +--- +description: "Lint contract: run BEFORE pushing or producing artifacts that claim green CI. Mirrors the CI Lint job." +--- + +# Linting (canonical contract) + +The CI `Lint` job is a hard gate. Mirror it locally before `git push` +and before producing any artifact (PR body, release note, audit +report) that claims CI is green. + +## CI-mirror commands + +The `Lint` job runs: + +- `uv run --extra dev ruff check src/ tests/` +- `uv run --extra dev ruff format --check src/ tests/` + +Both must be silent. + +## Local workflow + +- **Auto-fix style+imports:** `uv run --extra dev ruff check src/ tests/ --fix` +- **Apply formatter:** `uv run --extra dev ruff format src/ tests/` +- **Verify (must be silent):** `uv run --extra dev ruff check src/ tests/ && uv run --extra dev ruff format --check src/ tests/` + +Always run the verify pair before `git push` -- the CI Lint job +fails on any remaining diagnostic. + +## Common surprises + +- `RUF043` -- use `match=r"..."` for `pytest.raises` patterns with + regex metacharacters (`(`, `)`, `[`, etc.). +- `UP006` / `UP045` -- use `list` / `dict` / `X | None` instead of + `List` / `Dict` / `Optional`. +- `RUF100` -- drop stale `# noqa` directives. +- `F401` / `F841` -- remove unused imports / unused locals. +- `SIM103` -- inline negated returns where the body is one line. +- `I001` -- import sort order (auto-fixable). + +## Lifecycle binding + +This is the canonical lint contract for the repo. Skills that +produce artifacts asserting green CI -- notably `pr-description-skill` +(whose "Validation evidence" row covers CI checks) -- inherit this +gate transitively. Do NOT redefine ruff commands inside individual +skills; honor this instruction before invoking them. diff --git a/.apm/skills/pr-description-skill/SKILL.md b/.apm/skills/pr-description-skill/SKILL.md index 2ab98c900..8426aeabf 100644 --- a/.apm/skills/pr-description-skill/SKILL.md +++ b/.apm/skills/pr-description-skill/SKILL.md @@ -216,8 +216,7 @@ Run these steps in order. Tick each before moving on. 1. [ ] Confirm every row of the activation contract is filled in. Defense-in-depth gate: before drafting the body, confirm the repo's lint contract is green (canonical commands and lifecycle - binding live in the project's `copilot-instructions.md` Linting - block - do NOT inline or restate them here). If lint is red, + binding live in `.apm/instructions/linting.instructions.md`). If lint is red, STOP, fix, re-run; a PR body claiming green CI while lint fails is a credibility tax we refuse to take on. 2. [ ] Read the diff in full. Identify per-file change summary, diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4fc0a9802..8b37a0200 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,33 +1,52 @@ -- This project uses uv to manage Python environments and dependencies. - - Use `uv sync` to create the virtual environment and install all dependencies automatically. - - Use `uv run ` to run commands in the uv-managed environment. - - For development dependencies, use `uv sync --extra dev`. -- **Running tests**: Use pytest via `uv run`. Prefer targeted test runs during development: - - **Targeted (fastest, use during iteration):** `uv run pytest tests/unit/path/to/relevant_test.py -x` - - **Unit suite (default validation):** `uv run pytest tests/unit tests/test_console.py -x` (~2,400 tests, matches CI) - - **Full suite (only before final commit):** `uv run pytest` - - When modifying a specific module, run only its corresponding test file(s) first. Run the full unit suite once as final validation before considering your work done. -- **Test coverage principle**: When modifying existing code, add tests for the code paths you touch, on top of tests for the new functionality. -- **Linting (run BEFORE pushing - CI gate fails otherwise)**: The `Lint` job runs `uv run --extra dev ruff check src/ tests/` AND `uv run --extra dev ruff format --check src/ tests/`. Mirror it locally: - - **Auto-fix style+imports:** `uv run --extra dev ruff check src/ tests/ --fix` - - **Apply formatter:** `uv run --extra dev ruff format src/ tests/` - - **Verify (must be silent):** `uv run --extra dev ruff check src/ tests/ && uv run --extra dev ruff format --check src/ tests/` - - Always run the verify pair before `git push` -- the CI Lint job fails on any remaining diagnostic. Common surprises: `RUF043` (use `match=r"..."` for regex with metacharacters), `UP006/UP045` (use `list`/`dict`/`X | None` instead of `List`/`Dict`/`Optional`), `RUF100` (drop stale `# noqa`), `F401`/`F841` (unused import / unused local). - - **Lifecycle binding**: this rule is the canonical lint contract for the repo. Any skill that produces an artifact claiming green CI -- notably `pr-description-skill` (whose "Validation evidence" row covers CI checks) -- inherits this gate transitively. Do NOT redefine ruff commands inside individual skills; honor this rule before invoking them. -- **Development Workflow**: To run APM from source while working in other directories: - - Install in development mode: `cd /path/to/awd-cli && uv run pip install -e .` - - Use absolute path: `/Users/danielmeppiel/Repos/awd-cli/.venv/bin/apm compile --verbose --dry-run` - - Or create alias: `alias apm-dev='/Users/danielmeppiel/Repos/awd-cli/.venv/bin/apm'` - - Changes to source code are immediately reflected (no reinstall needed) -- The solution must meet the functionality as explained in the [README.md](README.md) file. -- The general high-level basis to the solution is depicted in [APPROACH.md](../../APPROACH.md). -- When developing functionality, we need to respect our own [CONTRIBUTING.md](../../CONTRIBUTING.md) file. -The architectural decisions and basis for the project in that document are only the inspiring foundation. It can and should always be challenged when needed and is not meant as the only truth, but a very useful context and grounding research. -- The project is meant for the Open Source community and should be open to contributions and follow the standards of the community. -- The project is meant to be used by developers and should be easy to use, with a focus on developer experience. -- The philosophy when architecting and implementing the project is to prime speed and simplicity over complexity. Do NOT over-engineer, but rather build a solid foundation that can be iterated on. -- APM is an active OSS project under the `microsoft` org with a growing community (250+ stars, external contributors). Breaking changes should be communicated clearly (CHANGELOG.md), but we still favor shipping fast over lengthy deprecation cycles. -- The goal is to deliver a solid and scalable architecture but simple starting implementation. Not building something complex from the start and then having to simplify it later. Remember we are delivering a new tool to the developer community and we will need to rapidly adapt to what's really useful, evolving standards, etc. -- **Cross-platform encoding rule**: All source code and CLI output must stay within printable ASCII (U+0020–U+007E). Do NOT use emojis, Unicode symbols, box-drawing characters, em dashes, or any character outside the ASCII range in source files or CLI output strings. Use bracket notation for status symbols: `[+]` success, `[!]` warning, `[x]` error, `[i]` info, `[*]` action, `[>]` running. This is required to prevent `charmap` codec errors on Windows cp1252 terminals. -- **Path safety rule**: Any code that builds filesystem paths from user input or external data (marketplace names, plugin paths, lockfile entries, bundle contents) **must** use the centralized guards in `src/apm_cli/utils/path_security.py`. Use `validate_path_segments(value, context=)` at parse time to reject traversal sequences (`.`, `..`) with cross-platform backslash normalization, and `ensure_path_within(path, base_dir)` after resolution to assert containment (resolves symlinks). Never write ad-hoc `".." in x` checks. -- **Expert review panel**: For any non-trivial change (cross-cutting refactor, new CLI surface, dependency/auth/lockfile work, release or positioning decision), invoke the [APM Review Panel skill](skills/apm-review-panel/SKILL.md). It orchestrates seven personas (Python Architect, CLI Logging Expert, DevX UX Expert, Supply Chain Security Expert, APM CEO, OSS Growth Hacker) with explicit routing: specialists raise findings, the CEO arbitrates disagreements and strategic calls, the Growth Hacker side-channels conversion / `WIP/growth-strategy.md` insights to the CEO. Individual per-persona skills (`devx-ux`, `supply-chain-security`, `apm-strategy`, `oss-growth`) auto-activate on relevant edits even outside the panel. \ No newline at end of file + + + + + +# Linting (canonical contract) + +The CI `Lint` job is a hard gate. Mirror it locally before `git push` +and before producing any artifact (PR body, release note, audit +report) that claims CI is green. + +## CI-mirror commands + +The `Lint` job runs: + +- `uv run --extra dev ruff check src/ tests/` +- `uv run --extra dev ruff format --check src/ tests/` + +Both must be silent. + +## Local workflow + +- **Auto-fix style+imports:** `uv run --extra dev ruff check src/ tests/ --fix` +- **Apply formatter:** `uv run --extra dev ruff format src/ tests/` +- **Verify (must be silent):** `uv run --extra dev ruff check src/ tests/ && uv run --extra dev ruff format --check src/ tests/` + +Always run the verify pair before `git push` -- the CI Lint job +fails on any remaining diagnostic. + +## Common surprises + +- `RUF043` -- use `match=r"..."` for `pytest.raises` patterns with + regex metacharacters (`(`, `)`, `[`, etc.). +- `UP006` / `UP045` -- use `list` / `dict` / `X | None` instead of + `List` / `Dict` / `Optional`. +- `RUF100` -- drop stale `# noqa` directives. +- `F401` / `F841` -- remove unused imports / unused locals. +- `SIM103` -- inline negated returns where the body is one line. +- `I001` -- import sort order (auto-fixable). + +## Lifecycle binding + +This is the canonical lint contract for the repo. Skills that +produce artifacts asserting green CI -- notably `pr-description-skill` +(whose "Validation evidence" row covers CI checks) -- inherit this +gate transitively. Do NOT redefine ruff commands inside individual +skills; honor this instruction before invoking them. + + +--- +*This file was generated by APM CLI. Do not edit manually.* +*To regenerate: `specify apm compile`* diff --git a/.github/instructions/linting.instructions.md b/.github/instructions/linting.instructions.md new file mode 100644 index 000000000..9654360c5 --- /dev/null +++ b/.github/instructions/linting.instructions.md @@ -0,0 +1,46 @@ +--- +description: "Lint contract: run BEFORE pushing or producing artifacts that claim green CI. Mirrors the CI Lint job." +--- + +# Linting (canonical contract) + +The CI `Lint` job is a hard gate. Mirror it locally before `git push` +and before producing any artifact (PR body, release note, audit +report) that claims CI is green. + +## CI-mirror commands + +The `Lint` job runs: + +- `uv run --extra dev ruff check src/ tests/` +- `uv run --extra dev ruff format --check src/ tests/` + +Both must be silent. + +## Local workflow + +- **Auto-fix style+imports:** `uv run --extra dev ruff check src/ tests/ --fix` +- **Apply formatter:** `uv run --extra dev ruff format src/ tests/` +- **Verify (must be silent):** `uv run --extra dev ruff check src/ tests/ && uv run --extra dev ruff format --check src/ tests/` + +Always run the verify pair before `git push` -- the CI Lint job +fails on any remaining diagnostic. + +## Common surprises + +- `RUF043` -- use `match=r"..."` for `pytest.raises` patterns with + regex metacharacters (`(`, `)`, `[`, etc.). +- `UP006` / `UP045` -- use `list` / `dict` / `X | None` instead of + `List` / `Dict` / `Optional`. +- `RUF100` -- drop stale `# noqa` directives. +- `F401` / `F841` -- remove unused imports / unused locals. +- `SIM103` -- inline negated returns where the body is one line. +- `I001` -- import sort order (auto-fixable). + +## Lifecycle binding + +This is the canonical lint contract for the repo. Skills that +produce artifacts asserting green CI -- notably `pr-description-skill` +(whose "Validation evidence" row covers CI checks) -- inherit this +gate transitively. Do NOT redefine ruff commands inside individual +skills; honor this instruction before invoking them. diff --git a/.github/skills/pr-description-skill/SKILL.md b/.github/skills/pr-description-skill/SKILL.md index 2ab98c900..8426aeabf 100644 --- a/.github/skills/pr-description-skill/SKILL.md +++ b/.github/skills/pr-description-skill/SKILL.md @@ -216,8 +216,7 @@ Run these steps in order. Tick each before moving on. 1. [ ] Confirm every row of the activation contract is filled in. Defense-in-depth gate: before drafting the body, confirm the repo's lint contract is green (canonical commands and lifecycle - binding live in the project's `copilot-instructions.md` Linting - block - do NOT inline or restate them here). If lint is red, + binding live in `.apm/instructions/linting.instructions.md`). If lint is red, STOP, fix, re-run; a PR body claiming green CI while lint fails is a credibility tax we refuse to take on. 2. [ ] Read the diff in full. Identify per-file change summary, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3b0f23080..b7484d024 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -118,7 +118,12 @@ check, not a redesign. 2. Create a new branch for your feature/fix: `git checkout -b feature/your-feature-name` or `git checkout -b fix/issue-description`. 3. Make your changes. 4. Run tests: `uv run pytest tests/unit tests/test_console.py -x` -5. Ensure your code passes linting: `uv run ruff check src/ tests/` +5. Mirror the CI `Lint` job locally before pushing -- both commands must be silent: + ```bash + uv run --extra dev ruff check src/ tests/ + uv run --extra dev ruff format --check src/ tests/ + ``` + Auto-fix with `ruff check --fix` and `ruff format` (without `--check`). The full contract -- including common surprises like `RUF043`, `UP006`, `I001` -- lives in [`.apm/instructions/linting.instructions.md`](.apm/instructions/linting.instructions.md), the canonical source of truth that CI, the `pr-description-skill`, and the dogfood `apm compile -t copilot` all mirror. 6. Commit your changes with a descriptive message. 7. Push to your fork. 8. Submit a pull request. @@ -215,14 +220,22 @@ This project follows: - [PEP 8](https://pep8.org/) for Python style guidelines - We use [Ruff](https://docs.astral.sh/ruff/) for linting and formatting -CI enforces all lint and formatting rules automatically. You can run them locally: +CI enforces all lint and formatting rules automatically. The CI `Lint` job runs the following two commands -- both must be silent before you open a PR: + +```bash +uv run --extra dev ruff check src/ tests/ # lint (CI-mirror) +uv run --extra dev ruff format --check src/ tests/ # format check (CI-mirror) +``` + +Auto-fix locally with: ```bash -uv run ruff check src/ tests/ # lint -uv run ruff check --fix src/ tests/ # lint with auto-fix -uv run ruff format src/ tests/ # format +uv run --extra dev ruff check src/ tests/ --fix # lint with auto-fix +uv run --extra dev ruff format src/ tests/ # apply formatter ``` +The canonical lint contract (with common diagnostics and lifecycle binding for skills that claim green CI) lives in [`.apm/instructions/linting.instructions.md`](.apm/instructions/linting.instructions.md). Do not redefine these commands elsewhere -- honor that instruction. + ### Optional: local pre-commit hooks For instant feedback before pushing, install the pre-commit hooks: diff --git a/apm.lock.yaml b/apm.lock.yaml index eaa002865..8cffa989a 100644 --- a/apm.lock.yaml +++ b/apm.lock.yaml @@ -19,6 +19,7 @@ local_deployed_files: - .github/instructions/doc-sync.instructions.md - .github/instructions/encoding.instructions.md - .github/instructions/integrators.instructions.md +- .github/instructions/linting.instructions.md - .github/instructions/python.instructions.md - .github/instructions/tests.instructions.md - .github/skills/apm-review-panel @@ -49,5 +50,6 @@ local_deployed_file_hashes: .github/instructions/doc-sync.instructions.md: sha256:bb3816254f8df6bffc6faacd556871f36903e9d7f348982f1e2de0339384c696 .github/instructions/encoding.instructions.md: sha256:93db7377dc896f6efecf2c5d8c5d89255a555562f468d034d64c42edd5cf46d5 .github/instructions/integrators.instructions.md: sha256:b151e0438088d2c0b636dfc28532ecf43c3b51e5f1070a354b8d5b57c345e335 + .github/instructions/linting.instructions.md: sha256:312acd32353567834ec9f4f246710a47a991729a11c0380aa6a010b63de607eb .github/instructions/python.instructions.md: sha256:45173f778eddc126c37c7ace96acd0e17adb1895031eec134ec0754638d3ba37 - .github/instructions/tests.instructions.md: sha256:19a0d078417876ab3b758f8d404cf8266354e3412860eb88b849b620692657e4 + .github/instructions/tests.instructions.md: sha256:4c6335e3373f9735778a05913f2d8ef250d118f8c5305e70ba407e578a525ef7 diff --git a/src/apm_cli/commands/compile/cli.py b/src/apm_cli/commands/compile/cli.py index e6fb5cace..3f8fb87c7 100644 --- a/src/apm_cli/commands/compile/cli.py +++ b/src/apm_cli/commands/compile/cli.py @@ -424,8 +424,9 @@ def compile( if isinstance(compile_config_target, str) else None, ) - # Map 'minimal' to 'vscode' for the compiler (AGENTS.md only, no folder integration) - effective_target = detected_target if detected_target != "minimal" else "vscode" + # Keep the detected target intact so the compiler can preserve + # minimal-mode semantics (AGENTS.md only, no .github side outputs). + effective_target = detected_target # Build config with distributed compilation flags (Task 7) config = CompilationConfig.from_apm_yml( diff --git a/src/apm_cli/compilation/agents_compiler.py b/src/apm_cli/compilation/agents_compiler.py index bdec805e2..44819130d 100644 --- a/src/apm_cli/compilation/agents_compiler.py +++ b/src/apm_cli/compilation/agents_compiler.py @@ -5,6 +5,7 @@ primitives & constitution are unchanged. """ +import hashlib import logging from dataclasses import dataclass from pathlib import Path @@ -14,6 +15,7 @@ CompileTargetType, should_compile_agents_md, should_compile_claude_md, + should_compile_copilot_instructions_md, should_compile_gemini_md, ) from ..primitives.discovery import discover_primitives @@ -21,6 +23,7 @@ from ..utils.paths import portable_relpath from ..version import get_version from .claude_formatter import ClaudeFormatter +from .constants import BUILD_ID_PLACEHOLDER from .link_resolver import resolve_markdown_links, validate_link_targets from .template_builder import ( TemplateData, @@ -45,6 +48,7 @@ "all", "minimal", ) + _VSCODE_TARGET_ALIASES +_COPILOT_ROOT_GENERATED_MARKER = "" # Compiler families allowed inside a multi-target frozenset (built by # _resolve_compile_target() from CLI-validated target names). Kept narrow @@ -336,10 +340,12 @@ def _compile_agents_md( """ # 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 @@ -871,6 +877,150 @@ def _generate_template_data( 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 + 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: + """Generate root Copilot instructions content from global instruction primitives.""" + sections = [ + _COPILOT_ROOT_GENERATED_MARKER, + BUILD_ID_PLACEHOLDER, + f"", + "", + ] + + for instruction in instructions: + rel_path = portable_relpath(instruction.file_path, self.base_dir) + sections.append(f"") + sections.append(instruction.content.strip()) + sections.append(f"") + 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"" + 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. diff --git a/src/apm_cli/core/target_detection.py b/src/apm_cli/core/target_detection.py index f6e8cf8e1..9474e281c 100644 --- a/src/apm_cli/core/target_detection.py +++ b/src/apm_cli/core/target_detection.py @@ -196,6 +196,18 @@ def should_compile_gemini_md(target: CompileTargetType) -> 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 + + 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. @@ -210,14 +222,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/", "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") diff --git a/src/apm_cli/integration/targets.py b/src/apm_cli/integration/targets.py index afb1932f6..733c58987 100644 --- a/src/apm_cli/integration/targets.py +++ b/src/apm_cli/integration/targets.py @@ -123,6 +123,14 @@ class TargetProfile: in ``KNOWN_TARGETS`` for tooling introspection. """ + generated_files: tuple[str, ...] = () + """Additional generated files associated with this target. + + These are compile-time outputs that live at the target root but are not + deployed via primitive integrators, e.g. Copilot's root + ``copilot-instructions.md`` file. + """ + @property def prefix(self) -> str: """Return the path prefix for this target (e.g. ``".github/"``). @@ -285,6 +293,7 @@ def for_scope(self, user_scope: bool = False) -> TargetProfile | None: user_supported="partial", user_root_dir=".copilot", unsupported_user_primitives=("prompts", "instructions"), + generated_files=("copilot-instructions.md",), ), # Claude Code -- the user-level config directory is whatever # ``CLAUDE_CONFIG_DIR`` points to (default ``~/.claude``). The env diff --git a/tests/integration/test_compile_copilot_root_instructions.py b/tests/integration/test_compile_copilot_root_instructions.py new file mode 100644 index 000000000..6038e377e --- /dev/null +++ b/tests/integration/test_compile_copilot_root_instructions.py @@ -0,0 +1,63 @@ +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 "