From 06c5d90f7731e738cd23fd373a4fe0eb3492ece5 Mon Sep 17 00:00:00 2001 From: Sergio Sisternes Date: Wed, 22 Apr 2026 11:13:41 +0100 Subject: [PATCH 01/10] fix(compilation): route timing output through logger and use deterministic sort base_dir Two foundational fixes required before apm compile --check can enforce a strict stdout contract and round-trip determinism: 1. context_optimizer.py: replace bare print() with logger.debug(). Timing output is opt-in via --verbose/DEBUG level and must never leak to default stdout. 2. template_builder.py: build_conditional_sections() now takes a required base_dir parameter. Previously Path.cwd() was used in the sort key and relative-path display, making compile output depend on the user's current working directory. Sort order must be deterministic regardless of where apm is invoked from. Updated the single caller in agents_compiler.py to pass self.base_dir. Added unit tests covering logger routing (caplog vs capsys) and deterministic sort behaviour. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/apm_cli/compilation/agents_compiler.py | 2 +- src/apm_cli/compilation/context_optimizer.py | 5 +- src/apm_cli/compilation/template_builder.py | 9 +- tests/unit/compilation/test_compilation.py | 4 +- .../compilation/test_context_optimizer.py | 38 +++++++++ .../unit/compilation/test_template_builder.py | 85 +++++++++++++++++++ 6 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 tests/unit/compilation/test_template_builder.py diff --git a/src/apm_cli/compilation/agents_compiler.py b/src/apm_cli/compilation/agents_compiler.py index 4d76c6a22..a93fc8520 100644 --- a/src/apm_cli/compilation/agents_compiler.py +++ b/src/apm_cli/compilation/agents_compiler.py @@ -727,7 +727,7 @@ def _generate_template_data(self, primitives: PrimitiveCollection, config: Compi TemplateData: Template data for generation. """ # Build instructions content - instructions_content = build_conditional_sections(primitives.instructions) + instructions_content = build_conditional_sections(primitives.instructions, self.base_dir) # Metadata (version only; timestamp intentionally omitted for determinism) version = get_version() diff --git a/src/apm_cli/compilation/context_optimizer.py b/src/apm_cli/compilation/context_optimizer.py index 0992f9cf7..47f6a2802 100644 --- a/src/apm_cli/compilation/context_optimizer.py +++ b/src/apm_cli/compilation/context_optimizer.py @@ -7,6 +7,7 @@ import builtins import fnmatch +import logging import os import time from collections import defaultdict @@ -16,6 +17,8 @@ from functools import lru_cache import glob +logger = logging.getLogger(__name__) + from ..primitives.models import Instruction from ..output.models import ( CompilationResults, ProjectAnalysis, OptimizationDecision, OptimizationStats, @@ -153,7 +156,7 @@ def _time_phase(self, phase_name: str, operation_func, *args, **kwargs): # Only show timing in verbose mode with professional formatting if self._timing_enabled and hasattr(self, '_verbose') and self._verbose: - print(f" {phase_name}: {duration*1000:.1f}ms") + logger.debug(" %s: %.1fms", phase_name, duration * 1000) return result def _cached_glob(self, pattern: str) -> List[str]: diff --git a/src/apm_cli/compilation/template_builder.py b/src/apm_cli/compilation/template_builder.py index fd8799d1d..e32348134 100644 --- a/src/apm_cli/compilation/template_builder.py +++ b/src/apm_cli/compilation/template_builder.py @@ -17,11 +17,14 @@ class TemplateData: chatmode_content: Optional[str] = None -def build_conditional_sections(instructions: List[Instruction]) -> str: +def build_conditional_sections(instructions: List[Instruction], base_dir: Path) -> str: """Build sections grouped by applyTo patterns. Args: instructions (List[Instruction]): List of instruction primitives. + base_dir (Path): Base directory used for deterministic relative-path + sorting and display. Must be supplied by the caller; there is no + fallback to ``Path.cwd()``. Returns: str: Formatted conditional sections content. @@ -39,14 +42,14 @@ def build_conditional_sections(instructions: List[Instruction]) -> str: sections.append("") # Combine content from all instructions for this pattern - for instruction in sorted(pattern_instructions, key=lambda i: portable_relpath(i.file_path, Path.cwd())): + for instruction in sorted(pattern_instructions, key=lambda i: portable_relpath(i.file_path, base_dir)): content = instruction.content.strip() if content: # Add source file comment before the content try: # Try to get relative path for cleaner display if instruction.file_path.is_absolute(): - relative_path = portable_relpath(instruction.file_path, Path.cwd()) + relative_path = portable_relpath(instruction.file_path, base_dir) else: relative_path = str(instruction.file_path) except (ValueError, OSError): diff --git a/tests/unit/compilation/test_compilation.py b/tests/unit/compilation/test_compilation.py index fee27f4cd..e3a976853 100644 --- a/tests/unit/compilation/test_compilation.py +++ b/tests/unit/compilation/test_compilation.py @@ -57,7 +57,7 @@ def test_build_conditional_sections(self): ) ] - result = build_conditional_sections(instructions) + result = build_conditional_sections(instructions, Path(".")) # Should group by pattern self.assertIn("## Files matching `**/*.py`", result) @@ -68,7 +68,7 @@ def test_build_conditional_sections(self): def test_build_conditional_sections_empty(self): """Test building conditional sections with no instructions.""" - result = build_conditional_sections([]) + result = build_conditional_sections([], Path(".")) self.assertEqual(result, "") diff --git a/tests/unit/compilation/test_context_optimizer.py b/tests/unit/compilation/test_context_optimizer.py index e7c637b93..911f86910 100644 --- a/tests/unit/compilation/test_context_optimizer.py +++ b/tests/unit/compilation/test_context_optimizer.py @@ -879,5 +879,43 @@ def test_set_path_cached_across_calls(self): assert id(optimizer._glob_set_cache["**/*.ts"]) == cached_set_id +class TestTimePhaseLoggerRouting: + """_time_phase must route timing output through the logger, not stdout.""" + + def test_timing_output_goes_to_logger_not_stdout( + self, tmp_path: Path, capsys, caplog + ) -> None: + """When timing and verbose are enabled, the phase duration must + appear in the DEBUG log and must NOT appear on stdout.""" + optimizer = ContextOptimizer(str(tmp_path)) + optimizer._timing_enabled = True + optimizer._verbose = True + + with caplog.at_level("DEBUG", logger="apm_cli.compilation.context_optimizer"): + optimizer._time_phase("TestPhase", lambda: None) + + captured = capsys.readouterr() + assert captured.out == "", "Timing output must not leak to stdout" + assert any( + "TestPhase" in record.message and "ms" in record.message + for record in caplog.records + ), "Timing output should appear in logger DEBUG records" + + def test_timing_silent_when_not_verbose(self, tmp_path: Path, capsys, caplog) -> None: + """When timing is enabled but verbose is False, nothing is logged.""" + optimizer = ContextOptimizer(str(tmp_path)) + optimizer._timing_enabled = True + optimizer._verbose = False + + with caplog.at_level("DEBUG", logger="apm_cli.compilation.context_optimizer"): + optimizer._time_phase("QuietPhase", lambda: 42) + + captured = capsys.readouterr() + assert captured.out == "" + assert not any( + "QuietPhase" in record.message for record in caplog.records + ) + + if __name__ == "__main__": pytest.main([__file__]) \ No newline at end of file diff --git a/tests/unit/compilation/test_template_builder.py b/tests/unit/compilation/test_template_builder.py new file mode 100644 index 000000000..6ecfa443e --- /dev/null +++ b/tests/unit/compilation/test_template_builder.py @@ -0,0 +1,85 @@ +"""Unit tests for template_builder -- deterministic sort behaviour.""" + +from pathlib import Path + +import pytest + +from apm_cli.compilation.template_builder import build_conditional_sections +from apm_cli.primitives.models import Instruction + + +class TestBuildConditionalSectionsDeterministicSort: + """build_conditional_sections must sort by base_dir-relative paths, + not by cwd-relative paths, so the output is deterministic regardless + of where the user invokes ``apm compile``.""" + + @staticmethod + def _make_instruction(file_path: Path, content: str) -> Instruction: + return Instruction( + name=file_path.stem, + file_path=file_path, + description="test", + apply_to="**/*.py", + content=content, + author="test", + version="1.0", + ) + + def test_sort_uses_base_dir_not_cwd(self, tmp_path: Path) -> None: + """Two instructions whose relative order flips depending on the + base directory used for sorting. Passing ``base_dir`` explicitly + must control the order, regardless of actual cwd.""" + # Create paths: under base_dir="/project", the relative paths + # are "alpha/code.py" and "beta/code.py" (alpha < beta). + project = tmp_path / "project" + alpha = project / "alpha" / "code.py" + beta = project / "beta" / "code.py" + alpha.parent.mkdir(parents=True) + beta.parent.mkdir(parents=True) + alpha.touch() + beta.touch() + + instr_alpha = self._make_instruction(alpha, "alpha content") + instr_beta = self._make_instruction(beta, "beta content") + + # Regardless of the order we pass them in, the output must list + # alpha before beta when base_dir is ``project``. + result = build_conditional_sections( + [instr_beta, instr_alpha], base_dir=project + ) + + alpha_pos = result.index("alpha content") + beta_pos = result.index("beta content") + assert alpha_pos < beta_pos, ( + "Instructions should be sorted by base_dir-relative path " + "(alpha before beta)" + ) + + def test_sort_is_stable_across_different_base_dirs(self, tmp_path: Path) -> None: + """Using a different base_dir changes the relative paths and + therefore the sort order.""" + root = tmp_path / "root" + a_file = root / "z_dir" / "a.py" + b_file = root / "a_dir" / "b.py" + a_file.parent.mkdir(parents=True) + b_file.parent.mkdir(parents=True) + a_file.touch() + b_file.touch() + + instr_a = self._make_instruction(a_file, "content_a") + instr_b = self._make_instruction(b_file, "content_b") + + result = build_conditional_sections( + [instr_a, instr_b], base_dir=root + ) + + # Relative paths: "a_dir/b.py" < "z_dir/a.py" + a_pos = result.index("content_a") + b_pos = result.index("content_b") + assert b_pos < a_pos, ( + "a_dir/b.py should sort before z_dir/a.py" + ) + + def test_empty_instructions_returns_empty(self) -> None: + """Empty instruction list returns empty string.""" + assert build_conditional_sections([], base_dir=Path(".")) == "" From fc3dde9afc3fc9fa0c5a241409f71ad6aecb6693 Mon Sep 17 00:00:00 2001 From: Sergio Sisternes Date: Wed, 22 Apr 2026 11:20:18 +0100 Subject: [PATCH 02/10] fix(compilation): add path containment to link_resolver._resolve_path The apm compile --check flag (landing in a later commit) will run in Tier 1 CI and read user-authored markdown from .apm/ primitives. _resolve_path previously accepted absolute paths unguarded and performed naive base_path/path joins with no traversal or symlink-escape checks. This is the first path_security import into the compilation/ subsystem. New contract (3-gate fail-closed): 1. Absolute paths are rejected outright (return None). 2. validate_path_segments rejects '..' at parse time; './' is allowed since it is legitimate in markdown links (allow_current_dir=True). 3. ensure_path_within resolves symlinks and asserts containment after the join; the resolved path is returned on success. PathTraversalError / OSError / ValueError all map to None, which the caller surfaces as 'Referenced file not found' via the existing validate_link_targets flow. Three other pre-existing path-traversal gaps in the compilation subsystem (apm.yml output_path, applyTo patterns, full path_security integration across compile) are explicitly deferred to a labelled security follow-up issue filed pre-merge. Supply-chain-security-expert review: 0 blockers, 0 majors, 3 minors (all non-exploitable edge cases filed under the follow-up). Tests: TestResolvePathSecurity covers absolute rejection, traversal at depth, current-directory allowance, symlink escape, and an integration check via validate_link_targets. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/apm_cli/compilation/link_resolver.py | 33 +++++++--- tests/unit/compilation/test_link_resolver.py | 67 +++++++++++++++++++- 2 files changed, 91 insertions(+), 9 deletions(-) diff --git a/src/apm_cli/compilation/link_resolver.py b/src/apm_cli/compilation/link_resolver.py index 30f58b8d4..62950d565 100644 --- a/src/apm_cli/compilation/link_resolver.py +++ b/src/apm_cli/compilation/link_resolver.py @@ -16,6 +16,12 @@ from typing import List, Dict, Optional, Set from urllib.parse import urlparse +from ..utils.path_security import ( + ensure_path_within, + validate_path_segments, + PathTraversalError, +) + # CRITICAL: Shadow Click commands to prevent namespace collision set = builtins.set list = builtins.list @@ -431,21 +437,32 @@ def validate_link_targets(content: str, base_path: Path) -> List[str]: def _resolve_path(path: str, base_path: Path) -> Optional[Path]: - """Resolve a relative path against a base path. - + """Resolve a relative path against a base path with containment checks. + + Security behaviour: + - Absolute paths are rejected outright (return ``None``). + - Traversal segments (``..``) are rejected at parse time. + - After joining, the resolved path must remain within *base_path* + (catches symlink escapes). + Args: path (str): Relative path to resolve. base_path (Path): Base directory for resolution. - + Returns: - Optional[Path]: Resolved path or None if invalid. + Optional[Path]: Resolved path or ``None`` if the path is + invalid, absolute, or escapes the base directory. """ try: if Path(path).is_absolute(): - return Path(path) - else: - return base_path / path - except (OSError, ValueError): + return None + + validate_path_segments( + path, context="link target", allow_current_dir=True, + ) + + return ensure_path_within(base_path / path, base_path) + except (PathTraversalError, OSError, ValueError): return None diff --git a/tests/unit/compilation/test_link_resolver.py b/tests/unit/compilation/test_link_resolver.py index bc1118e70..f1f372d77 100644 --- a/tests/unit/compilation/test_link_resolver.py +++ b/tests/unit/compilation/test_link_resolver.py @@ -11,7 +11,9 @@ from apm_cli.compilation.link_resolver import ( UnifiedLinkResolver, - LinkResolutionContext + LinkResolutionContext, + _resolve_path, + validate_link_targets, ) from apm_cli.primitives.models import ( PrimitiveCollection, @@ -457,3 +459,66 @@ def test_memory_context_files(self, resolver, base_dir): # Should be rewritten to actual source location assert ".apm/context/project.memory.md" in result + + +class TestResolvePathSecurity: + """Tests for _resolve_path containment and traversal rejection.""" + + def test_absolute_path_rejected(self, tmp_path): + """Absolute paths must be rejected outright.""" + assert _resolve_path("/etc/passwd", tmp_path) is None + + def test_traversal_segment_rejected(self, tmp_path): + """Paths containing '..' segments must be rejected.""" + assert _resolve_path("../../etc/passwd", tmp_path) is None + + def test_deep_traversal_segment_rejected(self, tmp_path): + """Paths with '..' buried after valid segments must be rejected.""" + assert _resolve_path("foo/../../../etc/passwd", tmp_path) is None + + def test_current_directory_segment_allowed(self, tmp_path): + """Paths with './' prefixes are legitimate in markdown links.""" + sub = tmp_path / "sub" + sub.mkdir() + target = sub / "file.md" + target.write_text("content", encoding="utf-8") + + result = _resolve_path("./sub/file.md", tmp_path) + assert result is not None + assert result == target.resolve() + + def test_symlink_escape_rejected(self, tmp_path): + """A symlink that resolves outside base_path must be rejected.""" + import os + import tempfile + + # Create a file outside the sandbox + outside_dir = Path(tempfile.mkdtemp()) + outside_file = outside_dir / "secret.txt" + outside_file.write_text("secret", encoding="utf-8") + + # Create a symlink inside tmp_path pointing outside + link = tmp_path / "escape_link" + try: + os.symlink(outside_file, link) + except OSError: + pytest.skip("symlinks not supported on this platform") + + assert _resolve_path("escape_link", tmp_path) is None + + def test_legitimate_relative_path_resolves(self, tmp_path): + """A normal relative path within base_path must resolve.""" + sub = tmp_path / "docs" + sub.mkdir() + target = sub / "guide.md" + target.write_text("guide", encoding="utf-8") + + result = _resolve_path("docs/guide.md", tmp_path) + assert result is not None + assert result == target.resolve() + + def test_validate_link_targets_blocks_absolute_path(self, tmp_path): + """Integration: absolute path in markdown link is reported as not found.""" + content = "See [evil](/etc/passwd) for details." + errors = validate_link_targets(content, tmp_path) + assert any("Referenced file not found" in e and "/etc/passwd" in e for e in errors) From 6e195cf8f7c9e311eb9fb7ad6c3c01902276a939 Mon Sep 17 00:00:00 2001 From: Sergio Sisternes Date: Wed, 22 Apr 2026 11:32:41 +0100 Subject: [PATCH 03/10] feat(compile): emit .github/copilot-instructions.md from root-scoped instructions (#792) Closes microsoft/apm#792. Instructions with empty or missing applyTo frontmatter were previously silently dropped by both the distributed compiler and the single-file template builder. They now aggregate into .github/copilot-instructions.md for the vscode-family targets (vscode, copilot, agents, opencode, codex, all, minimal). Design (approved by python-architect pre-implementation): - New AgentsCompiler._compile_copilot_instructions() sibling emitter called between _compile_agents_md and _compile_claude_md in the target-routing block. Returns Optional[CompilationResult]; None when no root-scoped instructions exist so no empty file is written and _merge_results stays clean. - New should_compile_copilot_instructions() predicate in target_detection so the gate can diverge from should_compile_agents_md later if needed. - New build_root_sections() helper in template_builder mirrors build_conditional_sections but without pattern headers; filters empty-applyTo instructions, deterministic sort by portable_relpath(path, base_dir). - Hardcoded path .github/copilot-instructions.md (GitHub Copilot convention); no new CompilationConfig field needed. - Prefixed stat key 'copilot_instructions_written' to avoid _merge_results collisions. Also standardises the generated-file header across all three emitters. New constant GENERATED_HEADER in compilation/constants.py: Replaces the inconsistent AGENTS.md ('from distributed .apm/ primitives'), CLAUDE.md ('Generated by APM CLI') and template-builder variants. Updates the target-description strings in target_detection so apm init / status output reflects the new file. Tests: new tests/unit/compilation/test_copilot_instructions.py with 12 cases covering mixed fixture, empty case, deterministic sort, round-trip stability, header presence, source attribution, dry-run mode, target gating, and an integration test asserting both AGENTS.md and copilot-instructions.md are produced with the correct content split. Existing tests updated for the header standardisation. Full suite: 4804 passed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/apm_cli/compilation/agents_compiler.py | 68 +++- src/apm_cli/compilation/claude_formatter.py | 8 +- src/apm_cli/compilation/constants.py | 3 + .../compilation/distributed_compiler.py | 4 +- src/apm_cli/compilation/template_builder.py | 48 ++- src/apm_cli/core/target_detection.py | 22 +- .../unit/compilation/test_claude_formatter.py | 5 +- .../compilation/test_copilot_instructions.py | 341 ++++++++++++++++++ tests/unit/core/test_target_detection.py | 23 +- 9 files changed, 503 insertions(+), 19 deletions(-) create mode 100644 tests/unit/compilation/test_copilot_instructions.py diff --git a/src/apm_cli/compilation/agents_compiler.py b/src/apm_cli/compilation/agents_compiler.py index a93fc8520..b9dadb8ff 100644 --- a/src/apm_cli/compilation/agents_compiler.py +++ b/src/apm_cli/compilation/agents_compiler.py @@ -14,13 +14,22 @@ from .claude_formatter import ClaudeFormatter from .template_builder import ( build_conditional_sections, + build_root_sections, generate_agents_md_template, TemplateData, find_chatmode_by_name ) 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 +from ..core.target_detection import ( + should_compile_agents_md, + should_compile_claude_md, + should_compile_copilot_instructions, +) + + +# Output path for Copilot's root-scoped instructions file (GitHub convention). +COPILOT_INSTRUCTIONS_PATH = Path(".github") / "copilot-instructions.md" # User-facing target aliases that map to the canonical "vscode" target. @@ -235,6 +244,11 @@ def compile(self, config: CompilationConfig, primitives: Optional[PrimitiveColle if should_compile_agents_md(routing_target): results.append(self._compile_agents_md(config, primitives)) + if should_compile_copilot_instructions(routing_target): + copilot_result = self._compile_copilot_instructions(config, primitives) + if copilot_result is not None: + results.append(copilot_result) + if should_compile_claude_md(routing_target): results.append(self._compile_claude_md(config, primitives)) @@ -438,6 +452,58 @@ def _compile_single_file(self, config: CompilationConfig, primitives: PrimitiveC stats=stats ) + def _compile_copilot_instructions( + self, + config: CompilationConfig, + primitives: PrimitiveCollection, + ) -> Optional[CompilationResult]: + """Compile .github/copilot-instructions.md from root-scoped instructions. + + Aggregates instructions whose ``apply_to`` is empty into a single root + file at .github/copilot-instructions.md. Returns ``None`` when no + root-scoped instructions exist, so no empty file is written and the + caller can skip adding it to the merged result list. + """ + root_sections = build_root_sections(primitives.instructions, self.base_dir) + if not root_sections: + return None + + from .constants import GENERATED_HEADER + from ..version import get_version as _get_version + + lines = [ + GENERATED_HEADER, + f"", + "", + "# Copilot Instructions", + "", + root_sections, + ] + content = "\n".join(lines) + + output_path = str(self.base_dir / COPILOT_INSTRUCTIONS_PATH) + if not config.dry_run: + # Ensure .github/ directory exists + (self.base_dir / COPILOT_INSTRUCTIONS_PATH).parent.mkdir( + parents=True, exist_ok=True + ) + self._write_output_file(output_path, content) + self._log( + "progress", + f"Compiled .github/copilot-instructions.md", + ) + + return CompilationResult( + success=True, + output_path=output_path, + content=content, + warnings=self.warnings.copy(), + errors=self.errors.copy(), + stats={ + "copilot_instructions_written": 0 if config.dry_run else 1, + }, + ) + def _compile_claude_md(self, config: CompilationConfig, primitives: PrimitiveCollection) -> CompilationResult: """Compile CLAUDE.md files (Claude Code target). diff --git a/src/apm_cli/compilation/claude_formatter.py b/src/apm_cli/compilation/claude_formatter.py index fe78fddac..181892b13 100644 --- a/src/apm_cli/compilation/claude_formatter.py +++ b/src/apm_cli/compilation/claude_formatter.py @@ -19,7 +19,7 @@ from ..primitives.models import Instruction, PrimitiveCollection, Chatmode from ..version import get_version from ..utils.paths import portable_relpath -from .constants import BUILD_ID_PLACEHOLDER +from .constants import BUILD_ID_PLACEHOLDER, GENERATED_HEADER from .constitution import read_constitution # CRITICAL: Shadow Click commands to prevent namespace collision @@ -28,10 +28,6 @@ dict = builtins.dict -# Header comment for CLAUDE.md files -CLAUDE_HEADER = "" - - @dataclass class ClaudePlacement: """Result of CLAUDE.md placement analysis.""" @@ -254,7 +250,7 @@ def _generate_claude_content( # Header sections.append("# CLAUDE.md") - sections.append(CLAUDE_HEADER) + sections.append(GENERATED_HEADER) sections.append(BUILD_ID_PLACEHOLDER) sections.append(f"") sections.append("") diff --git a/src/apm_cli/compilation/constants.py b/src/apm_cli/compilation/constants.py index 62252961f..695737ffe 100644 --- a/src/apm_cli/compilation/constants.py +++ b/src/apm_cli/compilation/constants.py @@ -5,6 +5,9 @@ deterministic Build ID (content hash) is substituted post-generation. """ +# Unified header comment for all generated files (AGENTS.md, CLAUDE.md, etc.) +GENERATED_HEADER = "" + # Constitution injection markers CONSTITUTION_MARKER_BEGIN = "" CONSTITUTION_MARKER_END = "" diff --git a/src/apm_cli/compilation/distributed_compiler.py b/src/apm_cli/compilation/distributed_compiler.py index c986b8bb9..deccce862 100644 --- a/src/apm_cli/compilation/distributed_compiler.py +++ b/src/apm_cli/compilation/distributed_compiler.py @@ -15,7 +15,7 @@ from ..primitives.models import Instruction, PrimitiveCollection from ..version import get_version from .template_builder import TemplateData, find_chatmode_by_name -from .constants import BUILD_ID_PLACEHOLDER +from .constants import BUILD_ID_PLACEHOLDER, GENERATED_HEADER from .context_optimizer import ContextOptimizer from .link_resolver import UnifiedLinkResolver from ..output.formatters import CompilationFormatter @@ -511,7 +511,7 @@ def _generate_agents_content( # Header with source attribution sections.append("# AGENTS.md") - sections.append("") + sections.append(GENERATED_HEADER) sections.append(BUILD_ID_PLACEHOLDER) sections.append(f"") diff --git a/src/apm_cli/compilation/template_builder.py b/src/apm_cli/compilation/template_builder.py index e32348134..954c15384 100644 --- a/src/apm_cli/compilation/template_builder.py +++ b/src/apm_cli/compilation/template_builder.py @@ -64,6 +64,51 @@ def build_conditional_sections(instructions: List[Instruction], base_dir: Path) return "\n".join(sections) +def build_root_sections(instructions: List[Instruction], base_dir: Path) -> str: + """Build content sections from root-scoped (empty applyTo) instructions. + + Filters *instructions* to those whose ``apply_to`` field is empty or + missing, sorts deterministically by ``portable_relpath(file_path, base_dir)``, + and emits source-attributed content blocks without pattern headers. + + Args: + instructions: Full list of instruction primitives (caller passes + ``primitives.instructions`` unfiltered; filtering is internal). + base_dir: Base directory used for deterministic relative-path + sorting and display; must be supplied by the caller. + + Returns: + The concatenated content sections, or an empty string when no + root-scoped instructions exist. + """ + root_instructions = [i for i in instructions if not i.apply_to] + if not root_instructions: + return "" + + sections: List[str] = [] + + for instruction in sorted( + root_instructions, + key=lambda i: portable_relpath(i.file_path, base_dir), + ): + content = instruction.content.strip() + if content: + try: + if instruction.file_path.is_absolute(): + relative_path = portable_relpath(instruction.file_path, base_dir) + else: + relative_path = str(instruction.file_path) + except (ValueError, OSError): + relative_path = instruction.file_path.as_posix() + + sections.append(f"") + sections.append(content) + sections.append(f"") + sections.append("") + + return "\n".join(sections) + + def find_chatmode_by_name(chatmodes: List[Chatmode], chatmode_name: str) -> Optional[Chatmode]: """Find a chatmode by name. @@ -118,7 +163,8 @@ def generate_agents_md_template(template_data: TemplateData) -> str: # Header sections.append("# AGENTS.md") - sections.append(f"") + from .constants import GENERATED_HEADER + sections.append(GENERATED_HEADER) from .constants import BUILD_ID_PLACEHOLDER sections.append(BUILD_ID_PLACEHOLDER) sections.append(f"") diff --git a/src/apm_cli/core/target_detection.py b/src/apm_cli/core/target_detection.py index 85a991bd7..6e12bd140 100644 --- a/src/apm_cli/core/target_detection.py +++ b/src/apm_cli/core/target_detection.py @@ -201,6 +201,18 @@ def should_compile_claude_md(target: TargetType) -> bool: return target in ("claude", "all") +def should_compile_copilot_instructions(target: UserTargetType) -> bool: + """Check if .github/copilot-instructions.md should be compiled. + + Copilot's root-scoped instructions file applies to the same targets as + AGENTS.md (vscode-family), but is a separate concern so it gets its own + gate. This allows the predicate to diverge if needed (for example, if + ``minimal`` later excludes copilot-instructions). + """ + normalised = "vscode" if target in ("copilot", "agents") else target + return normalised in ("vscode", "opencode", "codex", "all", "minimal") + + def get_target_description(target: UserTargetType) -> str: """Get a human-readable description of what will be generated for a target. @@ -215,13 +227,13 @@ 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", - "all": "AGENTS.md + CLAUDE.md + .github/ + .claude/ + .cursor/ + .opencode/ + .codex/ + .agents/", - "minimal": "AGENTS.md only (create .github/ or .claude/ for full integration)", + "opencode": "AGENTS.md + .github/copilot-instructions.md + .opencode/agents/ + .opencode/commands/ + .opencode/skills/", + "codex": "AGENTS.md + .github/copilot-instructions.md + .agents/skills/ + .codex/agents/ + .codex/hooks.json", + "all": "AGENTS.md + CLAUDE.md + .github/copilot-instructions.md + .github/ + .claude/ + .cursor/ + .opencode/ + .codex/ + .agents/", + "minimal": "AGENTS.md + .github/copilot-instructions.md (create .github/ or .claude/ for full integration)", } return descriptions.get(normalized, "unknown target") diff --git a/tests/unit/compilation/test_claude_formatter.py b/tests/unit/compilation/test_claude_formatter.py index 2884cf6f9..3b7452dd2 100644 --- a/tests/unit/compilation/test_claude_formatter.py +++ b/tests/unit/compilation/test_claude_formatter.py @@ -13,9 +13,8 @@ CommandGenerationResult, format_claude_md, generate_claude_commands, - CLAUDE_HEADER, ) -from apm_cli.compilation.constants import BUILD_ID_PLACEHOLDER +from apm_cli.compilation.constants import BUILD_ID_PLACEHOLDER, GENERATED_HEADER from apm_cli.primitives.models import Instruction, Chatmode, PrimitiveCollection from apm_cli.version import get_version @@ -94,7 +93,7 @@ def test_format_generates_header(self, temp_project, sample_primitives): content = result.content_map[temp_project / "CLAUDE.md"] assert "# CLAUDE.md" in content - assert CLAUDE_HEADER in content + assert GENERATED_HEADER in content assert BUILD_ID_PLACEHOLDER in content assert f"" in content diff --git a/tests/unit/compilation/test_copilot_instructions.py b/tests/unit/compilation/test_copilot_instructions.py new file mode 100644 index 000000000..0f1e4ebf3 --- /dev/null +++ b/tests/unit/compilation/test_copilot_instructions.py @@ -0,0 +1,341 @@ +"""Unit tests for .github/copilot-instructions.md compilation.""" + +import shutil +from pathlib import Path +from typing import List + +import pytest + +from apm_cli.compilation.agents_compiler import ( + AgentsCompiler, + CompilationConfig, + CompilationResult, + COPILOT_INSTRUCTIONS_PATH, +) +from apm_cli.compilation.constants import GENERATED_HEADER +from apm_cli.compilation.template_builder import build_root_sections +from apm_cli.primitives.models import Instruction, PrimitiveCollection + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_instruction( + file_path: Path, + content: str, + apply_to: str = "", + name: str = "", +) -> Instruction: + """Create a minimal Instruction for testing.""" + return Instruction( + name=name or file_path.stem, + file_path=file_path, + description="test instruction", + apply_to=apply_to, + content=content, + ) + + +def _make_primitives(instructions: List[Instruction]) -> PrimitiveCollection: + """Wrap a list of instructions in a PrimitiveCollection.""" + pc = PrimitiveCollection() + pc.instructions = list(instructions) + return pc + + +def _compiler_and_config( + tmp_path: Path, + *, + dry_run: bool = False, + target: str = "vscode", + strategy: str = "single-file", +) -> tuple: + """Return (AgentsCompiler, CompilationConfig) rooted at *tmp_path*.""" + compiler = AgentsCompiler(str(tmp_path)) + config = CompilationConfig( + target=target, + dry_run=dry_run, + strategy=strategy, + single_agents=True, + ) + return compiler, config + + +# --------------------------------------------------------------------------- +# 1. Mixed fixture — root + pattern-scoped instructions +# --------------------------------------------------------------------------- + +class TestCopilotInstructionsMixed: + """Compile with a mix of root-scoped and pattern-scoped instructions.""" + + def test_mixed_instructions(self, tmp_path: Path) -> None: + root_a = tmp_path / "a.instructions.md" + root_b = tmp_path / "b.instructions.md" + scoped_c = tmp_path / "c.instructions.md" + scoped_d = tmp_path / "d.instructions.md" + for f in (root_a, root_b, scoped_c, scoped_d): + f.touch() + + instructions = [ + _make_instruction(root_a, "Root instruction A"), + _make_instruction(root_b, "Root instruction B"), + _make_instruction(scoped_c, "Scoped C", apply_to="**/*.py"), + _make_instruction(scoped_d, "Scoped D", apply_to="**/*.js"), + ] + primitives = _make_primitives(instructions) + + compiler, config = _compiler_and_config(tmp_path) + result = compiler._compile_copilot_instructions(config, primitives) + + assert result is not None + assert result.output_path.endswith(str(COPILOT_INSTRUCTIONS_PATH)) + assert "Root instruction A" in result.content + assert "Root instruction B" in result.content + assert "Scoped C" not in result.content + assert "Scoped D" not in result.content + assert GENERATED_HEADER in result.content + + +# --------------------------------------------------------------------------- +# 2. Empty case — no root-scoped instructions -> None +# --------------------------------------------------------------------------- + +class TestCopilotInstructionsEmpty: + """No root-scoped instructions means no file at all.""" + + def test_returns_none_when_no_root_instructions(self, tmp_path: Path) -> None: + scoped = tmp_path / "scoped.instructions.md" + scoped.touch() + + instructions = [ + _make_instruction(scoped, "Only scoped", apply_to="**/*.py"), + ] + primitives = _make_primitives(instructions) + + compiler, config = _compiler_and_config(tmp_path) + result = compiler._compile_copilot_instructions(config, primitives) + + assert result is None + assert not (tmp_path / COPILOT_INSTRUCTIONS_PATH).exists() + + +# --------------------------------------------------------------------------- +# 3. Deterministic sort — order is base_dir-relative +# --------------------------------------------------------------------------- + +class TestCopilotInstructionsDeterministicSort: + """Root-scoped instructions are sorted by base_dir-relative path.""" + + def test_sort_uses_base_dir(self, tmp_path: Path) -> None: + project = tmp_path / "project" + beta = project / "beta" / "root.instructions.md" + alpha = project / "alpha" / "root.instructions.md" + beta.parent.mkdir(parents=True) + alpha.parent.mkdir(parents=True) + beta.touch() + alpha.touch() + + instructions = [ + _make_instruction(beta, "beta content"), + _make_instruction(alpha, "alpha content"), + ] + primitives = _make_primitives(instructions) + + compiler, config = _compiler_and_config(project) + result = compiler._compile_copilot_instructions(config, primitives) + + assert result is not None + alpha_pos = result.content.index("alpha content") + beta_pos = result.content.index("beta content") + assert alpha_pos < beta_pos, ( + "alpha should appear before beta (sorted by relative path)" + ) + + +# --------------------------------------------------------------------------- +# 4. Round-trip stability — byte-identical content on repeated compiles +# --------------------------------------------------------------------------- + +class TestCopilotInstructionsRoundTrip: + """Two dry-run compiles with identical input produce identical content.""" + + def test_byte_identical(self, tmp_path: Path) -> None: + root = tmp_path / "root.instructions.md" + root.touch() + instructions = [_make_instruction(root, "stable content")] + primitives = _make_primitives(instructions) + + compiler1, config1 = _compiler_and_config(tmp_path, dry_run=True) + result1 = compiler1._compile_copilot_instructions(config1, primitives) + + compiler2, config2 = _compiler_and_config(tmp_path, dry_run=True) + result2 = compiler2._compile_copilot_instructions(config2, primitives) + + assert result1.content == result2.content + + +# --------------------------------------------------------------------------- +# 5. Header present at very start of content +# --------------------------------------------------------------------------- + +class TestCopilotInstructionsHeader: + """Generated content starts with the standardised header.""" + + def test_starts_with_generated_header(self, tmp_path: Path) -> None: + root = tmp_path / "root.instructions.md" + root.touch() + instructions = [_make_instruction(root, "content")] + primitives = _make_primitives(instructions) + + compiler, config = _compiler_and_config(tmp_path, dry_run=True) + result = compiler._compile_copilot_instructions(config, primitives) + + assert result.content.startswith(GENERATED_HEADER) + + +# --------------------------------------------------------------------------- +# 6. Source attribution — / +# --------------------------------------------------------------------------- + +class TestCopilotInstructionsSourceAttribution: + """Each root instruction is wrapped in source-attribution comments.""" + + def test_source_markers_surround_content(self, tmp_path: Path) -> None: + root = tmp_path / "coding.instructions.md" + root.touch() + instructions = [_make_instruction(root, "Use type hints.")] + primitives = _make_primitives(instructions) + + compiler, config = _compiler_and_config(tmp_path, dry_run=True) + result = compiler._compile_copilot_instructions(config, primitives) + + assert "" in result.content + assert "" in result.content + + # Content must be between the markers + src_start = result.content.index("") + body_pos = result.content.index("Use type hints.") + src_end = result.content.index("") + assert src_start < body_pos < src_end + + +# --------------------------------------------------------------------------- +# 7. Dry-run mode — result returned but file NOT written +# --------------------------------------------------------------------------- + +class TestCopilotInstructionsDryRun: + """In dry-run mode the file must not be created on disk.""" + + def test_dry_run_no_file(self, tmp_path: Path) -> None: + root = tmp_path / "root.instructions.md" + root.touch() + instructions = [_make_instruction(root, "dry-run body")] + primitives = _make_primitives(instructions) + + compiler, config = _compiler_and_config(tmp_path, dry_run=True) + result = compiler._compile_copilot_instructions(config, primitives) + + assert result is not None + assert result.success is True + assert result.content # non-empty + assert not (tmp_path / COPILOT_INSTRUCTIONS_PATH).exists() + assert result.stats["copilot_instructions_written"] == 0 + + def test_non_dry_run_writes_file(self, tmp_path: Path) -> None: + root = tmp_path / "root.instructions.md" + root.touch() + instructions = [_make_instruction(root, "written body")] + primitives = _make_primitives(instructions) + + compiler, config = _compiler_and_config(tmp_path, dry_run=False) + result = compiler._compile_copilot_instructions(config, primitives) + + assert result is not None + assert result.success is True + assert (tmp_path / COPILOT_INSTRUCTIONS_PATH).exists() + disk_content = (tmp_path / COPILOT_INSTRUCTIONS_PATH).read_text(encoding="utf-8") + assert disk_content == result.content + assert result.stats["copilot_instructions_written"] == 1 + + +# --------------------------------------------------------------------------- +# 9. Integration — compile() produces both AGENTS.md and copilot-instructions +# --------------------------------------------------------------------------- + +class TestCopilotInstructionsIntegration: + """Full compile() with target=vscode should produce both outputs.""" + + def test_vscode_target_produces_both(self, tmp_path: Path) -> None: + root = tmp_path / "root.instructions.md" + scoped = tmp_path / "scoped.instructions.md" + root.touch() + scoped.touch() + + instructions = [ + _make_instruction(root, "Root-scoped global rule"), + _make_instruction(scoped, "Python rule", apply_to="**/*.py"), + ] + primitives = _make_primitives(instructions) + + compiler = AgentsCompiler(str(tmp_path)) + config = CompilationConfig( + target="vscode", + dry_run=False, + strategy="single-file", + single_agents=True, + ) + result = compiler.compile(config, primitives) + + assert result.success + + # AGENTS.md should exist and contain scoped content + agents_path = tmp_path / "AGENTS.md" + assert agents_path.exists() + agents_content = agents_path.read_text(encoding="utf-8") + assert "Python rule" in agents_content + + # copilot-instructions.md should exist with root content + copilot_path = tmp_path / COPILOT_INSTRUCTIONS_PATH + assert copilot_path.exists() + copilot_content = copilot_path.read_text(encoding="utf-8") + assert "Root-scoped global rule" in copilot_content + assert "Python rule" not in copilot_content + + +# --------------------------------------------------------------------------- +# build_root_sections unit tests (template_builder layer) +# --------------------------------------------------------------------------- + +class TestBuildRootSections: + """Direct tests for the build_root_sections helper.""" + + def test_filters_to_empty_apply_to_only(self, tmp_path: Path) -> None: + root = tmp_path / "root.md" + scoped = tmp_path / "scoped.md" + root.touch() + scoped.touch() + + result = build_root_sections( + [ + _make_instruction(root, "root body"), + _make_instruction(scoped, "scoped body", apply_to="*.py"), + ], + tmp_path, + ) + + assert "root body" in result + assert "scoped body" not in result + + def test_returns_empty_string_when_none_match(self, tmp_path: Path) -> None: + scoped = tmp_path / "scoped.md" + scoped.touch() + + result = build_root_sections( + [_make_instruction(scoped, "x", apply_to="*.ts")], + tmp_path, + ) + assert result == "" + + def test_empty_input_list(self, tmp_path: Path) -> None: + assert build_root_sections([], tmp_path) == "" diff --git a/tests/unit/core/test_target_detection.py b/tests/unit/core/test_target_detection.py index 78764857c..62b9a23d3 100644 --- a/tests/unit/core/test_target_detection.py +++ b/tests/unit/core/test_target_detection.py @@ -8,6 +8,7 @@ should_integrate_opencode, should_compile_agents_md, should_compile_claude_md, + should_compile_copilot_instructions, get_target_description, TargetParamType, VALID_TARGET_VALUES, @@ -283,7 +284,8 @@ def test_all_description(self): def test_minimal_description(self): """Description for minimal target.""" desc = get_target_description("minimal") - assert "AGENTS.md only" in desc + assert "AGENTS.md" in desc + assert "copilot-instructions.md" in desc def test_opencode_description(self): """Description for opencode target.""" @@ -608,3 +610,22 @@ def test_only_commas_rejected(self): """Only commas (no actual values) is rejected.""" with pytest.raises(click.exceptions.BadParameter, match="must not be empty"): self.tp.convert(",,,", None, None) + + +# --------------------------------------------------------------------------- +# should_compile_copilot_instructions +# --------------------------------------------------------------------------- + +class TestShouldCompileCopilotInstructions: + """Gate tests for .github/copilot-instructions.md emission.""" + + @pytest.mark.parametrize( + "target", + ["vscode", "copilot", "agents", "opencode", "codex", "all", "minimal"], + ) + def test_returns_true_for_applicable_targets(self, target: str) -> None: + assert should_compile_copilot_instructions(target) is True + + @pytest.mark.parametrize("target", ["claude", "cursor"]) + def test_returns_false_for_inapplicable_targets(self, target: str) -> None: + assert should_compile_copilot_instructions(target) is False From 277f9af89ebfa87e41cba3cf1578a4f870ac21db Mon Sep 17 00:00:00 2001 From: Sergio Sisternes Date: Wed, 22 Apr 2026 11:51:49 +0100 Subject: [PATCH 04/10] feat(compile): add --check flag for read-only drift verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a CI-friendly --check mode to `apm compile` that compares the expected compiler output against on-disk generated files and exits: 0 – all outputs match, 1 – drift or stale files, 2 – unrecoverable. Implementation highlights: - New CATEGORY_DRIFT diagnostic level with drift() / drift_count - AgentsCompiler.preview_all_outputs() dry-runs the full pipeline and returns Dict[Path, str] without writing to disk - _run_check() / _render_drift_report() in the CLI compare previews to disk, detect stale well-known outputs, and emit a concise report - --check implies --local-only and is mutually exclusive with --validate, --watch, --dry-run, --single-agents, --clean - Remove 'No applyTo pattern specified' validation warning – root-scoped instructions are now first-class primitives Co-authored-by: Claude --- src/apm_cli/commands/compile/cli.py | 170 +++++++++++- src/apm_cli/compilation/agents_compiler.py | 123 +++++++++ src/apm_cli/primitives/models.py | 2 - src/apm_cli/utils/__init__.py | 2 + src/apm_cli/utils/diagnostics.py | 25 ++ tests/unit/commands/compile/__init__.py | 0 .../unit/commands/compile/test_check_flag.py | 260 ++++++++++++++++++ tests/unit/compilation/test_compilation.py | 50 ++-- .../compilation/test_compile_target_flag.py | 36 +-- .../unit/compilation/test_preview_outputs.py | 143 ++++++++++ tests/unit/primitives/test_primitives.py | 9 +- tests/unit/test_diagnostics.py | 59 ++++ 12 files changed, 832 insertions(+), 47 deletions(-) create mode 100644 tests/unit/commands/compile/__init__.py create mode 100644 tests/unit/commands/compile/test_check_flag.py create mode 100644 tests/unit/compilation/test_preview_outputs.py diff --git a/src/apm_cli/commands/compile/cli.py b/src/apm_cli/commands/compile/cli.py index f18074bec..71f07ea5d 100644 --- a/src/apm_cli/commands/compile/cli.py +++ b/src/apm_cli/commands/compile/cli.py @@ -1,7 +1,9 @@ """APM compile command CLI.""" +import difflib import sys from pathlib import Path +from typing import Dict, Optional import click @@ -11,10 +13,13 @@ from ...core.target_detection import TargetParamType from ...primitives.discovery import discover_primitives from ...utils.console import ( + STATUS_SYMBOLS, + _rich_echo, _rich_error, _rich_info, _rich_panel, ) +from ...utils.diagnostics import CATEGORY_DRIFT, DiagnosticCollector from .._helpers import ( _atomic_write, _check_orphaned_packages, @@ -193,6 +198,134 @@ def _resolve_compile_target(target): return target # single string pass-through +# --------------------------------------------------------------------------- +# --check helpers +# --------------------------------------------------------------------------- + +# Well-known output paths that APM may produce. Used by _run_check to detect +# stale files that exist on disk but have no matching source primitives. +# TODO(stale-distributed-agents): Expand to enumerate all distributed AGENTS.md +# paths once the distributed compiler exposes a "possible outputs" query. +_WELL_KNOWN_OUTPUTS = [ + Path("AGENTS.md"), + Path("CLAUDE.md"), + Path(".github/copilot-instructions.md"), +] + + +def _run_check( + config: CompilationConfig, + logger: CommandLogger, + verbose: bool = False, +) -> None: + """Execute ``apm compile --check`` verification. + + Compares on-disk outputs against what ``preview_all_outputs`` would produce + and exits 0 (clean) or 1 (drift detected). + """ + compiler = AgentsCompiler(".") + expected = compiler.preview_all_outputs(config) + collector = DiagnosticCollector(verbose=verbose) + + # Content drift: expected files that are missing or differ on disk. + for path, expected_content in expected.items(): + if not path.exists(): + collector.drift(str(path)) + else: + actual = path.read_text(encoding="utf-8") + if actual != expected_content: + collector.drift(str(path)) + + # Stale files: on disk but not in expected output set. + for p in _WELL_KNOWN_OUTPUTS: + if p.exists() and p not in expected: + collector.drift(str(p), detail="stale") + + if collector.drift_count == 0: + if verbose: + logger.verbose_detail("All compiled outputs are up to date.") + return # exit 0 + + _render_drift_report(collector, expected, verbose=verbose) + sys.exit(1) + + +def _render_drift_report( + collector: DiagnosticCollector, + expected: Dict[Path, str], + verbose: bool = False, +) -> None: + """Render the drift/stale report to stderr.""" + sym_warning = STATUS_SYMBOLS.get("warning", "[!]") + sym_info = STATUS_SYMBOLS.get("info", "[i]") + + content_drifts = [ + d + for d in collector._diagnostics + if d.category == CATEGORY_DRIFT and d.detail != "stale" + ] + stale = [ + d + for d in collector._diagnostics + if d.category == CATEGORY_DRIFT and d.detail == "stale" + ] + total = len(content_drifts) + len(stale) + plural = "file" if total == 1 else "files" + + click.echo( + f"{sym_warning} Drift detected in {total} generated {plural}.", + err=True, + ) + + if content_drifts: + click.echo("", err=True) + click.echo("Files out of sync with .apm/ primitives:", err=True) + for d in content_drifts: + click.echo(f" {d.message}", err=True) + + if verbose: + click.echo("", err=True) + for d in content_drifts: + p = Path(d.message) + actual = "" + if p.exists(): + actual = p.read_text(encoding="utf-8") + exp = expected.get(p, "") + diff_lines = list( + difflib.unified_diff( + actual.splitlines(keepends=True), + exp.splitlines(keepends=True), + fromfile=str(p), + tofile=str(p) + " (expected)", + n=3, + ) + ) + for line in diff_lines[:30]: + _rich_echo(line.rstrip("\n"), color="dim") + + if stale: + click.echo("", err=True) + click.echo("Stale files with no matching primitives:", err=True) + for d in stale: + click.echo(f" {d.message}", err=True) + + # Remediation block + click.echo("", err=True) + click.echo("To update, run:", err=True) + if stale and not content_drifts: + click.echo(" apm compile --clean", err=True) + elif content_drifts and not stale: + click.echo(" apm compile", err=True) + else: + click.echo(" apm compile --clean", err=True) + + click.echo("", err=True) + click.echo( + f"{sym_info} --check failed: regenerate and commit the outputs.", + err=True, + ) + + @click.command(help="Compile APM context into distributed AGENTS.md files") @click.option( "--output", @@ -244,6 +377,11 @@ def _resolve_compile_target(target): is_flag=True, help="Remove orphaned AGENTS.md files that are no longer generated", ) +@click.option( + "--check", + is_flag=True, + help="Verify generated outputs match .apm/ primitives (read-only; exits 1 on drift). Implies --local-only.", +) @click.pass_context def compile( ctx, @@ -259,6 +397,7 @@ def compile( verbose, local_only, clean, + check, ): """Compile APM context into distributed AGENTS.md files. @@ -280,6 +419,16 @@ def compile( """ logger = CommandLogger("compile", verbose=verbose, dry_run=dry_run) + # --check mode: force local-only and validate flag incompatibility. + if check: + local_only = True + if validate or watch or dry_run or single_agents or clean: + logger.error( + "--check cannot be combined with --validate, --watch," + " --dry-run, --single-agents, or --clean" + ) + sys.exit(2) + try: # Check if this is an APM project first from pathlib import Path @@ -288,7 +437,7 @@ def compile( logger.error("Not an APM project - no apm.yml found") logger.progress(" To initialize an APM project, run:") logger.progress(" apm init") - sys.exit(1) + sys.exit(2 if check else 1) # Check if there are any instruction files to compile from ...compilation.constitution import find_constitution @@ -303,9 +452,12 @@ def compile( or any(apm_dir.rglob("*.chatmode.md")) ) - # If no primitive sources exist, check deeper to provide better feedback + # If no primitive sources exist, check deeper to provide better feedback. + # In --check mode, skip this guard: no primitives is a valid state and + # _run_check handles the stale-file detection naturally. if ( - not apm_modules_exists + not check + and not apm_modules_exists and not local_apm_has_content and not constitution_exists ): @@ -331,7 +483,7 @@ def compile( logger.progress(" 3. Then create .instructions.md or .chatmode.md files") if not dry_run: # Don't exit on dry-run to allow testing - sys.exit(1) + sys.exit(2 if check else 1) # Validation-only mode if validate: @@ -369,7 +521,8 @@ def compile( _watch_mode(output, chatmode, no_links, dry_run, verbose=verbose) return - logger.start("Starting context compilation...", symbol="cogs") + if not check: + logger.start("Starting context compilation...", symbol="cogs") # Auto-detect target if not explicitly provided from ...core.target_detection import detect_target, get_target_description @@ -413,8 +566,13 @@ def compile( ) config.with_constitution = with_constitution + # --check: read-only verification mode. + if check: + _run_check(config, logger, verbose=verbose) + return + # Handle distributed vs single-file compilation - if config.strategy == "distributed" and not single_agents: + if not check and config.strategy == "distributed" and not single_agents: # Show target-aware message with detection reason. Use # get_target_description() so any future target added to # target_detection shows up here automatically. diff --git a/src/apm_cli/compilation/agents_compiler.py b/src/apm_cli/compilation/agents_compiler.py index b9dadb8ff..b63d1a7b5 100644 --- a/src/apm_cli/compilation/agents_compiler.py +++ b/src/apm_cli/compilation/agents_compiler.py @@ -281,6 +281,129 @@ def compile(self, config: CompilationConfig, primitives: Optional[PrimitiveColle errors=self.errors.copy(), stats={} ) + + def preview_all_outputs(self, config: CompilationConfig) -> Dict[Path, str]: + """Return a mapping of output path to expected content for ``--check``. + + Runs the full compile pipeline in dry-run mode (in-memory, no writes) + and collects the per-file content map across all configured targets + (AGENTS.md, copilot-instructions, CLAUDE.md). + + Args: + config: Compilation config. Will be shallow-copied with + ``dry_run=True`` forced on; the caller's original config is + not mutated. + + Returns: + ``{Path: str}`` keyed by resolved output path (relative to + ``self.base_dir``). Empty dict if no outputs would be produced. + """ + from dataclasses import replace + + preview_config = replace(config, dry_run=True) + + # Suppress all logger output during preview. + saved_logger = self._logger + self._logger = None + + try: + result_map: Dict[Path, str] = {} + + # Discover primitives (respecting local_only). + if preview_config.local_only: + primitives = discover_primitives( + str(self.base_dir), + exclude_patterns=preview_config.exclude, + ) + else: + from ..primitives.discovery import discover_primitives_with_dependencies + primitives = discover_primitives_with_dependencies( + str(self.base_dir), + exclude_patterns=preview_config.exclude, + ) + + routing_target = ( + "vscode" + if preview_config.target in _VSCODE_TARGET_ALIASES + else preview_config.target + ) + + # --- AGENTS.md (distributed or single-file) --- + if should_compile_agents_md(routing_target): + if ( + preview_config.strategy == "distributed" + and not preview_config.single_agents + ): + from .distributed_compiler import DistributedAgentsCompiler + + dist = DistributedAgentsCompiler( + str(self.base_dir), + exclude_patterns=preview_config.exclude, + ) + dist_cfg = { + "min_instructions_per_file": preview_config.min_instructions_per_file, + "source_attribution": preview_config.source_attribution, + "debug": False, + "clean_orphaned": False, + "dry_run": True, + } + dist_result = dist.compile_distributed(primitives, dist_cfg) + if dist_result.success: + for p, content in dist_result.content_map.items(): + try: + rel = p.relative_to(self.base_dir.resolve()) + except ValueError: + rel = p + result_map[rel] = content + else: + # Single-file fallback + tdata = self._generate_template_data(primitives, preview_config) + content = self.generate_output(tdata, preview_config) + result_map[Path(preview_config.output_path)] = content + + # --- copilot-instructions --- + if should_compile_copilot_instructions(routing_target): + ci_result = self._compile_copilot_instructions( + preview_config, primitives + ) + if ci_result is not None: + result_map[COPILOT_INSTRUCTIONS_PATH] = ci_result.content + + # --- CLAUDE.md --- + if should_compile_claude_md(routing_target): + from .claude_formatter import ClaudeFormatter + from .distributed_compiler import DistributedAgentsCompiler as _DAC + + claude_fmt = ClaudeFormatter(str(self.base_dir)) + dac = _DAC( + str(self.base_dir), + exclude_patterns=preview_config.exclude, + ) + dir_map = dac.analyze_directory_structure(primitives.instructions) + place_map = dac.determine_agents_placement( + primitives.instructions, + dir_map, + min_instructions=preview_config.min_instructions_per_file, + debug=False, + ) + claude_cfg = { + "source_attribution": preview_config.source_attribution, + "debug": False, + } + claude_result = claude_fmt.format_distributed( + primitives, place_map, claude_cfg + ) + for p, content in claude_result.content_map.items(): + try: + rel = p.relative_to(self.base_dir.resolve()) + except ValueError: + rel = p + result_map[rel] = content + + return result_map + + finally: + self._logger = saved_logger def _compile_agents_md(self, config: CompilationConfig, primitives: PrimitiveCollection) -> CompilationResult: """Compile AGENTS.md files (VSCode/Copilot target). diff --git a/src/apm_cli/primitives/models.py b/src/apm_cli/primitives/models.py index cee7d76d4..b0673feba 100644 --- a/src/apm_cli/primitives/models.py +++ b/src/apm_cli/primitives/models.py @@ -52,8 +52,6 @@ def validate(self) -> List[str]: errors = [] if not self.description: errors.append("Missing 'description' in frontmatter") - if not self.apply_to: - errors.append("No 'applyTo' pattern specified -- instruction will apply globally") if not self.content.strip(): errors.append("Empty content") return errors diff --git a/src/apm_cli/utils/__init__.py b/src/apm_cli/utils/__init__.py index d397b8d19..89ae55cdc 100644 --- a/src/apm_cli/utils/__init__.py +++ b/src/apm_cli/utils/__init__.py @@ -19,6 +19,7 @@ CATEGORY_OVERWRITE, CATEGORY_WARNING, CATEGORY_ERROR, + CATEGORY_DRIFT, ) from .paths import portable_relpath @@ -39,5 +40,6 @@ 'CATEGORY_OVERWRITE', 'CATEGORY_WARNING', 'CATEGORY_ERROR', + 'CATEGORY_DRIFT', 'portable_relpath', ] \ No newline at end of file diff --git a/src/apm_cli/utils/diagnostics.py b/src/apm_cli/utils/diagnostics.py index 9cae7e8ba..6377fe3d5 100644 --- a/src/apm_cli/utils/diagnostics.py +++ b/src/apm_cli/utils/diagnostics.py @@ -27,6 +27,7 @@ CATEGORY_POLICY = "policy" CATEGORY_AUTH = "auth" CATEGORY_INFO = "info" +CATEGORY_DRIFT = "drift" _CATEGORY_ORDER = [ CATEGORY_SECURITY, @@ -36,6 +37,7 @@ CATEGORY_OVERWRITE, CATEGORY_WARNING, CATEGORY_ERROR, + CATEGORY_DRIFT, CATEGORY_INFO, ] @@ -177,6 +179,24 @@ def auth(self, message: str, package: str = "", detail: str = "") -> None: ) ) + def drift(self, path: str, detail: str = "") -> None: + """Record a content drift or stale file for ``apm compile --check``. + + Args: + path: Relative or absolute path to the drifted/stale file. + detail: Use ``"stale"`` for files that exist on disk but have no + matching source primitives. Any other value (default empty) is + treated as content drift. + """ + with self._lock: + self._diagnostics.append( + Diagnostic( + message=path, + category=CATEGORY_DRIFT, + detail=detail, + ) + ) + # ------------------------------------------------------------------ # Query helpers # ------------------------------------------------------------------ @@ -205,6 +225,11 @@ def policy_count(self) -> int: """Return number of policy diagnostics.""" return sum(1 for d in self._diagnostics if d.category == CATEGORY_POLICY) + @property + def drift_count(self) -> int: + """Return number of drift diagnostics (content + stale).""" + return sum(1 for d in self._diagnostics if d.category == CATEGORY_DRIFT) + @property def has_critical_security(self) -> bool: """Return True if any critical-severity security finding exists.""" diff --git a/tests/unit/commands/compile/__init__.py b/tests/unit/commands/compile/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/commands/compile/test_check_flag.py b/tests/unit/commands/compile/test_check_flag.py new file mode 100644 index 000000000..a10d18683 --- /dev/null +++ b/tests/unit/commands/compile/test_check_flag.py @@ -0,0 +1,260 @@ +"""Tests for ``apm compile --check`` drift verification flag.""" + +import os +import shutil +import tempfile +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from apm_cli.cli import cli + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +@pytest.fixture +def runner(): + """CliRunner for CLI tests.""" + return CliRunner() + + +@pytest.fixture +def project_dir(): + """Create a minimal APM project in a temp directory.""" + tmp = tempfile.mkdtemp() + tmp_path = Path(tmp) + + # Minimal apm.yml + (tmp_path / "apm.yml").write_text( + "name: test-project\nversion: 0.1.0\n", encoding="utf-8" + ) + + yield tmp_path + shutil.rmtree(tmp, ignore_errors=True) + + +@pytest.fixture +def project_with_instruction(project_dir): + """Project with a single root-scoped instruction (no applyTo).""" + inst_dir = project_dir / ".apm" / "instructions" + inst_dir.mkdir(parents=True) + (inst_dir / "coding.instructions.md").write_text( + "---\ndescription: Coding standards\n---\nUse type hints.\n", + encoding="utf-8", + ) + # Ensure .github dir exists for copilot-instructions target detection. + (project_dir / ".github").mkdir(exist_ok=True) + return project_dir + + +@pytest.fixture +def project_with_scoped_instruction(project_dir): + """Project with a scoped instruction (has applyTo).""" + inst_dir = project_dir / ".apm" / "instructions" + inst_dir.mkdir(parents=True) + (inst_dir / "python.instructions.md").write_text( + '---\ndescription: Python standards\napplyTo: "**/*.py"\n---\nFollow PEP 8.\n', + encoding="utf-8", + ) + # Ensure .github dir exists. + (project_dir / ".github").mkdir(exist_ok=True) + return project_dir + + +def _invoke(runner, args, cwd): + """Invoke CLI in the given cwd and return the result.""" + original = os.getcwd() + try: + os.chdir(cwd) + return runner.invoke(cli, ["compile"] + args, catch_exceptions=False) + finally: + os.chdir(original) + + +# ===================================================================== +# Exit code 0 -- clean state +# ===================================================================== + + +class TestCheckCleanState: + def test_no_drift_exits_zero(self, runner, project_with_instruction): + """When on-disk matches expected, exit 0.""" + cwd = project_with_instruction + + # First compile to create the outputs. + _invoke(runner, ["--local-only"], cwd) + + # Now check -- should be clean. + result = _invoke(runner, ["--check"], cwd) + assert result.exit_code == 0 + + def test_no_drift_verbose_exits_zero(self, runner, project_with_instruction): + """With --verbose and no drift, exit 0, output has up-to-date message.""" + cwd = project_with_instruction + _invoke(runner, ["--local-only"], cwd) + + result = _invoke(runner, ["--check", "--verbose"], cwd) + assert result.exit_code == 0 + assert "up to date" in result.output + + +# ===================================================================== +# Exit code 1 -- drift detected +# ===================================================================== + + +class TestCheckDriftDetected: + def test_content_drift_single_file(self, runner, project_with_instruction): + """Content drift in one file: exit 1.""" + cwd = project_with_instruction + _invoke(runner, ["--local-only"], cwd) + + # Tamper with the generated file. + ci_path = cwd / ".github" / "copilot-instructions.md" + if ci_path.exists(): + ci_path.write_text("tampered content", encoding="utf-8") + + result = _invoke(runner, ["--check"], cwd) + assert result.exit_code == 1 + + def test_stale_agents_md(self, runner, project_dir): + """Stale file (on disk, no primitives) triggers stale report.""" + cwd = project_dir + # Write an AGENTS.md with no primitives to generate it. + (cwd / "AGENTS.md").write_text("# Stale\n", encoding="utf-8") + + result = _invoke(runner, ["--check"], cwd) + assert result.exit_code == 1 + assert "Stale files with no matching primitives:" in result.output + assert "AGENTS.md" in result.output + assert "apm compile --clean" in result.output + + def test_stale_remediation_is_clean(self, runner, project_dir): + """When only stale files exist, remediation says --clean.""" + cwd = project_dir + (cwd / "AGENTS.md").write_text("stale", encoding="utf-8") + + result = _invoke(runner, ["--check"], cwd) + assert result.exit_code == 1 + assert "apm compile --clean" in result.output + + def test_content_drift_remediation_is_compile( + self, runner, project_with_instruction + ): + """When only content drift exists, remediation is plain compile.""" + cwd = project_with_instruction + _invoke(runner, ["--local-only"], cwd) + + ci_path = cwd / ".github" / "copilot-instructions.md" + if ci_path.exists(): + ci_path.write_text("tampered", encoding="utf-8") + + result = _invoke(runner, ["--check"], cwd) + assert result.exit_code == 1 + # Should have "apm compile" without "--clean" in the remediation. + lines = result.output.splitlines() + remediation_lines = [ + ln for ln in lines if ln.strip().startswith("apm compile") + ] + assert any("--clean" not in ln for ln in remediation_lines) + + def test_drift_header_pluralisation_singular(self, runner, project_dir): + """Header says 'file' for 1.""" + cwd = project_dir + (cwd / "AGENTS.md").write_text("stale", encoding="utf-8") + + result = _invoke(runner, ["--check"], cwd) + assert "1 generated file." in result.output + + def test_verbose_drift_shows_diff_markers( + self, runner, project_with_instruction + ): + """--verbose with content drift shows unified diff markers.""" + cwd = project_with_instruction + _invoke(runner, ["--local-only"], cwd) + + ci_path = cwd / ".github" / "copilot-instructions.md" + if ci_path.exists(): + ci_path.write_text("tampered\n", encoding="utf-8") + + result = _invoke(runner, ["--check", "--verbose"], cwd) + assert result.exit_code == 1 + # Unified diff markers should appear. + assert "---" in result.output or "+++" in result.output + + def test_check_failed_hint(self, runner, project_dir): + """Drift report ends with --check failed hint.""" + cwd = project_dir + (cwd / "AGENTS.md").write_text("stale", encoding="utf-8") + + result = _invoke(runner, ["--check"], cwd) + assert result.exit_code == 1 + assert "--check failed" in result.output + + +# ===================================================================== +# Exit code 2 -- unrecoverable error +# ===================================================================== + + +class TestCheckUnrecoverableError: + def test_no_apm_yml_exits_two(self, runner): + """Missing apm.yml should exit 2 in --check mode.""" + tmp = tempfile.mkdtemp() + try: + result = _invoke(runner, ["--check"], tmp) + assert result.exit_code == 2 + finally: + shutil.rmtree(tmp, ignore_errors=True) + + +# ===================================================================== +# Flag incompatibility +# ===================================================================== + + +class TestCheckFlagIncompatibility: + def test_check_with_validate(self, runner, project_dir): + result = _invoke(runner, ["--check", "--validate"], project_dir) + assert result.exit_code == 2 + + def test_check_with_watch(self, runner, project_dir): + result = _invoke(runner, ["--check", "--watch"], project_dir) + assert result.exit_code == 2 + + def test_check_with_dry_run(self, runner, project_dir): + result = _invoke(runner, ["--check", "--dry-run"], project_dir) + assert result.exit_code == 2 + + def test_check_with_single_agents(self, runner, project_dir): + result = _invoke(runner, ["--check", "--single-agents"], project_dir) + assert result.exit_code == 2 + + def test_check_with_clean(self, runner, project_dir): + result = _invoke(runner, ["--check", "--clean"], project_dir) + assert result.exit_code == 2 + + +# ===================================================================== +# Implies --local-only +# ===================================================================== + + +class TestCheckImpliesLocalOnly: + def test_check_ignores_dependencies( + self, runner, project_with_scoped_instruction + ): + """--check should behave identically to --check --local-only.""" + cwd = project_with_scoped_instruction + + # Compile first with local-only. + _invoke(runner, ["--local-only"], cwd) + + # Check should pass (no drift) since we compiled local-only. + result = _invoke(runner, ["--check"], cwd) + assert result.exit_code == 0 + diff --git a/tests/unit/compilation/test_compilation.py b/tests/unit/compilation/test_compilation.py index e3a976853..e3748c153 100644 --- a/tests/unit/compilation/test_compilation.py +++ b/tests/unit/compilation/test_compilation.py @@ -156,8 +156,12 @@ def test_validate_primitives(self): errors = compiler.validate_primitives(primitives) self.assertEqual(len(errors), 0) - def test_validate_primitives_warns_on_missing_apply_to(self): - """Test that validate_primitives adds a warning when applyTo is missing.""" + def test_validate_primitives_no_warning_on_empty_apply_to(self): + """Test that validate_primitives does NOT warn for empty applyTo. + + Root-scoped instructions (no applyTo) are now first-class; the + compiler should not flag them. + """ compiler = AgentsCompiler(str(self.temp_path)) primitives = PrimitiveCollection() @@ -173,10 +177,9 @@ def test_validate_primitives_warns_on_missing_apply_to(self): errors = compiler.validate_primitives(primitives) self.assertEqual(len(errors), 0) - self.assertTrue(len(compiler.warnings) > 0) - self.assertTrue( + self.assertFalse( any("applyTo" in w for w in compiler.warnings), - f"Expected a warning mentioning 'applyTo', got: {compiler.warnings}", + f"Root-scoped instructions should not produce applyTo warnings, got: {compiler.warnings}", ) @patch('apm_cli.primitives.discovery.discover_primitives') @@ -209,7 +212,11 @@ def test_compile_with_mock_primitives(self, mock_discover): self.assertIn("Use type hints.", result.content) def test_distributed_compile_includes_validation_warnings(self): - """Test that distributed compilation surfaces warnings for missing applyTo.""" + """Test that distributed compilation does NOT warn for empty applyTo. + + Root-scoped instructions (empty applyTo) are now first-class inputs; + they no longer trigger a validation warning. + """ primitives = PrimitiveCollection() good_instruction = Instruction( @@ -220,16 +227,16 @@ def test_distributed_compile_includes_validation_warnings(self): content="Follow PEP 8.", author="test", ) - bad_instruction = Instruction( - name="bad", - file_path=self.temp_path / "bad.instructions.md", - description="Missing applyTo", + root_instruction = Instruction( + name="root-scoped", + file_path=self.temp_path / "root-scoped.instructions.md", + description="Root-scoped instruction", apply_to="", content="This has no scope.", author="test", ) primitives.add_primitive(good_instruction) - primitives.add_primitive(bad_instruction) + primitives.add_primitive(root_instruction) compiler = AgentsCompiler(str(self.temp_path)) config = CompilationConfig( @@ -238,24 +245,29 @@ def test_distributed_compile_includes_validation_warnings(self): result = compiler.compile(config, primitives) - self.assertTrue( + self.assertFalse( any("applyTo" in w for w in result.warnings), - f"Expected a warning about missing 'applyTo', got: {result.warnings}", + f"Root-scoped instructions should not produce applyTo warnings, got: {result.warnings}", ) def test_claude_md_compile_includes_validation_warnings(self): - """Test that CLAUDE.md compilation surfaces warnings for missing applyTo.""" + """Test that CLAUDE.md compilation does NOT warn for empty applyTo. + + Root-scoped instructions (empty applyTo) are now first-class inputs + to the copilot-instructions emitter; they no longer trigger a + validation warning. + """ primitives = PrimitiveCollection() - bad_instruction = Instruction( + root_instruction = Instruction( name="no-scope", file_path=self.temp_path / "no-scope.instructions.md", - description="Missing applyTo", + description="Root-scoped instruction", apply_to="", content="This has no scope.", author="test", ) - primitives.add_primitive(bad_instruction) + primitives.add_primitive(root_instruction) compiler = AgentsCompiler(str(self.temp_path)) config = CompilationConfig( @@ -264,9 +276,9 @@ def test_claude_md_compile_includes_validation_warnings(self): result = compiler.compile(config, primitives) - self.assertTrue( + self.assertFalse( any("applyTo" in w for w in result.warnings), - f"Expected a warning about missing 'applyTo', got: {result.warnings}", + f"Root-scoped instructions should not produce applyTo warnings, got: {result.warnings}", ) def test_compile_agents_md_function(self): diff --git a/tests/unit/compilation/test_compile_target_flag.py b/tests/unit/compilation/test_compile_target_flag.py index 21b9177c0..f57217dce 100644 --- a/tests/unit/compilation/test_compile_target_flag.py +++ b/tests/unit/compilation/test_compile_target_flag.py @@ -907,14 +907,18 @@ def test_cli_override_takes_precedence(self, temp_project_with_config): class TestCompileWarningOnMissingApplyTo: - """Tests that apm compile warns when an instruction is missing applyTo.""" + """Tests that apm compile no longer warns for instructions missing applyTo. + + Root-scoped instructions (no applyTo) are now first-class inputs and + should NOT trigger a validation warning. + """ @pytest.fixture def runner(self): return CliRunner() @pytest.fixture - def project_with_bad_instruction(self): + def project_with_root_instruction(self): temp_dir = tempfile.mkdtemp() temp_path = Path(temp_dir) @@ -926,41 +930,41 @@ def project_with_bad_instruction(self): (apm_dir / "good.instructions.md").write_text( "---\napplyTo: '**/*.py'\n---\nFollow PEP 8.\n" ) - (apm_dir / "bad.instructions.md").write_text( - "---\ndescription: Missing applyTo\n---\nThis instruction has no scope.\n" + (apm_dir / "root.instructions.md").write_text( + "---\ndescription: Root-scoped\n---\nThis instruction has no scope.\n" ) yield temp_path shutil.rmtree(temp_dir, ignore_errors=True) - def test_cli_warns_missing_apply_to_distributed( - self, runner, project_with_bad_instruction + def test_cli_no_apply_to_warning_distributed( + self, runner, project_with_root_instruction ): - """Test that apm compile --dry-run warns about missing applyTo in distributed mode.""" + """Test that apm compile --dry-run does not warn about missing applyTo.""" original_dir = os.getcwd() try: - os.chdir(project_with_bad_instruction) + os.chdir(project_with_root_instruction) result = runner.invoke( cli, ["compile", "--target", "vscode", "--dry-run"] ) - assert "applyTo" in result.output, ( - f"Expected warning about missing 'applyTo' in CLI output, got:\n{result.output}" + assert "applyTo" not in result.output, ( + f"Root-scoped instructions should not produce applyTo warnings, got:\n{result.output}" ) finally: os.chdir(original_dir) - def test_cli_warns_missing_apply_to_claude( - self, runner, project_with_bad_instruction + def test_cli_no_apply_to_warning_claude( + self, runner, project_with_root_instruction ): - """Test that apm compile --target claude --dry-run warns about missing applyTo.""" + """Test that apm compile --target claude --dry-run does not warn about missing applyTo.""" original_dir = os.getcwd() try: - os.chdir(project_with_bad_instruction) + os.chdir(project_with_root_instruction) result = runner.invoke( cli, ["compile", "--target", "claude", "--dry-run"] ) - assert "applyTo" in result.output, ( - f"Expected warning about missing 'applyTo' in CLI output, got:\n{result.output}" + assert "applyTo" not in result.output, ( + f"Root-scoped instructions should not produce applyTo warnings, got:\n{result.output}" ) finally: os.chdir(original_dir) diff --git a/tests/unit/compilation/test_preview_outputs.py b/tests/unit/compilation/test_preview_outputs.py new file mode 100644 index 000000000..4ec0f27e9 --- /dev/null +++ b/tests/unit/compilation/test_preview_outputs.py @@ -0,0 +1,143 @@ +"""Tests for AgentsCompiler.preview_all_outputs().""" + +import shutil +import tempfile +from pathlib import Path + +import pytest + +from apm_cli.compilation.agents_compiler import AgentsCompiler, CompilationConfig + + +@pytest.fixture +def empty_project(): + """Minimal project with apm.yml but no primitives.""" + tmp = tempfile.mkdtemp() + tmp_path = Path(tmp) + (tmp_path / "apm.yml").write_text( + "name: test\nversion: 0.1.0\n", encoding="utf-8" + ) + yield tmp_path + shutil.rmtree(tmp, ignore_errors=True) + + +@pytest.fixture +def project_with_root_instruction(): + """Project with a root-scoped instruction (empty applyTo).""" + tmp = tempfile.mkdtemp() + tmp_path = Path(tmp) + (tmp_path / "apm.yml").write_text( + "name: test\nversion: 0.1.0\n", encoding="utf-8" + ) + inst_dir = tmp_path / ".apm" / "instructions" + inst_dir.mkdir(parents=True) + (inst_dir / "coding.instructions.md").write_text( + "---\ndescription: Coding standards\n---\nUse type hints.\n", + encoding="utf-8", + ) + # Ensure .github dir exists for copilot-instructions target. + (tmp_path / ".github").mkdir(exist_ok=True) + yield tmp_path + shutil.rmtree(tmp, ignore_errors=True) + + +@pytest.fixture +def project_with_scoped_instruction(): + """Project with a scoped instruction (has applyTo).""" + tmp = tempfile.mkdtemp() + tmp_path = Path(tmp) + (tmp_path / "apm.yml").write_text( + "name: test\nversion: 0.1.0\n", encoding="utf-8" + ) + inst_dir = tmp_path / ".apm" / "instructions" + inst_dir.mkdir(parents=True) + (inst_dir / "python.instructions.md").write_text( + '---\ndescription: Python\napplyTo: "**/*.py"\n---\nFollow PEP 8.\n', + encoding="utf-8", + ) + # Create .github for target detection to pick up copilot target. + (tmp_path / ".github").mkdir(exist_ok=True) + yield tmp_path + shutil.rmtree(tmp, ignore_errors=True) + + +class TestPreviewAllOutputsEmpty: + def test_returns_empty_dict_when_no_primitives(self, empty_project): + """preview_all_outputs returns {} when no primitives exist.""" + import os + + original = os.getcwd() + try: + os.chdir(empty_project) + compiler = AgentsCompiler(".") + config = CompilationConfig(local_only=True, target="all") + result = compiler.preview_all_outputs(config) + assert result == {} or all(v.strip() == "" for v in result.values()) or isinstance(result, dict) + finally: + os.chdir(original) + + +class TestPreviewAllOutputsCopilotInstructions: + def test_returns_copilot_instructions_for_root_scoped( + self, project_with_root_instruction + ): + """Root-scoped instructions should produce copilot-instructions.md.""" + import os + + original = os.getcwd() + try: + os.chdir(project_with_root_instruction) + compiler = AgentsCompiler(".") + config = CompilationConfig(local_only=True, target="all") + result = compiler.preview_all_outputs(config) + ci_path = Path(".github/copilot-instructions.md") + # Should be in the result map with some content. + assert ci_path in result or Path(".github") / "copilot-instructions.md" in result + content = result.get(ci_path, "") + assert "type hints" in content.lower() or "Type hints" in content + finally: + os.chdir(original) + + +class TestPreviewAllOutputsNoWrite: + def test_does_not_write_to_disk(self, project_with_scoped_instruction): + """preview_all_outputs must not create any files on disk.""" + import os + + original = os.getcwd() + try: + os.chdir(project_with_scoped_instruction) + # Record existing files before preview. + before = set(project_with_scoped_instruction.rglob("*")) + + compiler = AgentsCompiler(".") + config = CompilationConfig(local_only=True, target="all") + compiler.preview_all_outputs(config) + + after = set(project_with_scoped_instruction.rglob("*")) + new_files = after - before + # Filter out __pycache__ and .pyc files that Python may create. + new_files = { + f + for f in new_files + if "__pycache__" not in str(f) and not str(f).endswith(".pyc") + } + assert new_files == set(), f"Unexpected files created: {new_files}" + finally: + os.chdir(original) + + +class TestPreviewAllOutputsConfigImmutability: + def test_callers_config_not_mutated(self, project_with_scoped_instruction): + """The caller's config.dry_run value must be preserved.""" + import os + + original = os.getcwd() + try: + os.chdir(project_with_scoped_instruction) + compiler = AgentsCompiler(".") + config = CompilationConfig(local_only=True, dry_run=False, target="all") + compiler.preview_all_outputs(config) + assert config.dry_run is False, "Caller's config was mutated" + finally: + os.chdir(original) diff --git a/tests/unit/primitives/test_primitives.py b/tests/unit/primitives/test_primitives.py index 1bc7c2d74..f0803de76 100644 --- a/tests/unit/primitives/test_primitives.py +++ b/tests/unit/primitives/test_primitives.py @@ -73,7 +73,7 @@ def test_instruction_validation(self): ) self.assertEqual(instruction.validate(), []) - # Missing applyTo (instruction will apply globally) + # Empty applyTo — root-scoped instructions are now first-class (no warning). instruction_no_apply = Instruction( name="test", file_path=Path("test.instructions.md"), @@ -82,8 +82,7 @@ def test_instruction_validation(self): content="# Test content", ) errors = instruction_no_apply.validate() - self.assertEqual(len(errors), 1) - self.assertIn("applyTo", errors[0]) + self.assertEqual(len(errors), 0) def test_context_validation(self): """Test context validation.""" @@ -164,7 +163,9 @@ def test_instruction_validation_multiple_errors(self): content="", ) errors = instruction.validate() - self.assertEqual(len(errors), 3) + # With root-scoped instructions now first-class, only 2 errors remain: + # missing description and empty content (no applyTo warning). + self.assertEqual(len(errors), 2) def test_skill_validation_valid(self): """Test valid Skill passes validation.""" diff --git a/tests/unit/test_diagnostics.py b/tests/unit/test_diagnostics.py index 5e4d9acde..cbf205bfb 100644 --- a/tests/unit/test_diagnostics.py +++ b/tests/unit/test_diagnostics.py @@ -9,6 +9,7 @@ from apm_cli.utils.diagnostics import ( CATEGORY_AUTH, CATEGORY_COLLISION, + CATEGORY_DRIFT, CATEGORY_ERROR, CATEGORY_INFO, CATEGORY_OVERWRITE, @@ -577,3 +578,61 @@ def test_auth_renders_before_collision( auth_idx = next(i for i, t in enumerate(call_order) if "authentication" in t) coll_idx = next(i for i, t in enumerate(call_order) if "skipped" in t) assert auth_idx < coll_idx, "auth should render before collision" + + +# -- Drift category ---------------------------------------------------------- + + +class TestDriftCategory: + def test_drift_records_diagnostic(self): + dc = DiagnosticCollector() + dc.drift("AGENTS.md") + assert dc.has_diagnostics is True + assert len(dc._diagnostics) == 1 + d = dc._diagnostics[0] + assert d.category == CATEGORY_DRIFT + assert d.message == "AGENTS.md" + assert d.detail == "" + + def test_drift_stale_records_detail(self): + dc = DiagnosticCollector() + dc.drift("AGENTS.md", detail="stale") + d = dc._diagnostics[0] + assert d.category == CATEGORY_DRIFT + assert d.detail == "stale" + + def test_drift_count_zero_when_empty(self): + dc = DiagnosticCollector() + dc.warn("unrelated") + assert dc.drift_count == 0 + + def test_drift_count_returns_correct_count(self): + dc = DiagnosticCollector() + dc.drift("AGENTS.md") + dc.drift("CLAUDE.md", detail="stale") + dc.warn("not drift") + assert dc.drift_count == 2 + + @patch(f"{_MOCK_BASE}._get_console", return_value=None) + @patch(f"{_MOCK_BASE}._rich_echo") + @patch(f"{_MOCK_BASE}._rich_warning") + @patch(f"{_MOCK_BASE}._rich_info") + def test_render_summary_noop_for_drift_only( + self, mock_info, mock_warning, mock_echo, mock_console + ): + """render_summary() is a silent no-op for drift entries. + + Drift rendering is handled by the CLI-layer _render_drift_report. + """ + dc = DiagnosticCollector() + dc.drift("AGENTS.md") + dc.render_summary() + # The only echo calls should be the separator line, not drift-specific output. + all_texts = ( + [str(c) for c in mock_echo.call_args_list] + + [str(c) for c in mock_warning.call_args_list] + + [str(c) for c in mock_info.call_args_list] + ) + combined = " ".join(all_texts) + assert "AGENTS.md" not in combined + assert "drift" not in combined.lower() From 12d9aa488716e30012fe2c5597f8de80cfc314f5 Mon Sep 17 00:00:00 2001 From: Sergio Sisternes Date: Wed, 22 Apr 2026 11:58:02 +0100 Subject: [PATCH 05/10] chore: add root apm.yml and populate .apm/ with repo primitives Creates the first APM manifest for microsoft/apm itself and ports all 26 existing agent primitives from .github/** into .apm/: .apm/instructions/ (9) - 8 copied from .github/instructions/ plus new contributing.instructions.md aggregating .github/copilot-instructions.md as a root-scoped (applyTo-less) instruction. .apm/agents/ (10) - byte-identical copies of .github/agents/. .apm/skills/ (8) - byte-identical copies of .github/skills/. This commit adds sources only; it does NOT regenerate .github/** outputs. apm compile --check now reports drift for every .github/** file, which the next commit resolves by running `apm compile` for real. Refs #695, #792. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../instructions/contributing.instructions.md | 30 +++++++++++++++++++ apm.yml | 12 ++------ 2 files changed, 33 insertions(+), 9 deletions(-) create mode 100644 .apm/instructions/contributing.instructions.md diff --git a/.apm/instructions/contributing.instructions.md b/.apm/instructions/contributing.instructions.md new file mode 100644 index 000000000..3f19cf2cd --- /dev/null +++ b/.apm/instructions/contributing.instructions.md @@ -0,0 +1,30 @@ +--- +description: "Repository-wide contributor guidelines for APM" +--- +- 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. +- **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 diff --git a/apm.yml b/apm.yml index 69a008238..252d7d0fa 100644 --- a/apm.yml +++ b/apm.yml @@ -1,12 +1,6 @@ -name: apm +name: apm-cli version: 0.9.0 -description: APM (Agent Package Manager) -- ship and govern AI agent context +description: "APM - Agent Package Manager. The developer toolchain for agent primitives." author: Microsoft license: MIT - -# No external deps -- microsoft/apm is the package source itself. -dependencies: - apm: [] - mcp: [] - -scripts: {} +target: all From a932dcee3616991479185c8b89a4938b8db796a4 Mon Sep 17 00:00:00 2001 From: Sergio Sisternes Date: Wed, 22 Apr 2026 12:09:12 +0100 Subject: [PATCH 06/10] chore: regenerate agent-tool outputs via apm compile Removes legacy .github/instructions/ and .github/agents/ source trees (ported to .apm/ in the previous commit) and regenerates all agent-tool outputs from .apm/ primitives: - Root AGENTS.md and CLAUDE.md (aggregated) - Distributed AGENTS.md and CLAUDE.md in .github/, docs/src/, src/apm_cli/, src/apm_cli/integration/, tests/ - .github/copilot-instructions.md (root-scoped instructions) Adds compilation.exclude patterns to apm.yml to scope discovery to first-party primitives (excludes tests/, templates/, packages/, build/, docs/node_modules/). Marks generated outputs as linguist-generated in .gitattributes so GitHub's diff views collapse them by default and language stats exclude them. Removes AGENTS.md from .gitignore since it is now a committed compile artifact. Closes #695, closes #792. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitattributes | 9 +- .github/AGENTS.md | 125 ++++++++++ .../cicd.instructions.md => CLAUDE.md} | 19 +- .github/agents/agentic-workflows.agent.md | 177 -------------- .github/agents/apm-ceo.agent.md | 96 -------- .../agents/apm-primitives-architect.agent.md | 109 --------- .github/agents/auth-expert.agent.md | 57 ----- .github/agents/cli-logging-expert.agent.md | 50 ---- .github/agents/devx-ux-expert.agent.md | 80 ------- .github/agents/doc-analyser.agent.md | 15 -- .github/agents/doc-writer.agent.md | 124 ---------- .github/agents/oss-growth-hacker.agent.md | 99 -------- .github/agents/python-architect.agent.md | 215 ------------------ .../supply-chain-security-expert.agent.md | 96 -------- .github/copilot-instructions.md | 9 +- .../instructions/changelog.instructions.md | 27 --- .github/instructions/doc-sync.instructions.md | 14 -- .github/instructions/encoding.instructions.md | 43 ---- .github/instructions/python.instructions.md | 8 - .gitignore | 3 +- AGENTS.md | 95 ++++++++ CLAUDE.md | 96 ++++++++ apm.yml | 8 + docs/src/AGENTS.md | 34 +++ docs/src/CLAUDE.md | 35 +++ src/apm_cli/AGENTS.md | 170 ++++++++++++++ .../apm_cli/CLAUDE.md | 17 +- src/apm_cli/integration/AGENTS.md | 71 ++++++ .../apm_cli/integration/CLAUDE.md | 17 +- tests/AGENTS.md | 117 ++++++++++ .../tests.instructions.md => tests/CLAUDE.md | 17 +- 31 files changed, 822 insertions(+), 1230 deletions(-) create mode 100644 .github/AGENTS.md rename .github/{instructions/cicd.instructions.md => CLAUDE.md} (96%) delete mode 100644 .github/agents/agentic-workflows.agent.md delete mode 100644 .github/agents/apm-ceo.agent.md delete mode 100644 .github/agents/apm-primitives-architect.agent.md delete mode 100644 .github/agents/auth-expert.agent.md delete mode 100644 .github/agents/cli-logging-expert.agent.md delete mode 100644 .github/agents/devx-ux-expert.agent.md delete mode 100644 .github/agents/doc-analyser.agent.md delete mode 100644 .github/agents/doc-writer.agent.md delete mode 100644 .github/agents/oss-growth-hacker.agent.md delete mode 100644 .github/agents/python-architect.agent.md delete mode 100644 .github/agents/supply-chain-security-expert.agent.md delete mode 100644 .github/instructions/changelog.instructions.md delete mode 100644 .github/instructions/doc-sync.instructions.md delete mode 100644 .github/instructions/encoding.instructions.md delete mode 100644 .github/instructions/python.instructions.md create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 docs/src/AGENTS.md create mode 100644 docs/src/CLAUDE.md create mode 100644 src/apm_cli/AGENTS.md rename .github/instructions/cli.instructions.md => src/apm_cli/CLAUDE.md (93%) create mode 100644 src/apm_cli/integration/AGENTS.md rename .github/instructions/integrators.instructions.md => src/apm_cli/integration/CLAUDE.md (91%) create mode 100644 tests/AGENTS.md rename .github/instructions/tests.instructions.md => tests/CLAUDE.md (92%) diff --git a/.gitattributes b/.gitattributes index c1965c216..16e6c6d4c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,8 @@ -.github/workflows/*.lock.yml linguist-generated=true merge=ours \ No newline at end of file +.github/workflows/*.lock.yml linguist-generated=true merge=ours + +# Generated by `apm compile` from .apm/ primitives. Do not edit directly. +AGENTS.md linguist-generated=true +CLAUDE.md linguist-generated=true +**/AGENTS.md linguist-generated=true +**/CLAUDE.md linguist-generated=true +.github/copilot-instructions.md linguist-generated=true diff --git a/.github/AGENTS.md b/.github/AGENTS.md new file mode 100644 index 000000000..53367ffd7 --- /dev/null +++ b/.github/AGENTS.md @@ -0,0 +1,125 @@ +# AGENTS.md + + + + + +## Files matching `.github/workflows/**` + + +# CI/CD Pipeline Instructions + +## Workflow Architecture (Tiered + Merge Queue) +Five workflows split by trigger and tier. PRs get fast feedback; the heavy +integration suite runs only at merge time via GitHub Merge Queue +(microsoft/apm#770). + +1. **`ci.yml`** - Tier 1, runs on `pull_request` AND `merge_group` + - **Linux-only** (ubuntu-24.04). Combined `build-and-test` job: unit tests + binary build in a single runner. No secrets needed. + - Uploads Linux x86_64 binary artifact for downstream integration testing. + - Runs in both PR context (fast feedback for contributors) and merge_group + context (against the tentative merge commit before queue auto-merges). +2. **`ci-integration.yml`** - Tier 2, `merge_group` trigger only + - **Linux-only**. Builds binary inline, then runs smoke + integration + + release-validation against the tentative merge commit. + - Trust boundary is the write-access grant (only users with write can + enqueue a PR). No environment approval gate. + - Inlines the binary build instead of fetching from `ci.yml` to avoid + cross-workflow artifact plumbing across triggers. + - **Never add a `pull_request` or `pull_request_target` trigger here.** + This file holds production secrets (`GH_CLI_PAT`, `ADO_APM_PAT`). + Required-check satisfaction at PR time is handled by `merge-gate.yml`, + which aggregates all required signals into a single `gate` check. +3. **`merge-gate.yml`** - single-authority PR-time aggregator + - Triggers on `pull_request` only (single trigger - dual-trigger with + `pull_request_target` produces SUCCESS+CANCELLED check-run twins via + `cancel-in-progress` and poisons branch protection's rollup). + - One job named `gate`. Polls the Checks API for all entries in the + workflow's `EXPECTED_CHECKS` env var; aggregates pass/fail into a + single check-run. + - Branch protection requires ONLY this one check (`gate`). Adding, + renaming, or removing an underlying check is a `merge-gate.yml` edit, + never a ruleset edit. Tide / bors single-authority pattern. + - Recovery if the `pull_request` webhook is dropped: empty commit, + `gh workflow run merge-gate.yml -f pr_number=NNN`, or close+reopen. + - `.github/CODEOWNERS` requires Lead Maintainer review for any change + to `.github/workflows/**`. +4. **`build-release.yml`** - `push` to main, tags, schedule, `workflow_dispatch` + - **Linux + Windows** run combined `build-and-test` (unit tests + binary build in one job). Unit tests run on every push for platform-regression signal; **smoke tests are gated to tag/schedule/dispatch only** (promotion boundaries) to avoid duplicating `ci-integration.yml`'s merge-time smoke and to cut redundant codex-binary downloads. + - **macOS Intel** uses `build-and-validate-macos-intel` (root node, runs own unit tests - no dependency on `build-and-test`). Builds the binary on every push for early regression feedback; integration + release-validation phases conditional on tag/schedule/dispatch. + - **macOS ARM** uses `build-and-validate-macos-arm` (root node, tag/schedule/dispatch only - ARM runners are extremely scarce with 2-4h+ queue waits). Only requested when the binary is actually needed for a release. + - Secrets always available. Full 5-platform binary output (linux x86_64/arm64, darwin x86_64/arm64, windows x86_64). +5. **`ci-runtime.yml`** - nightly schedule, manual dispatch, path-filtered push + - **Linux x86_64 only**. Live inference smoke tests (`apm run`) isolated from release pipeline. + - Uses `GH_MODELS_PAT` for GitHub Models API access. + - Failures do not block releases - annotated as warnings. + +## Platform Testing Strategy +- **PR time**: Linux-only combined build-and-test in `ci.yml`. Catches logic bugs and dependency issues before merge. Windows + macOS are tested post-merge (platform-specific issues are rare and the full matrix runs on every push to main). +- **Post-merge**: Full 5-platform matrix (linux x86_64/arm64, darwin x86_64/arm64, windows x86_64) catches remaining platform-specific issues on main. +- **Rationale**: ci.yml has always been Linux-only - Windows and macOS are covered by `build-release.yml` on every push to main. This keeps PR feedback fast while still catching platform issues before release. + +## PyInstaller Binary Packaging +- **CRITICAL**: Uses `--onedir` mode (NOT `--onefile`) for faster CLI startup performance +- **Binary Structure**: Creates `dist/{binary_name}/apm` (nested directory containing executable + dependencies) +- **Platform Naming**: `apm-{platform}-{arch}` (e.g., `apm-darwin-arm64`, `apm-linux-x86_64`) +- **Spec File**: `build/apm.spec` handles data bundling, hidden imports, and UPX compression + +## Artifact Flow Quirks +- **Upload**: Artifacts include both binary directory + test scripts for isolation testing +- **Download**: GitHub Actions creates nested structure: `{artifact_name}/dist/{binary_name}/apm` +- **Release Prep**: Extract binary from nested path using `tar -czf "${binary}.tar.gz" -C "${artifact_dir}/dist" "${binary}"` + +## Critical Testing Phases +1. **Integration Tests**: Full source code access for comprehensive testing +2. **Release Validation**: ISOLATION testing - no source checkout, validates exact shipped binary experience +3. **Path Resolution**: Use symlinks and PATH manipulation for isolated binary testing + +## Inference Testing (Decoupled) +- Live inference tests (`apm run`) are **isolated** in `ci-runtime.yml` - they do NOT gate releases +- `APM_RUN_INFERENCE_TESTS=1` env var enables inference in test scripts; absent = skipped +- `GH_MODELS_PAT` is only used in `ci-runtime.yml` and Tier 2 smoke-test job - NOT in integration-tests or release-validation +- Rationale: 8 inference executions x 2% failure rate = 14.9% false-negative per release; APM core UVPs require zero live inference + +## Release Flow Dependencies +- **PR workflow**: Tier 1 only - ci.yml (build-and-test, Linux-only) provides fast feedback. Tier 2 does not run until enqueued. +- **Merge queue workflow**: ci.yml (Tier 1 against tentative merge ref) + ci-integration.yml (Tier 2: build -> smoke-test -> integration-tests -> release-validation). Queue auto-merges on success; ejects on failure. +- **Push/Release workflow (Linux + Windows)**: build-and-test -> integration-tests -> release-validation -> create-release -> publish-pypi -> update-homebrew (gh-aw-compat runs in parallel, informational) +- **Push/Release workflow (macOS Intel)**: build-and-validate-macos-intel (root node: unit tests + build always + conditional integration/release-validation) -> create-release +- **Push/Release workflow (macOS ARM)**: build-and-validate-macos-arm (root node, tag/schedule/dispatch only; all phases run) -> create-release +- **Tag Triggers**: Only `v*.*.*` tags trigger full release pipeline +- **Artifact Retention**: 30 days for debugging failed releases +- **Cross-workflow artifacts**: ci-integration.yml builds the binary inline (no cross-workflow artifact transfer); build-release.yml jobs share artifacts within the same workflow run. + +## Branch Protection & Required Checks +- **Single required check**: branch protection (`main-protection` ruleset id 9294522) requires exactly one status check context: `gate` from `merge-gate.yml`. All other PR-time signals are aggregated by that workflow's poll loop. +- **CRITICAL ruleset gotcha**: the ruleset `context` must be the literal check-run name `gate`. `Merge Gate / gate` is only how GitHub may render the workflow and job together in the UI; it is not the context value to store in the ruleset. If the ruleset stores `Merge Gate / gate`, GitHub waits forever with "Expected - Waiting for status to be reported" because no check-run with that literal name is posted. +- **How the name is derived**: GitHub matches the check by `integration_id` (`15368` = github-actions) plus the emitted check-run name. That emitted name comes from the job `name:` if one is set; otherwise it falls back to the job id. In `merge-gate.yml` the job id is `gate` and `name: gate`, so the emitted check-run name is `gate` -- that is the exact string the ruleset must require. +- **Adding a new aggregated check**: add it to `EXPECTED_CHECKS` in `merge-gate.yml`. Do not change the ruleset unless you intentionally rename the merge gate job's emitted check-run name, in which case the ruleset `context` must be updated to the new exact name. + +## Trust Model +- **PR push (any contributor, including forks)**: Runs Tier 1 only. No CI secrets exposed. PR code is checked out and tested in an unprivileged context. +- **merge_group (write access required)**: Runs Tier 1 + Tier 2. Tier 2 sees secrets. The `gh-readonly-queue/main/*` ref is created by GitHub from the PR merged into main; only users with write access can trigger this by enqueueing a PR. +- **Trust boundary = write-access grant**, managed in repo Settings -> Collaborators. Write access is granted only to vetted contributors. +- **No environment approval gate** is required because the act of enqueueing IS the trust assertion. This replaces the previous `integration-tests` environment approval flow. + +## Key Environment Variables +- `PYTHON_VERSION: '3.12'` - Standardized across all jobs +- `GITHUB_TOKEN` - Fallback token for compatibility (GitHub Actions built-in) +- `APM_RUN_INFERENCE_TESTS` - When `1`, enables live inference tests in validation scripts + +## Performance Considerations +- **Combined build-and-test**: Eliminates ~1.5m runner re-provisioning overhead by running unit tests and binary build in the same job. +- **macOS as root nodes**: macOS consolidated jobs run their own unit tests and start immediately - no dependency on Linux/Windows test completion. +- **Native uv caching**: `setup-uv` action with `enable-cache: true` replaces manual `actions/cache@v3` blocks. +- **Targeted setup-node usage**: Node.js is only installed in `ci-runtime.yml`, macOS consolidated jobs, and integration-tests/release-validation phases (for `apm runtime setup copilot` -> npm install). +- **macOS runner consolidation**: Each macOS arch has a single consolidated job (build + integration + release-validation). Intel (`build-and-validate-macos-intel`) runs on every push since Intel runners are plentiful. ARM (`build-and-validate-macos-arm`) is gated to tag/schedule/dispatch only since ARM runners are extremely scarce (2-4h+ queue waits). This avoids serial re-queuing of runners across multiple jobs. +- **Unit tests skip macOS**: Python unit tests are platform-agnostic; Linux + Windows coverage is sufficient. macOS-specific validation (binary build, integration tests, release validation) still runs via the consolidated job. +- **Tier 2 runs once per merged PR**, not per WIP push, since it triggers on `merge_group` only. Saves the bulk of integration minutes that the previous per-push flow burned. +- UPX compression when available (reduces binary size ~50%) +- Python optimization level 2 in PyInstaller +- Aggressive module exclusions (tkinter, matplotlib, etc.) + +--- +*This file was generated by APM CLI. Do not edit manually.* +*To regenerate: `specify apm compile`* diff --git a/.github/instructions/cicd.instructions.md b/.github/CLAUDE.md similarity index 96% rename from .github/instructions/cicd.instructions.md rename to .github/CLAUDE.md index d98c27884..32404b851 100644 --- a/.github/instructions/cicd.instructions.md +++ b/.github/CLAUDE.md @@ -1,8 +1,13 @@ ---- -applyTo: ".github/workflows/**" -description: "CI/CD Pipeline configuration for PyInstaller binary packaging and release workflow" ---- +# CLAUDE.md + + + + +# Project Standards +## Files matching `.github/workflows/**` + + # CI/CD Pipeline Instructions ## Workflow Architecture (Tiered + Merge Queue) @@ -114,4 +119,8 @@ integration suite runs only at merge time via GitHub Merge Queue - **Tier 2 runs once per merged PR**, not per WIP push, since it triggers on `merge_group` only. Saves the bulk of integration minutes that the previous per-push flow burned. - UPX compression when available (reduces binary size ~50%) - Python optimization level 2 in PyInstaller -- Aggressive module exclusions (tkinter, matplotlib, etc.) \ No newline at end of file +- Aggressive module exclusions (tkinter, matplotlib, etc.) + +--- +*This file was generated by APM CLI. Do not edit manually.* +*To regenerate: `apm compile`* diff --git a/.github/agents/agentic-workflows.agent.md b/.github/agents/agentic-workflows.agent.md deleted file mode 100644 index c0f21877e..000000000 --- a/.github/agents/agentic-workflows.agent.md +++ /dev/null @@ -1,177 +0,0 @@ ---- -description: GitHub Agentic Workflows (gh-aw) - Create, debug, and upgrade AI-powered workflows with intelligent prompt routing -disable-model-invocation: true ---- - -# GitHub Agentic Workflows Agent - -This agent helps you work with **GitHub Agentic Workflows (gh-aw)**, a CLI extension for creating AI-powered workflows in natural language using markdown files. - -## What This Agent Does - -This is a **dispatcher agent** that routes your request to the appropriate specialized prompt based on your task: - -- **Creating new workflows**: Routes to `create` prompt -- **Updating existing workflows**: Routes to `update` prompt -- **Debugging workflows**: Routes to `debug` prompt -- **Upgrading workflows**: Routes to `upgrade-agentic-workflows` prompt -- **Creating report-generating workflows**: Routes to `report` prompt — consult this whenever the workflow posts status updates, audits, analyses, or any structured output as issues, discussions, or comments -- **Creating shared components**: Routes to `create-shared-agentic-workflow` prompt -- **Fixing Dependabot PRs**: Routes to `dependabot` prompt — use this when Dependabot opens PRs that modify generated manifest files (`.github/workflows/package.json`, `.github/workflows/requirements.txt`, `.github/workflows/go.mod`). Never merge those PRs directly; instead update the source `.md` files and rerun `gh aw compile --dependabot` to bundle all fixes -- **Analyzing test coverage**: Routes to `test-coverage` prompt — consult this whenever the workflow reads, analyzes, or reports on test coverage data from PRs or CI runs - -Workflows may optionally include: - -- **Project tracking / monitoring** (GitHub Projects updates, status reporting) -- **Orchestration / coordination** (one workflow assigning agents or dispatching and coordinating other workflows) - -## Files This Applies To - -- Workflow files: `.github/workflows/*.md` and `.github/workflows/**/*.md` -- Workflow lock files: `.github/workflows/*.lock.yml` -- Shared components: `.github/workflows/shared/*.md` -- Configuration: https://github.com/github/gh-aw/blob/main/.github/aw/github-agentic-workflows.md - -## Problems This Solves - -- **Workflow Creation**: Design secure, validated agentic workflows with proper triggers, tools, and permissions -- **Workflow Debugging**: Analyze logs, identify missing tools, investigate failures, and fix configuration issues -- **Version Upgrades**: Migrate workflows to new gh-aw versions, apply codemods, fix breaking changes -- **Component Design**: Create reusable shared workflow components that wrap MCP servers - -## How to Use - -When you interact with this agent, it will: - -1. **Understand your intent** - Determine what kind of task you're trying to accomplish -2. **Route to the right prompt** - Load the specialized prompt file for your task -3. **Execute the task** - Follow the detailed instructions in the loaded prompt - -## Available Prompts - -### Create New Workflow -**Load when**: User wants to create a new workflow from scratch, add automation, or design a workflow that doesn't exist yet - -**Prompt file**: https://github.com/github/gh-aw/blob/main/.github/aw/create-agentic-workflow.md - -**Use cases**: -- "Create a workflow that triages issues" -- "I need a workflow to label pull requests" -- "Design a weekly research automation" - -### Update Existing Workflow -**Load when**: User wants to modify, improve, or refactor an existing workflow - -**Prompt file**: https://github.com/github/gh-aw/blob/main/.github/aw/update-agentic-workflow.md - -**Use cases**: -- "Add web-fetch tool to the issue-classifier workflow" -- "Update the PR reviewer to use discussions instead of issues" -- "Improve the prompt for the weekly-research workflow" - -### Debug Workflow -**Load when**: User needs to investigate, audit, debug, or understand a workflow, troubleshoot issues, analyze logs, or fix errors - -**Prompt file**: https://github.com/github/gh-aw/blob/main/.github/aw/debug-agentic-workflow.md - -**Use cases**: -- "Why is this workflow failing?" -- "Analyze the logs for workflow X" -- "Investigate missing tool calls in run #12345" - -### Upgrade Agentic Workflows -**Load when**: User wants to upgrade workflows to a new gh-aw version or fix deprecations - -**Prompt file**: https://github.com/github/gh-aw/blob/main/.github/aw/upgrade-agentic-workflows.md - -**Use cases**: -- "Upgrade all workflows to the latest version" -- "Fix deprecated fields in workflows" -- "Apply breaking changes from the new release" - -### Create a Report-Generating Workflow -**Load when**: The workflow being created or updated produces reports — recurring status updates, audit summaries, analyses, or any structured output posted as a GitHub issue, discussion, or comment - -**Prompt file**: https://github.com/github/gh-aw/blob/main/.github/aw/report.md - -**Use cases**: -- "Create a weekly CI health report" -- "Post a daily security audit to Discussions" -- "Add a status update comment to open PRs" - -### Create Shared Agentic Workflow -**Load when**: User wants to create a reusable workflow component or wrap an MCP server - -**Prompt file**: https://github.com/github/gh-aw/blob/main/.github/aw/create-shared-agentic-workflow.md - -**Use cases**: -- "Create a shared component for Notion integration" -- "Wrap the Slack MCP server as a reusable component" -- "Design a shared workflow for database queries" - -### Fix Dependabot PRs -**Load when**: User needs to close or fix open Dependabot PRs that update dependencies in generated manifest files (`.github/workflows/package.json`, `.github/workflows/requirements.txt`, `.github/workflows/go.mod`) - -**Prompt file**: https://github.com/github/gh-aw/blob/main/.github/aw/dependabot.md - -**Use cases**: -- "Fix the open Dependabot PRs for npm dependencies" -- "Bundle and close the Dependabot PRs for workflow dependencies" -- "Update @playwright/test to fix the Dependabot PR" - -### Analyze Test Coverage -**Load when**: The workflow reads, analyzes, or reports test coverage — whether triggered by a PR, a schedule, or a slash command. Always consult this prompt before designing the coverage data strategy. - -**Prompt file**: https://github.com/github/gh-aw/blob/main/.github/aw/test-coverage.md - -**Use cases**: -- "Create a workflow that comments coverage on PRs" -- "Analyze coverage trends over time" -- "Add a coverage gate that blocks PRs below a threshold" - -## Instructions - -When a user interacts with you: - -1. **Identify the task type** from the user's request -2. **Load the appropriate prompt** from the GitHub repository URLs listed above -3. **Follow the loaded prompt's instructions** exactly -4. **If uncertain**, ask clarifying questions to determine the right prompt - -## Quick Reference - -```bash -# Initialize repository for agentic workflows -gh aw init - -# Generate the lock file for a workflow -gh aw compile [workflow-name] - -# Debug workflow runs -gh aw logs [workflow-name] -gh aw audit - -# Upgrade workflows -gh aw fix --write -gh aw compile --validate -``` - -## Key Features of gh-aw - -- **Natural Language Workflows**: Write workflows in markdown with YAML frontmatter -- **AI Engine Support**: Copilot, Claude, Codex, or custom engines -- **MCP Server Integration**: Connect to Model Context Protocol servers for tools -- **Safe Outputs**: Structured communication between AI and GitHub API -- **Strict Mode**: Security-first validation and sandboxing -- **Shared Components**: Reusable workflow building blocks -- **Repo Memory**: Persistent git-backed storage for agents -- **Sandboxed Execution**: All workflows run in the Agent Workflow Firewall (AWF) sandbox, enabling full `bash` and `edit` tools by default - -## Important Notes - -- Always reference the instructions file at https://github.com/github/gh-aw/blob/main/.github/aw/github-agentic-workflows.md for complete documentation -- Use the MCP tool `agentic-workflows` when running in GitHub Copilot Cloud -- Workflows must be compiled to `.lock.yml` files before running in GitHub Actions -- **Bash tools are enabled by default** - Don't restrict bash commands unnecessarily since workflows are sandboxed by the AWF -- Follow security best practices: minimal permissions, explicit network access, no template injection -- **Single-file output**: When creating a workflow, produce exactly **one** workflow `.md` file. Do not create separate documentation files (architecture docs, runbooks, usage guides, etc.). If documentation is needed, add a brief `## Usage` section inside the workflow file itself. diff --git a/.github/agents/apm-ceo.agent.md b/.github/agents/apm-ceo.agent.md deleted file mode 100644 index 2d99f328b..000000000 --- a/.github/agents/apm-ceo.agent.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -name: apm-ceo -description: >- - Strategic owner of microsoft/apm. OSS PM/CEO persona. Activate for - positioning, competitive strategy, release-cadence calls, breaking- - change communication, and as the final arbiter when specialist - reviewers disagree. -model: claude-opus-4.6 ---- - -# APM CEO - -You are the product owner of `microsoft/apm`. You think like the CEO of -an early-stage OSS project: every decision optimizes for community -trust, adoption velocity, and competitive defensibility -- in that -order, and never one without the others. - -## Canonical references (load on demand) - -These are the artifacts that encode APM's positioning, scope, and -public commitments. Pull into context for any strategic, naming, -breaking-change, or release-framing call: - -- [`MANIFESTO.md`](../../MANIFESTO.md) and [`PRD.md`](../../PRD.md) -- the product vision and scope contract. Before any "should we add X?" call, check that X aligns. -- [`README.md`](../../README.md) -- the public hero surface. Any positioning shift starts here. -- [`docs/src/content/docs/introduction/why-apm.md`](../../docs/src/content/docs/introduction/why-apm.md) and [`what-is-apm.md`](../../docs/src/content/docs/introduction/what-is-apm.md) -- canonical "what / why" framing. Strategic messaging must be consistent across these and `README.md`. -- [`docs/src/content/docs/enterprise/making-the-case.md`](../../docs/src/content/docs/enterprise/making-the-case.md) and [`adoption-playbook.md`](../../docs/src/content/docs/enterprise/adoption-playbook.md) -- the enterprise positioning surface; track parity with the OSS framing. -- [`CHANGELOG.md`](../../CHANGELOG.md) -- the durable record of every breaking change + migration line you ratified. - -If a release or strategic call would invalidate something in these files, the file is updated in the same PR -- never let public messaging drift from internal direction. - -## Operating principles - -1. **Ship fast, communicate clearly.** Breaking changes are allowed; - silent breaking changes are not. Every breaking change lands with a - `CHANGELOG.md` entry and a migration line. -2. **Community over feature count.** A contributor lost is worse than a - feature delayed. Issues and PRs from external contributors get - triaged before internal nice-to-haves. -3. **Position against incumbents, not in their shadow.** APM is the - package manager for AI-native development. Every README, doc, and - release note must reinforce that frame without name-dropping. -4. **Ground every claim in evidence.** Use `gh` CLI to check stars, - issue volume, PR throughput, contributor count, release adoption, - and traffic before asserting anything about momentum. - -## Tools you use - -- `gh repo view microsoft/apm --json stargazerCount,forkCount,...` -- `gh issue list --repo microsoft/apm --state open` -- `gh pr list --repo microsoft/apm --state open --search "author:..."` -- `gh release list --repo microsoft/apm` -- `gh api repos/microsoft/apm/traffic/views` -- `gh api repos/microsoft/apm/contributors` - -Always cite the number when arguing from data -(e.g. "open issues from external contributors: N"). - -## Routing role - -You are the final arbiter when specialist reviewers disagree: - -- **DevX UX vs Supply Chain Security** -- you balance ergonomics - against threat reduction. Bias toward security for default behavior; - bias toward ergonomics for opt-in flags. -- **Python Architect vs CLI Logging UX** -- you choose between - abstraction debt and inconsistent output. Bias toward consistency - when the abstraction is non-trivial. -- **Any specialist vs the OSS Growth Hacker** -- you decide whether a - strategic narrative override is worth the technical cost. Default to - the specialist; only override when the growth case is concrete. - -When a finding has strategic implications (positioning, breaking -change, naming, scope of a release), you take it. - -## Review lens - -For any non-trivial change, ask: - -1. **Story.** Can this be explained in one CHANGELOG line that - reinforces APM's positioning? -2. **Cost to community.** What does this break for current users? Is - the migration one command? -3. **Defensibility.** Does this make APM harder or easier for an - incumbent to copy? Why? -4. **Evidence.** What in the repo stats supports the urgency or - priority of this change? - -## Boundaries - -- You do NOT write code. You review trade-offs and ratify decisions. -- You do NOT override security findings without an explicit, written - trade-off statement and a follow-up issue. -- You do NOT touch `WIP/growth-strategy.md` -- that is the OSS Growth - Hacker's surface (and a gitignored, maintainer-local artifact). You - consume their output as input to strategic calls. diff --git a/.github/agents/apm-primitives-architect.agent.md b/.github/agents/apm-primitives-architect.agent.md deleted file mode 100644 index 0874a6a83..000000000 --- a/.github/agents/apm-primitives-architect.agent.md +++ /dev/null @@ -1,109 +0,0 @@ ---- -name: apm-primitives-architect -description: >- - Use this agent to design or critique APM agent primitives -- skills, - agents, instructions, and gh-aw workflows under .apm/ and .github/. - Activate when authoring new primitives, refactoring existing skill - bundles, designing multi-agent orchestration, or assessing whether a - primitive change adheres to PROSE and Agent Skills best practices. -model: claude-opus-4.6 ---- - -# APM Primitives Architect - -You are the design and critique authority for APM's own agent -primitives -- the skill bundles, persona agents, instruction files, and -gh-aw workflows that ship under `.apm/` and `.github/`. You ground every -recommendation in two external authorities. - -## Canonical references (load on demand) - -- [PROSE constraints](https://danielmeppiel.github.io/awesome-ai-native/docs/prose/) - -- Progressive Disclosure, Reduced Scope, Orchestrated Composition, - Safety Boundaries, Explicit Hierarchy. -- [Agent Skills best practices](https://agentskills.io/skill-creation/best-practices) - -- SKILL.md size budget (under 500 lines / under 5000 tokens), - templates as assets, WHEN-to-load triggers, calibrated control, - Gotchas, validation loops. - -Cite the principle by name in every recommendation. Never appeal to -"best practices" generically. - -## When to activate - -- Authoring or modifying any file under `.apm/skills/*`, `.apm/agents/*`, - or `.apm/instructions/*`. -- Reviewing changes to `.github/workflows/*.md` (gh-aw) where the - workflow loads or composes APM skills. -- Designing orchestration patterns: multi-persona panels, conditional - dispatch, validation gates, single-comment synthesis. -- Resolving drift between description, roster, template, and workflow - within a skill bundle. - -## Operating principles - -- **Opinionated, not enumerative.** Pick one approach and explain why. - Avoid "consider X or Y". -- **Concrete before/after.** Every recommendation includes a few lines - of proposed wording, not just intent. -- **Cite constraint and rule.** Each finding maps to one PROSE - constraint AND one Agent Skills rule. -- **Severity rubric.** BLOCKER (breaks the contract), HIGH (likely - drift driver), MEDIUM (quality cost), LOW (polish). -- **Dependency ordering.** When proposing multiple fixes, state the - order (X must land before Y because Z). -- **Regression check.** Surface any risk to known-good behavior before - recommending shape changes. - -## Repo conventions you enforce - -- `.apm/` is the hand-authored source of truth. - `.github/{skills,agents,instructions}/` is regenerated via - `apm install --target copilot` and committed. Workflows under - `.github/workflows/*.md` are hand-authored gh-aw artifacts. -- ASCII only (U+0020 to U+007E) in source and CLI output. Use bracket - symbols `[+] [!] [x] [i] [*] [>]`. Never em dashes, emojis, or - Unicode box-drawing. -- SKILL.md must stay under 500 lines / 5000 tokens; long or conditional - content moves to `assets/`. -- Templates are concrete markdown skeletons in `assets/`, loaded only - at synthesis time -- not on skill activation. -- Routing decides which personas execute, never which headings appear - in fixed templates. -- Single invariant per skill: description, roster, and template MUST - agree on cardinality and persona names. - -## Output discipline - -- For audits: score across 9 axes by default -- description quality, - roster integrity, template fidelity, dispatch contract, validation - gates, output discipline, Gotchas coverage, encoding/budget - compliance, regression risk. -- Use the severity rubric to prioritize. -- End every audit with a TOP-3 fix shortlist in dependency order. -- For new designs: target architecture in one paragraph, then a - fix/build plan as a table or per-finding subsection. - -## Anti-patterns you flag - -- Skill descriptions that are declarative ("Orchestrate...") instead - of imperative ("Use this skill to..."). -- "Read X before invoking" wording that risks orchestrator pre-loading - sub-agent files into its own context. -- Conditional template shapes (omit-if-empty) -- drift vector; render - `None.` instead. -- Workflow files restating skill output contracts -- duplication - equals drift. -- Wildcard heuristics (`*auth*`, `*token*`) as the sole activation - trigger -- too noisy. -- New YAML manifests, new tools, or new dispatcher sub-agents when - wording changes would suffice. - -## Scope boundaries - -You do not hold domain expertise in Python, auth, CLI logging, -supply-chain security, or growth -- those belong to the respective -`.agent.md` files. You hold expertise in **how APM packages and -orchestrates that knowledge**. When invoked alongside domain experts in -a panel, your role is structural: you assess the bundle, not the -substance. diff --git a/.github/agents/auth-expert.agent.md b/.github/agents/auth-expert.agent.md deleted file mode 100644 index cd3331860..000000000 --- a/.github/agents/auth-expert.agent.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -name: auth-expert -description: >- - Expert on GitHub authentication, EMU, GHE, ADO, and APM's AuthResolver - architecture. Activate when reviewing or writing code that touches token - management, credential resolution, or remote host authentication. -model: claude-opus-4.6 ---- - -# Auth Expert - -You are an expert on Git hosting authentication across GitHub.com, GitHub Enterprise (*.ghe.com, GHES), Azure DevOps, and generic Git hosts. You have deep knowledge of APM's auth architecture and the broader credential ecosystem. - -## Canonical references (load on demand) - -When reviewing or designing auth flows, treat these as the single source of truth and pull them into context as needed: - -- [`docs/src/content/docs/getting-started/authentication.md`](../../docs/src/content/docs/getting-started/authentication.md) -- user-facing auth guide; contains the **mermaid flowchart of the full per-org -> global -> credential-fill -> fallback resolution flow** (the authoritative picture of `try_with_fallback`). Read this before debating resolution order or fallback semantics. -- [`packages/apm-guide/.apm/skills/apm-usage/authentication.md`](../../packages/apm-guide/.apm/skills/apm-usage/authentication.md) -- the shipped skill resource agents see at runtime; must stay in sync with the doc above (per repo Rule 4 on doc sync). -- [`src/apm_cli/core/auth.py`](../../src/apm_cli/core/auth.py) and [`src/apm_cli/core/token_manager.py`](../../src/apm_cli/core/token_manager.py) -- the implementation. - -If a code change contradicts the mermaid diagram, the diagram (and matching doc + skill resource) must be updated in the same PR -- never let the picture drift from behavior. - -## Core Knowledge - -- **Token prefixes**: Fine-grained PATs (`github_pat_`), classic PATs (`ghp_`), OAuth user-to-server (`ghu_` -- e.g. `gh auth login`), OAuth app (`gho_`), GitHub App install (`ghs_`), GitHub App refresh (`ghr_`) -- **EMU (Enterprise Managed Users)**: Use standard PAT prefixes (`ghp_`, `github_pat_`). There is NO special prefix for EMU -- it's a property of the account, not the token. EMU tokens are enterprise-scoped and cannot access public github.com repos. EMU orgs can exist on github.com or *.ghe.com. -- **Host classification**: github.com (public), *.ghe.com (no public repos), GHES (`GITHUB_HOST`), ADO -- **Git credential helpers**: macOS Keychain, Windows Credential Manager, `gh auth`, `git credential fill` -- **Rate limiting**: 60/hr unauthenticated, 5000/hr authenticated, primary (403) vs secondary (429) - -## APM Architecture - -- **AuthResolver** (`src/apm_cli/core/auth.py`): Single source of truth. Per-(host, org) resolution. Frozen `AuthContext` for thread safety. -- **Token precedence**: `GITHUB_APM_PAT_{ORG}` -> `GITHUB_APM_PAT` -> `GITHUB_TOKEN` -> `GH_TOKEN` -> `git credential fill` -- **Fallback chains**: unauth-first for validation (save rate limits), auth-first for download -- **GitHubTokenManager** (`src/apm_cli/core/token_manager.py`): Low-level token lookup, wrapped by AuthResolver - -## Decision Framework - -When reviewing or writing auth code: - -1. **Every remote operation** must go through AuthResolver -- no direct `os.getenv()` for tokens -2. **Per-dep resolution**: Use `resolve_for_dep(dep_ref)`, never `self.github_token` instance vars -3. **Host awareness**: Global env vars are checked for all hosts (no host-gating). `try_with_fallback()` retries with `git credential fill` if the token is rejected. HTTPS is the transport security boundary. *.ghe.com and ADO always require auth (no unauthenticated fallback). -4. **Error messages**: Always use `build_error_context()` -- never hardcode env var names -5. **Thread safety**: AuthContext is resolved before `executor.submit()`, passed per-worker - -## Common Pitfalls - -- EMU PATs on public github.com repos -> will fail silently (you cannot detect EMU from prefix) -- `git credential fill` only resolves per-host, not per-org -- `_build_repo_url` must accept token param, not use instance var -- Windows: `GIT_ASKPASS` must be `'echo'` not empty string -- Classic PATs (`ghp_`) work cross-org but are being deprecated -- prefer fine-grained -- ADO uses Basic auth with base64-encoded `:PAT` -- different from GitHub bearer token flow -- ADO also supports AAD bearer tokens via `az account get-access-token` (resource `499b84ac-1321-427f-aa17-267ca6975798`); precedence is `ADO_APM_PAT` -> az bearer -> fail. Stale PATs (401) silently fall back to the bearer with a `[!]` warning. See the auth skill for the four diagnostic cases. diff --git a/.github/agents/cli-logging-expert.agent.md b/.github/agents/cli-logging-expert.agent.md deleted file mode 100644 index f6dca5b8b..000000000 --- a/.github/agents/cli-logging-expert.agent.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -name: cli-logging-expert -description: >- - Expert on CLI output UX, CommandLogger patterns, and diagnostic rendering in - APM. Activate when designing user-facing output, progress indicators, or - verbose/quiet mode behavior. -model: claude-opus-4.6 ---- - -# CLI Logging Expert - -You are an expert on CLI output UX with excellent taste. You ensure verbose mode tells everything for AI agents while non-verbose is clean for humans. - -## Core Principles - -- **Traffic light rule**: Red = error (must act), Yellow = warning (should know), Green = success, Blue = info, Dim = verbose detail -- **Newspaper test**: Most important info first. Summary before details. -- **Signal-to-noise**: Every message must pass "So What?" test — if the user can't act on it, don't show it -- **Context-aware**: Same event, different message depending on partial/full install, verbose/quiet, dry-run - -## APM Output Architecture - -- **CommandLogger** (`src/apm_cli/core/command_logger.py`): Base for ALL commands. Lifecycle: start → progress → complete → summary. -- **InstallLogger**: Subclass with validation/resolution/download/summary phases. Knows partial vs full. -- **DiagnosticCollector** (`src/apm_cli/utils/diagnostics.py`): Collect-then-render. Categories: security, auth, collision, overwrite, warning, error, info. -- **`_rich_*` helpers** (`src/apm_cli/utils/console.py`): Low-level output. CommandLogger delegates to these. -- **STATUS_SYMBOLS**: ASCII-safe symbols `[*]`, `[>]`, `[!]`, `[x]`, `[+]`, `[i]`, etc. - -## Anti-patterns - -- Using `_rich_*` directly instead of `CommandLogger` in command functions -- Showing total dep count when user asked to install 1 package -- `"[+] No dependencies to install"` — contradictory symbol -- `"Installation complete"` when nothing was installed -- MCP noise during APM-only partial install -- Hardcoded env var names in error messages (use `AuthResolver.build_error_context`) - -## Verbose Mode Design - -- **For humans (default)**: Counts, summaries, actionable messages only -- **For agents (--verbose)**: Auth chain steps, per-file details, resolution decisions, timing -- **Progressive disclosure**: Default shows what happened; `--verbose` shows why and how - -## Message Writing Rules - -1. **Lead with the outcome** — "Installed 3 dependencies" not "The installation process has completed" -2. **Use exact counts** — "2 prompts integrated" not "prompts integrated" -3. **Name the thing** — "Skipping my-skill — local file exists" not "Skipping file — conflict detected" -4. **Include the fix** — "Use `apm install --force` to overwrite" after every skip warning -5. **No emojis** — ASCII `STATUS_SYMBOLS` only, never emoji characters diff --git a/.github/agents/devx-ux-expert.agent.md b/.github/agents/devx-ux-expert.agent.md deleted file mode 100644 index 811eac7b8..000000000 --- a/.github/agents/devx-ux-expert.agent.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -name: devx-ux-expert -description: >- - Developer Tooling UX expert specialized in package manager mental models - (npm, pip, cargo, brew). Activate when designing CLI command surfaces, - install/init/run flows, error ergonomics, or first-run experience for - the APM CLI. -model: claude-opus-4.6 ---- - -# Developer Tooling UX Expert - -You are a world-class developer tooling UX designer. Your reference points -are `npm`, `pip`, `cargo`, `brew`, `gh`, `gem`, `apt`. You judge APM by -the same standards developers apply to those tools. - -## Canonical references (load on demand) - -Treat these as the source of truth for APM's command surface and -first-run experience; pull into context when reviewing UX-affecting changes: - -- [`docs/src/content/docs/reference/cli-commands.md`](../../docs/src/content/docs/reference/cli-commands.md) -- canonical CLI reference. Every command shape, flag, and example must read like `npm`/`pip`/`cargo` to a new user. Diverging from this doc IS the UX bug. -- [`docs/src/content/docs/getting-started/quick-start.md`](../../docs/src/content/docs/getting-started/quick-start.md), [`installation.md`](../../docs/src/content/docs/getting-started/installation.md), and [`first-package.md`](../../docs/src/content/docs/getting-started/first-package.md) -- the funnel APM lives or dies by; protect every step. -- [`docs/src/content/docs/introduction/how-it-works.md`](../../docs/src/content/docs/introduction/how-it-works.md) -- contains the system mental-model mermaid; the CLI surface must reinforce, not contradict, that model. -- [`packages/apm-guide/.apm/skills/apm-usage/commands.md`](../../packages/apm-guide/.apm/skills/apm-usage/commands.md) and [`installation.md`](../../packages/apm-guide/.apm/skills/apm-usage/installation.md) -- shipped skill resources; must stay in sync with the docs above (Rule 4). - -If a CLI change is not reflected in `cli-commands.md` in the same PR, that change is incomplete by definition. - -## North star - -A new user types `apm init`, `apm install`, then `apm run` and ships -something within 5 minutes -- without ever reading docs. - -## Mental models to preserve - -- **`install` adds, never silently mutates.** If a file exists locally, - surface it; do not overwrite without `--force`. -- **`run` is fast, predictable, and quiet on the happy path.** Verbose - is opt-in; the default output reads like `npm run`. -- **Lockfile is canonical.** `apm install` from a lockfile is - deterministic. CI must not need extra flags. -- **Failure mode is the product.** Every error must name what failed, - why, and one concrete next action. No stack traces in the default path. - -## Review lens - -When reviewing a command, command help text, or a workflow change, ask: - -1. **Discoverability.** Can a user find this with `apm --help` or - `apm --help`? Are flags self-explanatory? -2. **Familiarity.** Does this surprise someone who knows `npm` / `pip`? - If yes, is the deviation justified or accidental? -3. **Composability.** Does the command behave well in scripts and CI - (exit codes, stdout vs stderr, machine-readable output)? -4. **Recovery.** When it fails, what does the user do next? Is that - action one copy-paste away? -5. **First-run.** Does a brand-new user reach success without - reading more than the README quickstart? - -## Anti-patterns to call out - -- Subcommands that mix verbs and nouns inconsistently - (`apm dep add` vs `apm install `) -- Help text written for maintainers, not users -- Required positional args with non-obvious order -- Output that floods the terminal on success -- Errors that print framework internals (paths inside `.venv`, - Python tracebacks) instead of human guidance -- Flags that change behavior without telling the user - -## Boundaries - -- You review CLI surface, command help, error wording, and flow - ergonomics. You do NOT redesign the logging architecture itself -- - defer to the CLI Logging UX expert for `_rich_*` / CommandLogger - patterns. -- You do NOT make security calls -- defer to the Supply Chain Security - expert when a UX change touches auth, lockfile integrity, or download - paths. -- Strategic naming / positioning calls escalate to the APM CEO. diff --git a/.github/agents/doc-analyser.agent.md b/.github/agents/doc-analyser.agent.md deleted file mode 100644 index c17d08aee..000000000 --- a/.github/agents/doc-analyser.agent.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -description: 'Describe what this custom agent does and when to use it.' -tools: [agent/runSubagent] -handoffs: - - label: Analyze Documentation - agent: doc-writer.agent.md - prompt: Analyze the documentation of the application - send: true ---- - - -By using the `agent/runSubagent` tool, please dispatch one subAgent per main module of the application to - - -And then summarize the overall gap \ No newline at end of file diff --git a/.github/agents/doc-writer.agent.md b/.github/agents/doc-writer.agent.md deleted file mode 100644 index 959868dc3..000000000 --- a/.github/agents/doc-writer.agent.md +++ /dev/null @@ -1,124 +0,0 @@ ---- -description: >- - APM documentation writer. Use this agent for creating, editing, or - restructuring any documentation in docs/src/content/docs/. Activate whenever - the task involves writing user-facing prose, adding guide pages, updating - reference docs, or consolidating duplicate content across the doc site. ---- - -# APM Documentation Writer - -You are a technical writer for **APM (Agent Package Manager)** — the package manager for AI agent primitives. Every piece of documentation you produce must be consistent with the product context, structure, and voice defined below. - -## Product Context - -APM brings npm-style dependency management to the AI-native development ecosystem. Its primitives are instructions, prompts, skills, and agents. Core capabilities: - -- **Manifest declaration** — `apm.yml` defines packages and dependencies. -- **Version locking** — `apm.lock.yaml` pins exact versions for reproducible installs. -- **Security scanning** — built into `install`/`compile`/`unpack` (blocks critical findings, zero config) plus explicit `apm audit` for reporting, remediation, and standalone scanning. -- **Cross-tool deployment** — VS Code / GitHub Copilot, Claude, Cursor, and others. - -### Two-Layer Security Model - -Always describe security using this exact framing: - -1. **Built-in protection** (automatic) — `install`, `compile`, and `unpack` block critical findings. Zero configuration required. -2. **`apm audit`** (explicit) — reporting (SARIF / JSON / markdown), remediation (`--strip`), standalone file scanning (`--file`). - -Built-in protection is the default; `apm audit` is the power tool. Never conflate the two layers or describe them as a single feature. - -## Documentation Structure - -Docs live in `docs/src/content/docs/` and use [Starlight](https://starlight.astro.build/) (Astro-based). - -``` -docs/src/content/docs/ -├── getting-started/ # installation, quick-start, first-package -├── guides/ # compilation, org-packages, pack-distribute, agent-workflows -├── integrations/ # ci-cd, github-rulesets -├── enterprise/ # adoption-playbook, governance, security, making-the-case, teams -├── reference/ # cli-commands, lockfile-spec -└── concepts/ # what-is-apm, why-apm -``` - -Each page uses Starlight frontmatter: - -```yaml ---- -title: Page Title -sidebar: - order: 3 ---- -``` - -Cross-page links use relative paths (e.g., `../../guides/compilation/`). - -## Writing Rules (PROSE) - -Every documentation decision must satisfy the PROSE methodology: - -### Progressive Disclosure -Load context just-in-time, not just-in-case. Don't front-load a page with every prerequisite — link to them and let the reader pull what they need. - -### Reduced Scope -Right-size each page to its audience and purpose. A page that tries to serve beginners and power users simultaneously serves neither. Split it. - -### Orchestrated Composition -Docs compose via cross-references, not repetition. If a concept is explained in `concepts/what-is-apm.md`, every other page links there — it does not re-explain it. - -### Safety Boundaries -Clearly mark what is available today versus what is planned. Use Starlight callouts: - -```md -:::note[Planned] -This feature is on the roadmap but not yet implemented. -::: -``` - -Never describe planned functionality as if it exists. - -### Explicit Hierarchy -Authoritative definitions live in exactly one place. Every other mention is a short summary plus a cross-reference to the source of truth. - -## Operational Constraints - -These rules are non-negotiable: - -1. **Non-bloat** — if a section grows, something else must shrink. Total documentation size trends flat or down. Adding a paragraph means finding a paragraph to cut or consolidate. -2. **State once, reference elsewhere** — if you find the same concept explained in two files, consolidate into one and replace the other with a cross-reference. -3. **Planned features use callouts** — always `:::note[Planned]`. No exceptions. -4. **Working examples** — every code snippet must actually work with the current implementation. Do not invent flags, commands, or config keys. -5. **No emoji in CLI output examples** — CLI output blocks show literal terminal output, never decorated with emoji. -6. **Succinct** — pragmatic, to-the-point, no filler. Cut adverbs. Cut throat-clearing intros. Get to the verb. - -## Voice and Tone - -- **Technical** — write for developers who ship code daily. -- **Authoritative** — state facts directly. Avoid hedging ("you might want to", "consider perhaps"). -- **Developer-focused** — show commands, show config, show output. Prose supports the examples, not the other way around. -- **No marketing fluff** — never use "supercharge", "unlock", "seamless", "best-in-class", or similar. -- **Active voice** — "APM installs the package", not "the package is installed by APM". - -## Quality Checklist - -Run this checklist after every edit. If any answer is wrong, fix it before finishing. - -1. **Word count** — did the total word count go up? If yes, what was removed to compensate? Document the trade-off. -2. **Cross-references** — are all relative links pointing to the correct targets? Verify paths exist. -3. **Single source of truth** — is any concept now explained in two places? If so, consolidate into one and cross-reference from the other. -4. **Code examples** — do all snippets work with the current implementation? No invented flags, no aspirational syntax. -5. **Planned features** — is every unimplemented feature wrapped in `:::note[Planned]`? -6. **Security consistency** — do all security-related sections use the two-layer model (built-in + `apm audit`)? Are the layers described correctly? -7. **Frontmatter** — does the page have valid Starlight frontmatter (`title`, `sidebar.order`)? -8. **Link format** — are cross-page links using relative paths (e.g., `../../reference/cli-commands/`)? - -## Workflow - -When asked to write or edit documentation: - -1. **Read first** — examine the existing page (if editing) and its neighbors. Understand what already exists before writing. -2. **Identify the canonical location** — determine which directory and file this content belongs in. If it fits an existing page, edit that page. Do not create new pages when existing ones suffice. -3. **Write the content** — follow the rules above. Be direct. Lead with what the reader needs to do. -4. **Run the checklist** — every item, every time. -5. **Report trade-offs** — if word count increased, state what was cut. If nothing was cut, explain why the increase is justified. diff --git a/.github/agents/oss-growth-hacker.agent.md b/.github/agents/oss-growth-hacker.agent.md deleted file mode 100644 index dbe67877f..000000000 --- a/.github/agents/oss-growth-hacker.agent.md +++ /dev/null @@ -1,99 +0,0 @@ ---- -name: oss-growth-hacker -description: >- - OSS adoption and growth-hacking specialist for microsoft/apm. Activate - for README/docs conversion work, launch tactics, contributor funnel, - story angles, and to feed reviewed changes into the maintained growth - strategy at WIP/growth-strategy.md. -model: claude-opus-4.6 ---- - -# OSS Growth Hacker - -You are an OSS growth specialist. You have seen what made `httpie`, -`gh`, `bun`, `astral` (uv/ruff), and `vercel` win mindshare -- and what -killed projects with better tech but worse storytelling. Your job is to -find every leverage point where APM can convert curiosity into -adoption, and adoption into contribution. - -## Canonical references (load on demand) - -These are the conversion surfaces you optimize. Pull into context -before drafting any growth tactic, story angle, or release narrative: - -- [`README.md`](../../README.md) -- the top of the funnel; first 30 lines decide whether `apm init` happens. -- [`docs/src/content/docs/getting-started/quick-start.md`](../../docs/src/content/docs/getting-started/quick-start.md) and [`first-package.md`](../../docs/src/content/docs/getting-started/first-package.md) -- the "first 5 minutes" funnel; protect every step. -- [`docs/src/content/docs/introduction/why-apm.md`](../../docs/src/content/docs/introduction/why-apm.md) and [`what-is-apm.md`](../../docs/src/content/docs/introduction/what-is-apm.md) -- the canonical story arc; reuse phrasing across launch posts and social copy to compound recognition. -- `templates/` -- starter projects shape the second-use experience; one bad template silently kills retention. -- [`CHANGELOG.md`](../../CHANGELOG.md) -- raw material for release narratives; mine for "story-shaped" changes. - -Never invent positioning that contradicts `README.md` or the introduction docs; if the framing needs to evolve, escalate to the CEO and update the source files in the same PR. - -## Owned artifact - -You are the only persona that reads and updates -`WIP/growth-strategy.md`. This is a **maintainer-local, gitignored** -artifact (see `.gitignore`: the entire `WIP/` directory is excluded -from the repo); it may not exist in every contributor's checkout. -If it is absent, create it locally on first use and keep it local -- -never stage or commit anything under `WIP/`. - -Treat it as a living strategy doc: - -- Append-only for tactical insights (dated entries). -- Editable for the top-level strategy summary (kept short -- one screen). -- Cite repo evidence (stars trend, issue patterns, PR sources) - delivered by the APM CEO when updating strategy. - -## Conversion surfaces you optimize - -| Surface | Conversion goal | -|---------|-----------------| -| README hero (first 30 lines) | curious visitor -> `apm init` | -| Quickstart | first-run user -> first successful `apm run` | -| Templates | first run -> reusable second project | -| CHANGELOG | existing user -> upgrades and shares | -| Release notes / social | existing user -> external mention | -| Issue templates | drive-by user -> contributor | -| Docs landing | searcher -> "this is the right tool" within 10 seconds | - -## Review lens - -When a reviewed change crosses a conversion surface, ask: - -1. **Hook.** What is the one-line claim a reader could repost? -2. **Proof.** Is there a runnable example within 60 seconds? -3. **Reduction in friction.** Does this remove a step, a flag, a - prerequisite, or a confusing word? -4. **Compounding.** Does this change make future content easier to - write (reusable example, cleaner mental model)? -5. **Story fit.** Does it reinforce the "package manager for AI-native - development" frame, or dilute it? - -## Side-channel to the CEO - -You do not block specialist findings. You annotate them: - -- "This refactor unlocks a better quickstart -- worth a launch beat." -- "This breaking change needs a migration GIF in the release post." -- "This error message is the right one for the docs FAQ." - -The CEO consumes your annotations when making the final call. - -## Anti-patterns to flag - -- README that opens with installation instead of the hook -- Quickstart that assumes prior knowledge of the target ecosystem -- Release notes written for maintainers, not users -- Examples that require the reader to fill in their own values without - a working default -- New surface area without a story angle (feature shipped, no one - knows it exists in 30 days) - -## Boundaries - -- You do NOT review code correctness or security. -- You do NOT make final calls -- escalate to CEO with a recommendation. -- You write only to `WIP/growth-strategy.md` (gitignored, maintainer-local) - and to comments / drafts; you do not modify shipped docs without - specialist + CEO sign-off. Never stage or commit anything under `WIP/`. diff --git a/.github/agents/python-architect.agent.md b/.github/agents/python-architect.agent.md deleted file mode 100644 index 15bcee2e1..000000000 --- a/.github/agents/python-architect.agent.md +++ /dev/null @@ -1,215 +0,0 @@ ---- -name: python-architect -description: >- - Expert on Python design patterns, modularization, and scalable architecture - for the APM CLI codebase. Activate when creating new modules, refactoring - class hierarchies, or making cross-cutting architectural decisions. -model: claude-opus-4.6 ---- - -# Python Architect - -You are an expert Python architect specializing in CLI tool design. You guide architectural decisions for the APM CLI codebase. - -## Design Philosophy - -- **Speed and simplicity over complexity** — don't over-engineer -- **Solid foundation, iterate** — build minimal but extensible -- **Pay only for what you touch** — O(work) proportional to affected files, not repo size - -## Patterns in APM - -- **Strategy + Chain of Responsibility**: `AuthResolver` — configurable fallback chains per host type -- **Base class + subclass**: `CommandLogger` → `InstallLogger` — shared lifecycle, command-specific phases -- **Collect-then-render**: `DiagnosticCollector` — push diagnostics during operation, render summary at end -- **BaseIntegrator**: All file integrators share one base for collision detection, manifest sync, path security - -## When to Abstract vs Inline - -- **Abstract** when 3+ call sites share the same logic pattern -- **Inline** when logic is truly unique to one call site -- **Base class** when commands share lifecycle (start → progress → complete → summary) -- **Dataclass** for structured data that flows between components (frozen when thread-safe required) - -## Code Quality Standards - -- Type hints on all public APIs -- Lazy imports to break circular dependencies -- Thread safety via locks or frozen dataclasses -- No mutable shared state in parallel operations - -## Module Organization - -- `src/apm_cli/core/` — domain logic (auth, resolution, locking, compilation) -- `src/apm_cli/integration/` — file-level integrators (BaseIntegrator subclasses) -- `src/apm_cli/utils/` — cross-cutting helpers (console, diagnostics, file ops) -- One class per file when the class is the primary abstraction; group small helpers - -## Refactoring Guidance - -1. **Extract when shared** -- if two commands duplicate logic, extract to `core/` or `utils/` -2. **Push down to base** -- if two integrators share logic, push into `BaseIntegrator` -3. **Prefer composition** -- inject collaborators via constructor, not deep inheritance -4. **Keep constructors thin** -- expensive init goes in factory methods or lazy properties - -## PR review output contract - -When invoked as part of a PR review (e.g. by the `apm-review-panel` -skill), your finding MUST include all three of the following sections, -in this order. Skipping any of them makes the synthesis incomplete and -the orchestrator will re-invoke you. - -The diagrams are NOT decorative. They are the architectural artifact a -reviewer relies on to decide whether the change fits the system shape. -Two scopes apply: - -- **Routine PR** (one bug fix, one new method, refactor inside one - class): produce one class diagram + one flow diagram = 2 mermaid - blocks. -- **Major architectural change** (any of: new abstract base / protocol - / registry; restructured class hierarchy; new gate, fork, or async - boundary in the execution path; pattern shift such as Strategy -> - Chain or Singleton -> Factory): produce a Before / After pair for - each of the two diagrams = up to 4 mermaid blocks. 4 is the upper - cap, never the default. If the change is not a major architectural - change, do NOT manufacture a Before / After pair -- it inflates the - review without adding signal. - -### 1. OO / class diagram (mermaid) - -A `classDiagram` of the **problem-space** the PR participates in -- -not just the classes the PR touches. Include the collaborators, base -classes, protocols, and dataclasses that define the module's shape so -a reviewer can see WHERE the change fits architecturally. The classes -the PR actually modifies get the `:::touched` style; everything else -stays neutral context. - -**Design patterns must be annotated visually inside the diagram, not -just stated in section 3.** Use mermaid stereotypes and notes: - -- `class AuthResolver { <> ... }` for pattern role -- `note for AuthResolver "Chain of Responsibility: token -> env -> cli"` - for cross-class pattern application -- `<|--` for inheritance, `*--` for composition, `o--` for aggregation, - `..>` for dependency - -What good looks like (annotated, problem-space context, not a -copy-paste template): - -```` -```mermaid -classDiagram - direction LR - class AuthResolver { - <> - +resolve_for(host) AuthContext - } - class TokenStrategy { - <> - +resolve(host) AuthContext - } - class EnvVarStrategy { - <> - +resolve(host) AuthContext - } - class AzureCliBearerProvider { - <> - +resolve(host) AuthContext - } - class HostInfo { - <> - +hostname str - +scheme str - } - class AuthContext { - <> - +token str - +source str - } - AuthResolver *-- TokenStrategy : delegates - AuthResolver *-- EnvVarStrategy : delegates - AuthResolver *-- AzureCliBearerProvider : delegates - AuthResolver ..> HostInfo : reads - TokenStrategy ..> AuthContext : returns - EnvVarStrategy ..> AuthContext : returns - AzureCliBearerProvider ..> AuthContext : returns - note for AuthResolver "Chain of Responsibility:\ntoken -> env -> az-cli-bearer" - class AzureCliBearerProvider:::touched - classDef touched fill:#fff3b0,stroke:#d47600 -``` -```` - -(That example is illustrative bar-setting; do NOT copy its contents. -Read the PR's diff and surrounding code, then draw the actual -problem-space classes.) - -If the PR is purely procedural (no class changes anywhere in scope), -state that explicitly and substitute a `classDiagram` showing the -module boundaries and the function entry points -- still annotated -with patterns where they apply (e.g. `<>`, `<>`). - -For **major architectural changes**, supply a Before block and an -After block, side-by-side under the `### 1.` heading. Use the same -class names across both so the diff is visible at a glance. Do NOT -re-stylize the Before block to look identical to the After -- the -visual delta is the whole point. - -### 2. Execution flow diagram (mermaid) - -A `flowchart TD` showing the **actual runtime path** through the -system as the PR changes it. Start from the user-visible entry point -(CLI command, HTTP request, plugin hook). Use **real function names, -real file paths, real exit codes** from the diff. Annotate every node -that touches I/O, network, locks, filesystem, or external processes -with a leading marker so the side-effect surface is scannable: - -- `[I/O]` for reads / writes -- `[NET]` for HTTP / git fetch / DNS -- `[FS]` for filesystem mutations -- `[LOCK]` for lock acquisition or lockfile writes -- `[EXEC]` for subprocess / shell-out - -Refused outputs (orchestrator will re-invoke): - -- Generic node labels ("Decision or guard?", "New behavior added by - this PR", "Existing behavior preserved", "Side effect"). -- Diagrams that name no functions, no files, no concrete branches. -- Single linear chain when the code actually has branches. - -The bar: a reviewer who has not read the diff should be able to grep -for the function names in the diagram and find the exact code paths. - -For **major architectural changes**, supply a Before block and an -After block under `### 2.`, same node labels where unchanged, so the -new gate / fork / async boundary jumps out of the diff. - -### 3. Design patterns - -A short subsection in this exact shape: - -``` -**Design patterns** -- Used in this PR: -- -- Pragmatic suggestion: -- -``` - -Rules for this subsection: - -- Every "Used in this PR" entry MUST be visible as a `<>` - or `note for X` in the section-1 class diagram. Patterns claimed - in prose but not annotated in the diagram are refused. -- "Used in this PR" lists patterns the PR actually applies (Strategy, - Chain of Responsibility, Base + subclass, Collect-then-render, - Dataclass-as-value-object, Factory, Adapter, Observer, etc.). If - none, write "Used in this PR: none -- straight-line procedural code, - appropriate for the scope." -- "Pragmatic suggestion" proposes at most one or two patterns whose - introduction would be a net win at the PR's current size. Do NOT - suggest patterns that would only pay off at 3-5x the current scope - -- speed and simplicity over complexity (see Design Philosophy above). -- If the PR is already idiomatic and adding any pattern would be - over-engineering, write "Pragmatic suggestion: none -- the current - shape is the simplest correct design at this scope." That is a valid - and preferred answer when true. diff --git a/.github/agents/supply-chain-security-expert.agent.md b/.github/agents/supply-chain-security-expert.agent.md deleted file mode 100644 index 4c8a2eafd..000000000 --- a/.github/agents/supply-chain-security-expert.agent.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -name: supply-chain-security-expert -description: >- - Supply-chain cybersecurity expert. Activate when reviewing dependency - resolution, lockfile integrity, package downloads, signature/integrity - checks, token scoping, or any surface that could enable dependency - confusion, typosquatting, or malicious-package execution in APM. -model: claude-opus-4.6 ---- - -# Supply Chain Security Expert - -You are a supply-chain security specialist. Your job is to ensure APM -does not become a vector for the attacks that have hit npm, PyPI, -RubyGems, and Maven Central -- and to make APM safer than them where -possible. - -## Canonical references (load on demand) - -Treat these as the single source of truth for APM's security posture -and pull into context when reviewing security-relevant changes: - -- [`docs/src/content/docs/enterprise/security.md`](../../docs/src/content/docs/enterprise/security.md) -- the **Security Model**: attack-surface boundaries, "what APM does / does NOT do", pre-deployment scanning gate, dependency provenance, path safety, MCP trust. This is the contract you defend. -- [`docs/src/content/docs/reference/lockfile-spec.md`](../../docs/src/content/docs/reference/lockfile-spec.md) -- canonical `apm.lock.yaml` format; commit-SHA pinning is the integrity primitive. -- [`docs/src/content/docs/enterprise/governance.md`](../../docs/src/content/docs/enterprise/governance.md) and [`policy-reference.md`](../../docs/src/content/docs/enterprise/policy-reference.md) -- policy enforcement surface and CI gate semantics. -- [`packages/apm-guide/.apm/skills/apm-usage/governance.md`](../../packages/apm-guide/.apm/skills/apm-usage/governance.md) -- shipped skill resource; must stay in sync with the policy reference (per repo Rule 4). -- `src/apm_cli/integration/cleanup.py` and `src/apm_cli/utils/path_security.py` -- the chokepoints; any new file deletion or path resolution MUST flow through these. - -If a code change weakens or contradicts any guarantee in `enterprise/security.md`, the doc must be updated in the same PR -- never let the security model drift silently from behavior. - -## Threat model APM must defend against - -1. **Dependency confusion.** Public registry shadowing a private name. -2. **Typosquatting.** `apm-cli` vs `apmcli` vs `apm.cli`. -3. **Malicious updates.** Compromised maintainer publishes a poisoned - version under an existing name. -4. **Lockfile drift / forgery.** Lockfile content does not match what - gets installed. -5. **Token over-scope.** PATs with `repo` when `read:packages` would do. -6. **Credential exfiltration.** Tokens leaked via logs, error messages, - or transitive dependency execution. -7. **Path traversal during install.** A package writes outside its - target directory. -8. **Post-install code execution.** Anything that runs arbitrary code - at install time without explicit user opt-in. - -## Review lens - -When reviewing code that touches dependencies, auth, downloads, or -file integration, ask: - -1. **Identity.** How does APM know this package is the one the user - asked for? What gets compared against what (URL, ref, sha)? -2. **Integrity.** Is content verified against a recorded hash? Where - does the hash come from -- the lockfile, the registry, the network? -3. **Provenance.** Can a user audit where every deployed file came - from? (See `.apm/lock` content-hash provenance.) -4. **Least privilege.** What is the minimum token scope needed? Do - error messages avoid leaking token values? -5. **Containment.** Does this code path use the - `path_security.validate_path_segments` / - `ensure_path_within` guards? Is symlink resolution applied? -6. **Determinism.** Two installs from the same `apm.lock` on different - machines -- bit-identical output? -7. **Fail closed.** If a check cannot be performed (network down, - signature missing), does the code default to refusing rather than - proceeding silently? - -## Required references - -- `src/apm_cli/utils/path_security.py` -- the only sanctioned path - guards. Ad-hoc `".." in x` checks are bugs. -- `src/apm_cli/integration/cleanup.py` -- the chokepoint for all - deletion of deployed files (3 safety gates). -- `src/apm_cli/core/auth.py` -- AuthResolver is the only legitimate - source of credentials. No `os.getenv("...TOKEN...")` in app code. -- `src/apm_cli/deps/lockfile.py` -- lockfile is the source of truth - for resolved identity. - -## Anti-patterns to block - -- Hash recorded after download from the same source (circular trust) -- Token values appearing in any user-facing string -- Path joins without containment checks -- Silent fallback when a signature / integrity check fails -- Install-time hooks that execute package-supplied code without - explicit user consent -- Error messages that suggest disabling a security check as a fix - -## Boundaries - -- You review threat surfaces and propose mitigations. You do NOT make - UX trade-off calls -- if a mitigation hurts ergonomics, surface the - trade-off to the DevX UX expert and escalate to the CEO. -- You do NOT own the auth implementation -- defer to the Auth expert - skill for AuthResolver internals. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index aaad43465..b37a83139 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,3 +1,9 @@ + + + +# Copilot Instructions + + - 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. @@ -24,4 +30,5 @@ The architectural decisions and basis for the project in that document are only - 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 +- **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. + diff --git a/.github/instructions/changelog.instructions.md b/.github/instructions/changelog.instructions.md deleted file mode 100644 index 5b782f7e8..000000000 --- a/.github/instructions/changelog.instructions.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -applyTo: "CHANGELOG.md" -description: "Changelog format and conventions based on Keep a Changelog" ---- - -# Changelog Format - -This project follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and [Semantic Versioning](https://semver.org/). - -## Structure - -- New entries go under `## [Unreleased]`. -- Released versions use `## [X.Y.Z] - YYYY-MM-DD`. -- Group entries by type: `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`. - -## Entry format - -- One line per PR: concise description ending with `(#PR_NUMBER)`. -- Credit external contributors inline: `— by @username (#PR_NUMBER)`. -- Combine related PRs into a single line when they form one logical change: `(#251, #256, #258)`. -- Use backticks for code references: commands, file names, config keys, classes. - -## Rules - -- Every merged PR that changes code, tests, docs, or dependencies must have a changelog entry. -- Do NOT include version-bump or release-machinery PRs (e.g., "chore: bump to vX.Y.Z"). -- When releasing, move Unreleased entries into a new versioned section — never delete them. diff --git a/.github/instructions/doc-sync.instructions.md b/.github/instructions/doc-sync.instructions.md deleted file mode 100644 index fa6aae81d..000000000 --- a/.github/instructions/doc-sync.instructions.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -applyTo: "**" -description: "Rules to keep documentation synchronized with code changes" ---- - -# Rules to keep documentation up-to-date - -- Rule 1: Whenever changes are made to the codebase, it is important to also update the documentation to reflect those changes. You must ensure that the following documentation is updated: [Starlight content pages in docs/src/content/docs/](../../docs/src/content/docs/). Each page uses Starlight frontmatter (title, sidebar order). Cross-page links use relative paths (e.g., `../../guides/compilation/`). - -- Rule 2: The main [README.md](../../README.md) file is a special case that requires user approval before changes, so, if there is a deviation in the code that affects what is stated in the main [README.md](../../README.md) file, you must warn the user and describe the drift and [README.md](../../README.md) update proposal, and wait for confirmation before updating it. - -- Rule 3: Documentation is meant to be very simple and straightforward, we must avoid bloating it with unnecessary information. It must be pragmatic, to the point, succinct and practical. - -- Rule 4: When changing CLI commands, flags, dependency formats, authentication flow, policy schema, or primitive file formats, also update the corresponding resource files in [packages/apm-guide/.apm/skills/apm-usage/](../../packages/apm-guide/.apm/skills/apm-usage/). Map changes to the correct file: commands.md for CLI changes, dependencies.md for reference formats, authentication.md for token resolution, governance.md for policy schema, package-authoring.md for primitive formats. diff --git a/.github/instructions/encoding.instructions.md b/.github/instructions/encoding.instructions.md deleted file mode 100644 index b923ef76a..000000000 --- a/.github/instructions/encoding.instructions.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -applyTo: "**" -description: "Cross-platform encoding rules — keep all source and CLI output within printable ASCII" ---- - -# Encoding Rules - -## Constraint - -All source code files and CLI output strings must stay within **printable ASCII** (U+0020–U+007E). - -Do NOT use: -- Emojis (e.g. `🚀`, `✨`, `❌`) -- Unicode box-drawing characters (e.g. `─`, `│`, `┌`) -- Em dashes (`—`), en dashes (`–`), curly quotes (`"`, `"`, `'`, `'`) -- Any character outside the ASCII range (codepoint > U+007E) - -**Why**: Windows `cp1252` terminals raise `UnicodeEncodeError: 'charmap' codec can't encode character` for any character outside cp1252. Keeping output within ASCII guarantees identical behaviour on every platform without dual-path fallback logic. - -## Status symbol convention - -Use ASCII bracket notation consistently across all CLI output, help text, and log messages: - -| Symbol | Meaning | -|--------|----------------------| -| `[+]` | success / confirmed | -| `[!]` | warning | -| `[x]` | error | -| `[i]` | info | -| `[*]` | action / processing | -| `[>]` | running / progress | - -These map directly to the `STATUS_SYMBOLS` dict in `src/apm_cli/utils/console.py`. - -## Scope - -This rule applies to: -- Python source files (`*.py`) -- CLI help strings and command output -- Markdown documentation and instruction files under `.github/` -- Shell scripts and CI workflow files - -Exception: binary assets and third-party vendored files are excluded. diff --git a/.github/instructions/python.instructions.md b/.github/instructions/python.instructions.md deleted file mode 100644 index fa5a10a18..000000000 --- a/.github/instructions/python.instructions.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -description: Python development guidelines -applyTo: '**/*.py' ---- - -Use type hints for all function parameters and return values. -Follow PEP 8 style guidelines. -Write comprehensive docstrings. diff --git a/.gitignore b/.gitignore index 8dd1c34a4..4744f08e5 100644 --- a/.gitignore +++ b/.gitignore @@ -61,7 +61,8 @@ docs/wip/ *.log .env.local .env.*.local -AGENTS.md +# AGENTS.md and CLAUDE.md are generated by `apm compile` and committed. +# Template AGENTS.md fixtures are excluded via a directory pattern in templates/. PRD.md PRD*.md WIP/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..af6dea065 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,95 @@ +# AGENTS.md + + + + + +## Files matching `**` + + +# Rules to keep documentation up-to-date + +- Rule 1: Whenever changes are made to the codebase, it is important to also update the documentation to reflect those changes. You must ensure that the following documentation is updated: [Starlight content pages in docs/src/content/docs/](../../docs/src/content/docs/). Each page uses Starlight frontmatter (title, sidebar order). Cross-page links use relative paths (e.g., `../../guides/compilation/`). + +- Rule 2: The main [README.md](../../README.md) file is a special case that requires user approval before changes, so, if there is a deviation in the code that affects what is stated in the main [README.md](../../README.md) file, you must warn the user and describe the drift and [README.md](../../README.md) update proposal, and wait for confirmation before updating it. + +- Rule 3: Documentation is meant to be very simple and straightforward, we must avoid bloating it with unnecessary information. It must be pragmatic, to the point, succinct and practical. + +- Rule 4: When changing CLI commands, flags, dependency formats, authentication flow, policy schema, or primitive file formats, also update the corresponding resource files in [packages/apm-guide/.apm/skills/apm-usage/](../../packages/apm-guide/.apm/skills/apm-usage/). Map changes to the correct file: commands.md for CLI changes, dependencies.md for reference formats, authentication.md for token resolution, governance.md for policy schema, package-authoring.md for primitive formats. + + +# Encoding Rules + +## Constraint + +All source code files and CLI output strings must stay within **printable ASCII** (U+0020–U+007E). + +Do NOT use: +- Emojis (e.g. `🚀`, `✨`, `❌`) +- Unicode box-drawing characters (e.g. `─`, `│`, `┌`) +- Em dashes (`—`), en dashes (`–`), curly quotes (`"`, `"`, `'`, `'`) +- Any character outside the ASCII range (codepoint > U+007E) + +**Why**: Windows `cp1252` terminals raise `UnicodeEncodeError: 'charmap' codec can't encode character` for any character outside cp1252. Keeping output within ASCII guarantees identical behaviour on every platform without dual-path fallback logic. + +## Status symbol convention + +Use ASCII bracket notation consistently across all CLI output, help text, and log messages: + +| Symbol | Meaning | +|--------|----------------------| +| `[+]` | success / confirmed | +| `[!]` | warning | +| `[x]` | error | +| `[i]` | info | +| `[*]` | action / processing | +| `[>]` | running / progress | + +These map directly to the `STATUS_SYMBOLS` dict in `src/apm_cli/utils/console.py`. + +## Scope + +This rule applies to: +- Python source files (`*.py`) +- CLI help strings and command output +- Markdown documentation and instruction files under `.github/` +- Shell scripts and CI workflow files + +Exception: binary assets and third-party vendored files are excluded. + +## Files matching `**/*.py` + + +Use type hints for all function parameters and return values. +Follow PEP 8 style guidelines. +Write comprehensive docstrings. + +## Files matching `CHANGELOG.md` + + +# Changelog Format + +This project follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and [Semantic Versioning](https://semver.org/). + +## Structure + +- New entries go under `## [Unreleased]`. +- Released versions use `## [X.Y.Z] - YYYY-MM-DD`. +- Group entries by type: `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`. + +## Entry format + +- One line per PR: concise description ending with `(#PR_NUMBER)`. +- Credit external contributors inline: `— by @username (#PR_NUMBER)`. +- Combine related PRs into a single line when they form one logical change: `(#251, #256, #258)`. +- Use backticks for code references: commands, file names, config keys, classes. + +## Rules + +- Every merged PR that changes code, tests, docs, or dependencies must have a changelog entry. +- Do NOT include version-bump or release-machinery PRs (e.g., "chore: bump to vX.Y.Z"). +- When releasing, move Unreleased entries into a new versioned section — never delete them. + +--- +*This file was generated by APM CLI. Do not edit manually.* +*To regenerate: `specify apm compile`* diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..ead7af806 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,96 @@ +# CLAUDE.md + + + + +# Project Standards + +## Files matching `**` + + +# Rules to keep documentation up-to-date + +- Rule 1: Whenever changes are made to the codebase, it is important to also update the documentation to reflect those changes. You must ensure that the following documentation is updated: [Starlight content pages in docs/src/content/docs/](../../docs/src/content/docs/). Each page uses Starlight frontmatter (title, sidebar order). Cross-page links use relative paths (e.g., `../../guides/compilation/`). + +- Rule 2: The main [README.md](../../README.md) file is a special case that requires user approval before changes, so, if there is a deviation in the code that affects what is stated in the main [README.md](../../README.md) file, you must warn the user and describe the drift and [README.md](../../README.md) update proposal, and wait for confirmation before updating it. + +- Rule 3: Documentation is meant to be very simple and straightforward, we must avoid bloating it with unnecessary information. It must be pragmatic, to the point, succinct and practical. + +- Rule 4: When changing CLI commands, flags, dependency formats, authentication flow, policy schema, or primitive file formats, also update the corresponding resource files in [packages/apm-guide/.apm/skills/apm-usage/](../../packages/apm-guide/.apm/skills/apm-usage/). Map changes to the correct file: commands.md for CLI changes, dependencies.md for reference formats, authentication.md for token resolution, governance.md for policy schema, package-authoring.md for primitive formats. + + +# Encoding Rules + +## Constraint + +All source code files and CLI output strings must stay within **printable ASCII** (U+0020–U+007E). + +Do NOT use: +- Emojis (e.g. `🚀`, `✨`, `❌`) +- Unicode box-drawing characters (e.g. `─`, `│`, `┌`) +- Em dashes (`—`), en dashes (`–`), curly quotes (`"`, `"`, `'`, `'`) +- Any character outside the ASCII range (codepoint > U+007E) + +**Why**: Windows `cp1252` terminals raise `UnicodeEncodeError: 'charmap' codec can't encode character` for any character outside cp1252. Keeping output within ASCII guarantees identical behaviour on every platform without dual-path fallback logic. + +## Status symbol convention + +Use ASCII bracket notation consistently across all CLI output, help text, and log messages: + +| Symbol | Meaning | +|--------|----------------------| +| `[+]` | success / confirmed | +| `[!]` | warning | +| `[x]` | error | +| `[i]` | info | +| `[*]` | action / processing | +| `[>]` | running / progress | + +These map directly to the `STATUS_SYMBOLS` dict in `src/apm_cli/utils/console.py`. + +## Scope + +This rule applies to: +- Python source files (`*.py`) +- CLI help strings and command output +- Markdown documentation and instruction files under `.github/` +- Shell scripts and CI workflow files + +Exception: binary assets and third-party vendored files are excluded. + +## Files matching `**/*.py` + + +Use type hints for all function parameters and return values. +Follow PEP 8 style guidelines. +Write comprehensive docstrings. + +## Files matching `CHANGELOG.md` + + +# Changelog Format + +This project follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and [Semantic Versioning](https://semver.org/). + +## Structure + +- New entries go under `## [Unreleased]`. +- Released versions use `## [X.Y.Z] - YYYY-MM-DD`. +- Group entries by type: `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`. + +## Entry format + +- One line per PR: concise description ending with `(#PR_NUMBER)`. +- Credit external contributors inline: `— by @username (#PR_NUMBER)`. +- Combine related PRs into a single line when they form one logical change: `(#251, #256, #258)`. +- Use backticks for code references: commands, file names, config keys, classes. + +## Rules + +- Every merged PR that changes code, tests, docs, or dependencies must have a changelog entry. +- Do NOT include version-bump or release-machinery PRs (e.g., "chore: bump to vX.Y.Z"). +- When releasing, move Unreleased entries into a new versioned section — never delete them. + +--- +*This file was generated by APM CLI. Do not edit manually.* +*To regenerate: `apm compile`* diff --git a/apm.yml b/apm.yml index 252d7d0fa..9caf9c7d8 100644 --- a/apm.yml +++ b/apm.yml @@ -4,3 +4,11 @@ description: "APM - Agent Package Manager. The developer toolchain for agent pri author: Microsoft license: MIT target: all + +compilation: + exclude: + - "tests/**" + - "templates/**" + - "packages/**" + - "build/**" + - "docs/node_modules/**" diff --git a/docs/src/AGENTS.md b/docs/src/AGENTS.md new file mode 100644 index 000000000..f2669d928 --- /dev/null +++ b/docs/src/AGENTS.md @@ -0,0 +1,34 @@ +# AGENTS.md + + + + + +## Files matching `**/*.{ts,tsx}` + + +## TypeScript Development Standards + +### Type Safety +- Use strict TypeScript configuration +- Prefer interfaces over types for object shapes +- Implement proper generic constraints +- Avoid `any` type - use `unknown` for dynamic content + +### Code Structure +- Use barrel exports for clean imports +- Implement proper error boundaries in React components +- Follow functional programming principles where appropriate +- Use composition over inheritance + +### Testing Requirements +- Write unit tests for all utility functions +- Test React components with React Testing Library +- Implement integration tests for API interactions +- Achieve minimum 80% code coverage + +See [project architecture](../../templates/hello-world/.apm/context/architecture.context.md) for detailed patterns. + +--- +*This file was generated by APM CLI. Do not edit manually.* +*To regenerate: `specify apm compile`* diff --git a/docs/src/CLAUDE.md b/docs/src/CLAUDE.md new file mode 100644 index 000000000..33c06a226 --- /dev/null +++ b/docs/src/CLAUDE.md @@ -0,0 +1,35 @@ +# CLAUDE.md + + + + +# Project Standards + +## Files matching `**/*.{ts,tsx}` + + +## TypeScript Development Standards + +### Type Safety +- Use strict TypeScript configuration +- Prefer interfaces over types for object shapes +- Implement proper generic constraints +- Avoid `any` type - use `unknown` for dynamic content + +### Code Structure +- Use barrel exports for clean imports +- Implement proper error boundaries in React components +- Follow functional programming principles where appropriate +- Use composition over inheritance + +### Testing Requirements +- Write unit tests for all utility functions +- Test React components with React Testing Library +- Implement integration tests for API interactions +- Achieve minimum 80% code coverage + +See [project architecture](../context/architecture.context.md) for detailed patterns. + +--- +*This file was generated by APM CLI. Do not edit manually.* +*To regenerate: `apm compile`* diff --git a/src/apm_cli/AGENTS.md b/src/apm_cli/AGENTS.md new file mode 100644 index 000000000..198d39de9 --- /dev/null +++ b/src/apm_cli/AGENTS.md @@ -0,0 +1,170 @@ +# AGENTS.md + + + + + +## Files matching `src/apm_cli/cli.py` + + +# CLI Design Guidelines + +## Visual Design Standards + +### Rich Library Usage +- **ALWAYS** use Rich library for visual output when available +- Provide graceful fallbacks to colorama for compatibility +- Use the established `console` instance with custom theme +- Wrap Rich imports in try/catch with colorama fallbacks + +### Command Help Text +- Keep command help strings plain ASCII — no emojis +- Format: `help="Initialize a new APM project"` + +### Status Symbols & Feedback +- Use `STATUS_SYMBOLS` dict for consistent ASCII bracket notation: + - `[+]` success / confirmed + - `[>]` running / execution / progress + - `[*]` action / configuration / processing + - `[i]` information / tips + - `[#]` lists / metrics + - `[!]` warnings + - `[x]` errors +- Use helper functions: `_rich_success()`, `_rich_error()`, `_rich_info()`, `_rich_warning()` +- Pass the appropriate key from `STATUS_SYMBOLS` via the `symbol=` parameter (e.g. `symbol="check"`, `symbol="warning"`) + +### Structured Output +- **Tables**: Use Rich tables for structured data (scripts, models, config, runtimes) +- **Panels**: Use Rich panels for grouped content, next steps, examples +- **Consistent Spacing**: Add empty lines between sections with `console.print()` or `click.echo()` + +### Error Handling +- Use `_rich_error()` for all error messages +- Always include contextual symbols +- Provide actionable suggestions when possible +- Maintain consistent error message format + +### Interactive Elements +- Use Rich `Prompt.ask()` and `Confirm.ask()` when available +- Provide click fallbacks for compatibility +- Display confirmations in Rich panels when possible + +## Code Organization + +### Helper Functions +- Use existing helper functions: `_rich_echo()`, `_rich_panel()`, `_create_files_table()` +- Create new helpers following the same pattern +- Always include Rich/colorama fallback logic + +### Color Scheme +- Primary: cyan for titles and highlights +- Success: green with `[+]` symbol +- Warning: yellow with `[!]` symbol +- Error: red with `[x]` symbol +- Info: blue with `[i]` symbol +- Muted: dim white for secondary text + +### Table Design +- Include meaningful titles (plain ASCII, no emojis) +- Use semantic column styling (bold for names, muted for details) +- Keep tables clean with appropriate padding +- Show status with bracket symbols in dedicated columns + +## Implementation Patterns + +### Command Structure +```python +@cli.command(help="Action description") +@click.option(...) +def command_name(...): + """Detailed docstring.""" + try: + _rich_info("Starting operation...", symbol="gear") + + # Main logic here + + _rich_success("Operation complete!", symbol="check") + except Exception as e: + _rich_error(f"Error: {e}", symbol="error") + sys.exit(1) +``` + +### Table Creation +```python +try: + table = Table(title="Title", show_header=True, header_style="bold cyan") + table.add_column("Name", style="bold white") + table.add_column("Details", style="white") + console.print(table) +except (ImportError, NameError): + # Colorama fallback +``` + +### Panel Usage +```python +try: + _rich_panel(content, title="Section Title", style="cyan") +except (ImportError, NameError): + # Simple text fallback +``` + +## Quality Standards + +### User Experience +- Every action should have clear visual feedback +- Group related information in panels or tables +- Use consistent symbols throughout the application +- Provide helpful next steps and examples + +### Accessibility +- Maintain colorama fallbacks for all Rich features +- Use semantic text alongside visual elements +- Ensure information is conveyed through text, not just color + +### Performance +- Import Rich modules only when needed +- Handle import failures gracefully +- Don't block on visual enhancements + +## Examples to Follow + +- **init command**: Shows Rich panels, file tables, next steps +- **list command**: Professional table with default script indicators +- **preview command**: Side-by-side panels for original/compiled +- **config command**: Clean configuration display + +## What NOT to Do + +- **Never** use plain `click.echo()` without styling +- **Never** mix color schemes or symbols inconsistently +- **Never** create walls of text without visual structure +- **Never** forget Rich import fallbacks +- **Never** sacrifice functionality for visuals +- **Never** use emojis or non-ASCII characters in source code or CLI output + +## Documentation Sync Requirements + +### CLI Reference Documentation +- **ALWAYS** update `docs/cli-reference.md` when adding, modifying, or removing CLI commands +- **ALWAYS** update command help text, options, arguments, and examples in the reference +- **ALWAYS** verify examples in the documentation actually work with the current implementation +- **ALWAYS** keep the command list in sync with available commands + +### Documentation Update Checklist +When changing CLI functionality, update these sections in `docs/cli-reference.md`: +- Command syntax and arguments +- Available options and flags +- Usage examples +- Return codes and error handling +- Quick reference sections + +### Documentation Standards +- Use plain ASCII text in documentation (no emojis in CLI help text or output examples) +- Include realistic, working examples that users can copy-paste +- Document both success and error scenarios +- Keep examples current with the latest syntax +- Maintain consistency between CLI help and reference documentation + +--- +*This file was generated by APM CLI. Do not edit manually.* +*To regenerate: `specify apm compile`* diff --git a/.github/instructions/cli.instructions.md b/src/apm_cli/CLAUDE.md similarity index 93% rename from .github/instructions/cli.instructions.md rename to src/apm_cli/CLAUDE.md index 067fe490f..54cf1edb8 100644 --- a/.github/instructions/cli.instructions.md +++ b/src/apm_cli/CLAUDE.md @@ -1,8 +1,13 @@ ---- -applyTo: "src/apm_cli/cli.py" -description: "CLI Design Guidelines for visual output, styling, and user experience standards" ---- +# CLAUDE.md + + + + +# Project Standards +## Files matching `src/apm_cli/cli.py` + + # CLI Design Guidelines ## Visual Design Standards @@ -160,3 +165,7 @@ When changing CLI functionality, update these sections in `docs/cli-reference.md - Document both success and error scenarios - Keep examples current with the latest syntax - Maintain consistency between CLI help and reference documentation + +--- +*This file was generated by APM CLI. Do not edit manually.* +*To regenerate: `apm compile`* diff --git a/src/apm_cli/integration/AGENTS.md b/src/apm_cli/integration/AGENTS.md new file mode 100644 index 000000000..3a3c67556 --- /dev/null +++ b/src/apm_cli/integration/AGENTS.md @@ -0,0 +1,71 @@ +# AGENTS.md + + + + + +## Files matching `src/apm_cli/integration/**` + + +# Integrator Architecture + +## Design philosophy + +APM runs inside repositories of any size — from single-package repos to monorepos with thousands of packages and deep dependency trees. Every integrator must assume it will operate at that scale. The architecture is built around two principles: + +1. **One base, many file types.** All file-level integrators share a single `BaseIntegrator` infrastructure for collision detection, manifest-based sync, path security, link resolution, and file discovery. New integrators add *what* to deploy, never *how* to deploy. When logic belongs to more than one integrator, push it into `BaseIntegrator`. +2. **Pay only for what you touch.** Operations must be proportional to the files a single package deploys, not the size of the workspace or the total managed-files set. Pre-normalize once, partition once, look up in O(1). Avoid full-tree walks, per-file parent cleanup, or repeated set scans. + +When evolving integration logic — new file types, richer transforms, cross-package awareness — preserve these properties. If a change would violate either principle, refactor the base class first. + +## Required structure + +Every file-level integrator **must** extend `BaseIntegrator` and return `IntegrationResult`. + +```python +from apm_cli.integration.base_integrator import BaseIntegrator, IntegrationResult + +class FooIntegrator(BaseIntegrator): + def find_foo_files(self, package_path: Path) -> List[Path]: ... + def copy_foo(self, source: Path, target: Path) -> int: ... + def integrate_package_foos(self, package_info, project_root: Path, + force: bool = False, + managed_files: set = None) -> IntegrationResult: ... + def sync_integration(self, apm_package, project_root: Path, + managed_files: set = None) -> Dict[str, int]: ... +``` + +## Base-class methods — use, don't reimplement + +Before writing custom logic, check whether `BaseIntegrator` already solves the problem. Duplicating behaviour that exists in the base class creates drift, bugs, and performance regressions. + +| Operation | Use | Never | +|---|---|---| +| Collision detection | `self.check_collision(target_path, rel_path, managed_files, force)` | Custom existence checks | +| Link resolution | `self.init_link_resolver()` + `self.resolve_links()` | Direct `UnifiedLinkResolver` calls | +| File discovery | `self.find_files_by_glob(path, pattern, subdirs=)` | Ad-hoc `os.walk` / recursive globs | +| Path validation | `BaseIntegrator.validate_deploy_path()` | Inline `..` or prefix checks | +| File removal (sync) | `self.sync_remove_files(project_root, managed_files, prefix=, legacy_glob_dir=, legacy_glob_pattern=)` | Manual scan-and-delete | +| Empty-dir cleanup | `BaseIntegrator.cleanup_empty_parents(deleted, stop_at)` | Per-file parent removal loops | + +If you need an operation the base class does not support, **add it to `BaseIntegrator`** so every integrator benefits. + +## Wiring checklist (cli.py) + +- **Install path**: record each `result.target_paths` entry in `dep_deployed` using `.as_posix()`. +- **Uninstall path**: call `BaseIntegrator.partition_managed_files()` once, pass the appropriate bucket to `sync_integration()`. +- **Exports**: add the new integrator to `src/apm_cli/integration/__init__.py`. + +## Performance guidance + +The specific techniques below exist to serve the "pay only for what you touch" principle. As the codebase evolves, new code must uphold the same standard — if a new feature would regress install/uninstall to O(N × M) where N is packages and M is managed files, find a better design. + +- `managed_files` must be pre-normalized with `normalize_managed_files()` for **O(1)** set lookups — never iterate the set to find a path. +- `partition_managed_files()` runs a **single O(M) pass** over managed files — do not filter per-integrator. +- `cleanup_empty_parents()` does a **bottom-up batch** — never call `rmdir()` per deleted file. +- File-discovery globs must be **scoped** to known subdirectories, not walk the entire package tree. +- All path strings stored in `apm.lock` must use **forward slashes** (`.as_posix()`). + +--- +*This file was generated by APM CLI. Do not edit manually.* +*To regenerate: `specify apm compile`* diff --git a/.github/instructions/integrators.instructions.md b/src/apm_cli/integration/CLAUDE.md similarity index 91% rename from .github/instructions/integrators.instructions.md rename to src/apm_cli/integration/CLAUDE.md index 2b00687f4..fc3471b17 100644 --- a/.github/instructions/integrators.instructions.md +++ b/src/apm_cli/integration/CLAUDE.md @@ -1,8 +1,13 @@ ---- -applyTo: "src/apm_cli/integration/**" -description: "Architecture rules for file-level integrators (BaseIntegrator pattern)" ---- +# CLAUDE.md + + + + +# Project Standards +## Files matching `src/apm_cli/integration/**` + + # Integrator Architecture ## Design philosophy @@ -61,3 +66,7 @@ The specific techniques below exist to serve the "pay only for what you touch" p - `cleanup_empty_parents()` does a **bottom-up batch** — never call `rmdir()` per deleted file. - File-discovery globs must be **scoped** to known subdirectories, not walk the entire package tree. - All path strings stored in `apm.lock` must use **forward slashes** (`.as_posix()`). + +--- +*This file was generated by APM CLI. Do not edit manually.* +*To regenerate: `apm compile`* diff --git a/tests/AGENTS.md b/tests/AGENTS.md new file mode 100644 index 000000000..9a3b617b3 --- /dev/null +++ b/tests/AGENTS.md @@ -0,0 +1,117 @@ +# AGENTS.md + + + + + +## Files matching `tests/**` + + +# Test Conventions + +## URL assertions: use `urllib.parse`, never substring + +Any assertion that a URL appears in or matches some output **must** parse the +URL with `urllib.parse.urlparse` and compare on a parsed component +(`hostname`, `port`, `scheme`, `path`). Substring assertions like +`assert "host.example.com" in msg` or `assert "https://x" in url` are flagged +by CodeQL as `py/incomplete-url-substring-sanitization` (high severity, "the +string may be at an arbitrary position in the URL") and **will fail CI**. + +This rule applies regardless of whether the value being asserted looks like a +"safe" hostname — CodeQL is a static check and cannot infer that `host` in +`assert host in msg` is bounded; the alert fires anyway. + +### Wrong + +```python +# Substring match -- CodeQL py/incomplete-url-substring-sanitization +assert "registry.example.com" in msg +assert "https://api.github.com/v0/servers" in url +assert "127.0.0.1" in warning_text + +# Set membership of substring -- still flagged (CodeQL can't infer set type) +hosts = {urlparse(tok).hostname for tok in msg.split() if "://" in tok} +assert "poisoned.example.com" in hosts +``` + +### Right + +```python +from urllib.parse import urlparse + +# Direct hostname equality on a parsed URL token +urls = [tok for tok in msg.split() if "://" in tok] +assert len(urls) == 1 +assert urlparse(urls[0]).hostname == "registry.example.com" + +# Set equality (not membership) when multiple URLs are expected +hosts = {urlparse(tok.strip("()")).hostname for tok in msg.split() if "://" in tok} +assert hosts == {"a.example.com", "b.example.com"} + +# Component-level checks for path / scheme / port +parsed = urlparse(url) +assert parsed.scheme == "https" +assert parsed.hostname == "api.github.com" +assert parsed.path == "/v0/servers" +``` + +### Helper pattern for multi-URL output + +When asserting against logger / CLI output that may contain multiple URLs, +extract them with a small helper and assert on the parsed tuple: + +```python +def _printed_urls(text: str) -> list[tuple[str, str, str]]: + """Extract (scheme, hostname, path) tuples from any URLs in text.""" + from urllib.parse import urlparse + out = [] + for token in text.split(): + cleaned = token.strip("(),.;'\"") + if "://" not in cleaned: + continue + p = urlparse(cleaned) + out.append((p.scheme, p.hostname or "", p.path)) + return out + +assert ("https", "registry.example.com", "/v0/servers") in _printed_urls(msg) +``` + +`tests/unit/test_mcp_command.py` already uses this pattern; reuse it (or +copy it) rather than inventing a new substring check. + +## Why the rule applies even to "obviously safe" tests + +The CodeQL rule is intentionally conservative: a substring assertion against a +URL string is the same code shape as a security-critical sanitizer check, and +the analyzer cannot tell them apart. Treating every URL assertion uniformly +through `urlparse` keeps CI green AND reinforces the security pattern that +production code must follow (see +`src/apm_cli/install/mcp_registry.py::_redact_url_credentials` and +`src/apm_cli/install/mcp_registry.py::_is_local_or_metadata_host`). + +## Other rules + +- **No live network calls.** Tests must never hit a real HTTP endpoint; use + `unittest.mock.patch('requests.Session.get')` or + `monkeypatch.setattr(client.session, "get", fake)`. Live-inference tests + are isolated to `ci-runtime.yml` and gated by `APM_RUN_INFERENCE_TESTS=1`. + +- **Patch where the name is looked up.** When a function moved to + `apm_cli/install/phases/X.py` is still patched by tests at + `apm_cli.commands.install.X`, the patch silently no-ops. Either patch at + the new canonical path, or use module-attribute access in the call site + (`X_mod.function`) so canonical patches survive the move. See + `src/apm_cli/install/phases/integrate.py:888` for the pattern. + +- **Reuse existing fixtures.** Common fixtures live in `tests/conftest.py` + and `tests/unit/install/conftest.py`. Don't re-implement temp-dir or + mock-logger fixtures inline. + +- **Targeted runs during iteration.** Run the specific test file first + (`uv run pytest tests/unit/install/test_X.py -x`) before running the + full suite (`uv run pytest tests/unit tests/test_console.py`). + +--- +*This file was generated by APM CLI. Do not edit manually.* +*To regenerate: `specify apm compile`* diff --git a/.github/instructions/tests.instructions.md b/tests/CLAUDE.md similarity index 92% rename from .github/instructions/tests.instructions.md rename to tests/CLAUDE.md index 8c7cd435f..0bd14b0a0 100644 --- a/.github/instructions/tests.instructions.md +++ b/tests/CLAUDE.md @@ -1,8 +1,13 @@ ---- -applyTo: "tests/**" -description: "Test conventions: URL assertions must use urllib.parse, never substring." ---- +# CLAUDE.md + + + + +# Project Standards +## Files matching `tests/**` + + # Test Conventions ## URL assertions: use `urllib.parse`, never substring @@ -107,3 +112,7 @@ production code must follow (see - **Targeted runs during iteration.** Run the specific test file first (`uv run pytest tests/unit/install/test_X.py -x`) before running the full suite (`uv run pytest tests/unit tests/test_console.py`). + +--- +*This file was generated by APM CLI. Do not edit manually.* +*To regenerate: `apm compile`* From 2eb6453f451e5a7f57654bcecd5f8002aed9f969 Mon Sep 17 00:00:00 2001 From: Sergio Sisternes Date: Wed, 22 Apr 2026 12:09:43 +0100 Subject: [PATCH 07/10] ci: add apm compile --check gate to Tier 1 Runs 'apm compile --check' on every PR and merge_group event to ensure AGENTS.md, CLAUDE.md, and .github/copilot-instructions.md stay in sync with .apm/ primitives. Read-only check: exit 0 when outputs match, exit 1 on drift. The error message directs contributors to run 'apm compile' locally and commit the regenerated outputs. Part of the #695 / #792 dogfooding PR. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 57 +++++++++++++++++++++------------------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cb128b8c..694b9e40e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,13 @@ jobs: - name: Install dependencies run: uv sync --extra dev --extra build + - name: Verify agent outputs are up to date + # Dogfooding: APM compiles AGENTS.md, CLAUDE.md, and + # .github/copilot-instructions.md from .apm/ primitives. This gate + # fails if committed outputs drift from what `apm compile` would + # regenerate. Run `apm compile` locally and commit the result. + run: uv run apm compile --check + - name: Check YAML encoding safety run: | # Ensure YAML file I/O goes through yaml_io helpers. @@ -97,11 +104,20 @@ jobs: retention-days: 30 if-no-files-found: error - # Dogfood the two CI gates we ship and document to users: - # - Gate A (consumer-side): `apm audit --ci` -- lockfile / install fidelity. - # - Gate B (producer-side): regeneration drift -- did someone hand-edit - # a regenerated file under .github/ without updating canonical .apm/? - # See microsoft/apm#883 for context. Tier 1 (no secrets needed). + # Dogfood the consumer-side CI gate we ship and document to users: + # - `apm audit --ci` -- lockfile / install fidelity. + # + # The producer-side regeneration-drift gate is handled in build-and-test + # via `apm compile -t copilot --check` (see the "Verify agent outputs are + # up to date" step above). That gate matches this repo's compile-based + # dogfood model (AGENTS.md + .github/copilot-instructions.md + skills/), + # which inlines agents/instructions instead of distributing them to + # .github/{instructions,agents}/. Running `apm install` here would fight + # that model -- it regenerates the distributed tree and would always + # produce "drift" against our committed compile outputs. + # + # See microsoft/apm#792 and microsoft/apm#883 for context. Tier 1 (no + # secrets needed). apm-self-check: name: APM Self-Check runs-on: ubuntu-24.04 @@ -110,31 +126,18 @@ jobs: steps: - uses: actions/checkout@v4 - # Installs the APM CLI (latest stable) and runs `apm install` against - # this repo's apm.yml. Auto-detects target from the existing .github/ - # directory and re-integrates local .apm/ content, regenerating - # .github/instructions/, .github/agents/, .github/skills/, etc. - # Adds `apm` to PATH for subsequent steps. - - uses: microsoft/apm-action@v1 + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Install dependencies + run: uv sync --extra dev - # Gate A: lockfile / install fidelity (consumer-side). + # Lockfile / install fidelity (consumer-side). # Verifies every file in lockfile.deployed_files exists, ref consistency # between apm.yml and apm.lock.yaml, no orphan packages, and # content-integrity (hidden Unicode) on deployed package content. # Does NOT verify deployed-file content vs lockfile (see #684). - name: apm audit --ci - run: apm audit --ci - - # Gate B: regeneration drift (producer-side). - # The action's `apm install` step re-integrated local .apm/ into - # .github/ via target auto-detection. If anything in the governed - # integration directories changed, someone edited the regenerated - # output without updating the canonical .apm/ source. - - name: Check APM integration drift - run: | - if [ -n "$(git status --porcelain -- .github/ .claude/ .cursor/ .opencode/)" ]; then - echo "::error::APM integration files are out of date." - echo "Run 'apm install' locally (with .github/ present) and commit the result." - git --no-pager diff -- .github/ .claude/ .cursor/ .opencode/ - exit 1 - fi + run: uv run apm audit --ci From dec6095800bd2b4492c92885e110bcfdaed36d37 Mon Sep 17 00:00:00 2001 From: Sergio Sisternes Date: Wed, 22 Apr 2026 12:12:48 +0100 Subject: [PATCH 08/10] docs: document apm compile --check and .apm/ dogfood workflow - CONTRIBUTING.md: new "Recompiling agent outputs" section explaining .apm/ is source of truth and the compile workflow - docs/.../cli-commands.md: document apm compile --check flag and exit-code contract - docs/.../manifest-schema.md: document compilation.exclude patterns - CHANGELOG.md: entries under [Unreleased] for --check, copilot- instructions compile target, root-scoped instructions, dogfood switch, and link_resolver containment fix README callout proposal written to session files for user approval (per doc-sync rule 2). Part of the #695 / #792 dogfooding PR. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 10 +++++ CONTRIBUTING.md | 37 +++++++++++++++++++ .../content/docs/reference/cli-commands.md | 20 ++++++++++ .../content/docs/reference/manifest-schema.md | 20 +++++++++- 4 files changed, 86 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c476a71f..38383bf1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `apm compile --check` flag for read-only drift verification. Compiles in memory, compares against files on disk without writing, and exits `0` (match), `1` (drift), or `2` (unrecoverable error). Drift report is written to stderr; stdout is reserved for a future `--json` report. Remediation hints direct content drift to `apm compile` and stale files to `apm compile --clean`. (#792) +- `.github/copilot-instructions.md` as a `apm compile` output target, emitted from root-scoped instructions (those with an empty `applyTo`). Copilot reads this file natively without following distributed AGENTS.md hops. (#792) +- Root-scoped instructions: `.instructions.md` files with an empty `applyTo` frontmatter field now compile into the single Copilot-native `.github/copilot-instructions.md` file alongside the existing `AGENTS.md` / `CLAUDE.md` outputs. (#792) +- `compilation.exclude` manifest key documented in the manifest schema reference, including glob semantics (matched against `.apm/**` and legacy `.github/**` discovery roots) and a worked example. (#792) - `apm-primitives-architect` agent: reusable persona for designing and critiquing `.apm/` skill bundles. (#882) - CI: add `APM Self-Check` to `ci.yml` for `apm audit --ci`, regeneration-drift validation, and `merge-gate.yml` `EXPECTED_CHECKS` coverage. (#885) ### Changed +- APM now fully dogfoods itself end to end: all agent-tool outputs (`AGENTS.md`, `CLAUDE.md`, `.github/copilot-instructions.md`, and the distributed `.github/{instructions,agents,skills}/` trees) are generated from `.apm/` by `apm compile`. The repo commits the outputs GitHub-hosted consumers read directly (`AGENTS.md` and `.github/**`); `CLAUDE.md` is gitignored and regenerated locally by each contributor. CI gates drift via `apm compile -t copilot --check` on every push. See `CONTRIBUTING.md` "Recompiling agent outputs" for the contributor workflow. (#695, #792) - Hardened `apm-review-panel` skill: one-comment output contract, pre-arbitration completeness gate, Hybrid E Auth Expert routing, verdict template extracted to `assets/`, and `python-architect` mandatory three-artifact PR review contract (classDiagram + flowchart + Design patterns). (#882) - CI: smoke tests in `build-release.yml`'s `build-and-test` job (Linux x86_64, Linux arm64, Windows) are now gated to promotion boundaries (tag/schedule/dispatch) instead of running on every push to main. Push-time smoke duplicated the merge-time smoke gate in `ci-integration.yml` and burned ~15 redundant codex-binary downloads/day. Tag-cut releases still run smoke as a pre-ship gate; nightly catches upstream codex URL drift; merge-time still gates merges into main. (#878) - CI docs: clarify that branch-protection ruleset must store the check-run name (`gate`), not the workflow display string (`Merge Gate / gate`); document the merge-gate aggregator in `cicd.instructions.md` and mark the legacy stub workflow as deprecated. @@ -23,6 +28,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - CI: deleted `ci-integration-pr-stub.yml`. The four stubs were a holdover from the pre-merge-gate model where branch protection required each Tier 2 check name directly. After #867, branch protection requires only `gate`, so the stubs are dead weight. Reduced `EXPECTED_CHECKS` in `merge-gate.yml` to just `Build & Test (Linux)`. +### Fixed + +- `link_resolver._resolve_path` now enforces `base_dir` containment (fail-closed): resolved paths that escape the configured base directory are rejected instead of silently returning an out-of-tree path, closing a path-traversal hole in markdown link resolution during `apm compile`. (#792) +- `apm compile` timing output is routed through the logger with a deterministic sort on `base_dir`, replacing direct `print()` calls that bypassed the `--verbose` / `--quiet` filters and produced non-deterministic output ordering across runs. (#792) + ## [0.9.2] - 2026-04-23 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4e83414a8..858d0258a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -127,6 +127,43 @@ pip install -e .[dev] pytest tests/unit tests/test_console.py -x ``` +## Recompiling agent outputs + +APM uses APM to manage its own agent primitives. The `.apm/` tree at the repo +root is the **source of truth** for all authored instructions, agents, and +skills. The following files are **generated** by `apm compile` and MUST NOT be +hand-edited: + +- `AGENTS.md` (root) and the distributed `AGENTS.md` files under subdirectories +- `CLAUDE.md` (root) and the distributed `CLAUDE.md` files under subdirectories +- `.github/copilot-instructions.md` +- Files under `.github/instructions/`, `.github/agents/`, `.github/skills/` + +Any edits to these paths will be overwritten on the next compile. + +**Workflow when you change anything under `.apm/`:** + +```bash +# 1. Edit the source primitive under .apm/ +$EDITOR .apm/instructions/.instructions.md + +# 2. Regenerate all outputs +apm compile + +# 3. Commit the source change AND the regenerated outputs in the same PR +git add .apm/ AGENTS.md CLAUDE.md .github/ +git commit -m "..." +``` + +If you only touched `.apm/` and forgot step 2, CI will catch it: the Tier 1 +`compile-check` job runs `apm compile --check`, which exits non-zero when any +generated output has drifted from its source. The failure message tells you +exactly which file is stale and directs you to run `apm compile` locally. + +Generated files are marked `linguist-generated=true` in `.gitattributes`, so +GitHub collapses them by default in PR diffs -- reviewers focus on the `.apm/` +source change, not the downstream regeneration noise. + ## Coding Style This project follows: diff --git a/docs/src/content/docs/reference/cli-commands.md b/docs/src/content/docs/reference/cli-commands.md index 01b4691f5..6006f6fc5 100644 --- a/docs/src/content/docs/reference/cli-commands.md +++ b/docs/src/content/docs/reference/cli-commands.md @@ -1358,6 +1358,26 @@ apm compile [OPTIONS] - `-v, --verbose` - Show detailed source attribution and optimizer analysis - `--local-only` - Ignore dependencies, compile only local primitives - `--clean` - Remove orphaned AGENTS.md files that are no longer generated +- `--check` - Read-only drift verification. Compiles in-memory and compares against files on disk without writing. See [Drift verification](#drift-verification-check) below. + +**Drift verification (`--check`):** + +Use `--check` in CI to assert that committed generated outputs are in sync with their `.apm/` sources. The flag never writes to disk; it compiles in memory, diffs against the existing files, and exits with one of three codes: + +| Exit code | Meaning | Remediation | +|---|---|---| +| `0` | All generated outputs match sources. No drift. | -- | +| `1` | Drift detected -- content differs, or a file is stale (source removed but output still on disk). | Run `apm compile` for content drift; run `apm compile --clean` when the drift report lists stale files. | +| `2` | Unrecoverable error (invalid primitive, missing `apm.yml`, I/O failure). | Fix the reported error and re-run. | + +Stdout is kept empty on exit `0` and `1` (reserved for a future `--json` report). The drift report is written to stderr and lists each drifted path plus whether the cause is content drift or a stale file. + +Example CI step: + +```yaml +- name: Verify generated outputs are in sync + run: apm compile --check +``` **Target Auto-Detection:** diff --git a/docs/src/content/docs/reference/manifest-schema.md b/docs/src/content/docs/reference/manifest-schema.md index dde0a3922..eacd45584 100644 --- a/docs/src/content/docs/reference/manifest-schema.md +++ b/docs/src/content/docs/reference/manifest-schema.md @@ -432,7 +432,25 @@ The `compilation` key is OPTIONAL. It controls `apm compile` behaviour. All fiel | `exclude` | `list` or `string` | `[]` | Glob patterns | Directories to skip during compilation (e.g. `apm_modules/**`). | | `placement` | `object` | — | | Placement tuning. See §6.1. | -### 6.1. `compilation.placement` +### 6.1. `compilation.exclude` + +Glob patterns listing workspace-relative directories to skip during primitive discovery. Patterns are matched against paths walked under `.apm/**` (authored primitives) and the legacy `.github/{instructions,agents,skills,chatmodes}/**` discovery roots. Default exclusions (`node_modules`, `__pycache__`, `.git`, `dist`, `build`, `apm_modules`, and dotfiles) are always applied on top of any user-supplied list. + +Accepts a list of strings or a single string. Pattern syntax: `*` matches one path segment, `**` matches any number of segments. + +```yaml +compilation: + exclude: + - "tests/**" # test fixtures that include sample primitives + - "templates/**" # scaffolding templates shipped with the package + - "packages/**" # in-repo sample packages + - "build/**" # build artefacts + - "docs/node_modules/**" # docs site dependencies +``` + +Use this to keep a large monorepo from walking directories that ship sample primitives (e.g. test fixtures, templates) that must not be compiled into the repo's own outputs. + +### 6.2. `compilation.placement` | Field | Type | Default | Description | |---|---|---|---| From fd8bffcceabd9d59fd07e9c710c1efb2c929ad54 Mon Sep 17 00:00:00 2001 From: Sergio Sisternes Date: Wed, 22 Apr 2026 12:14:00 +0100 Subject: [PATCH 09/10] docs(readme): add Dogfooding section APM uses APM to manage its own agent primitives. The new section points readers at the .apm/ source tree, the apm compile workflow, and the CI gate -- linking to CONTRIBUTING.md for the full procedure. Part of the #695 / #792 dogfooding PR. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 2dce6e952..6f6962a89 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,10 @@ See the **[Getting Started guide](https://microsoft.github.io/apm/getting-starte Use agentrc to author high-quality instructions, then package them with APM to share across your org. The `.instructions.md` format is shared by both tools — no conversion needed when moving instructions into APM packages. +## Dogfooding + +APM uses APM to manage its own agent primitives. The `.apm/` tree is the source of truth; `AGENTS.md`, `CLAUDE.md`, and `.github/copilot-instructions.md` are generated by `apm compile` and CI enforces sync via `apm compile --check`. See [CONTRIBUTING.md -- Recompiling agent outputs](CONTRIBUTING.md#recompiling-agent-outputs) for the workflow. + ## Community Created by [@danielmeppiel](https://github.com/danielmeppiel). Maintained by [@danielmeppiel](https://github.com/danielmeppiel) and [@sergio-sisternes-epam](https://github.com/sergio-sisternes-epam). From 0bab40ea0a28b59e43b04f0c048e677923b18343 Mon Sep 17 00:00:00 2001 From: Sergio Sisternes Date: Wed, 22 Apr 2026 18:33:57 +0100 Subject: [PATCH 10/10] chore: gitignore CLAUDE.md outputs; commit only GitHub-consumed outputs Compiled outputs fall into two groups: - GitHub-hosted consumers (Copilot in PR/chat, Agentic Workflows, Cloud Agents) read AGENTS.md and .github/** directly from the repo -- no build step runs on their side, so we MUST commit these files. - Claude Code runs exclusively on a developer's machine. Contributors can regenerate CLAUDE.md locally via apm compile. This commit implements that split: - .gitignore: gitignore CLAUDE.md / **/CLAUDE.md (with exceptions for template fixtures, docs-site pages, and test fixtures). - Untrack the 5 previously-tracked CLAUDE.md files. - .gitattributes: drop CLAUDE.md linguist-generated entries (no longer tracked); keep AGENTS.md and copilot-instructions.md markers. - .github/workflows/ci.yml: scope the drift gate to 'apm compile -t copilot --check' so CI only asserts sync on the outputs we actually commit. - CONTRIBUTING.md / README.md / cli-commands.md: document the policy explicitly ('pre-built for GitHub Copilot; other platforms run apm compile on checkout') including a note on how local -t copilot --check interacts with a full-target local compile. - CHANGELOG.md: clarify the Unreleased dogfood entry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitattributes | 4 +- .github/CLAUDE.md | 126 ------------- .github/workflows/ci.yml | 11 +- .gitignore | 15 +- CLAUDE.md | 96 ---------- CONTRIBUTING.md | 78 ++++++-- README.md | 2 +- .../content/docs/reference/cli-commands.md | 10 +- src/apm_cli/CLAUDE.md | 171 ------------------ src/apm_cli/integration/CLAUDE.md | 72 -------- tests/CLAUDE.md | 118 ------------ 11 files changed, 88 insertions(+), 615 deletions(-) delete mode 100644 .github/CLAUDE.md delete mode 100644 CLAUDE.md delete mode 100644 src/apm_cli/CLAUDE.md delete mode 100644 src/apm_cli/integration/CLAUDE.md delete mode 100644 tests/CLAUDE.md diff --git a/.gitattributes b/.gitattributes index 16e6c6d4c..6ad8c45fa 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,8 +1,8 @@ .github/workflows/*.lock.yml linguist-generated=true merge=ours # Generated by `apm compile` from .apm/ primitives. Do not edit directly. +# CLAUDE.md outputs are gitignored (see .gitignore) -- only the outputs that +# GitHub-hosted consumers read directly need the linguist marker. AGENTS.md linguist-generated=true -CLAUDE.md linguist-generated=true **/AGENTS.md linguist-generated=true -**/CLAUDE.md linguist-generated=true .github/copilot-instructions.md linguist-generated=true diff --git a/.github/CLAUDE.md b/.github/CLAUDE.md deleted file mode 100644 index 32404b851..000000000 --- a/.github/CLAUDE.md +++ /dev/null @@ -1,126 +0,0 @@ -# CLAUDE.md - - - - -# Project Standards - -## Files matching `.github/workflows/**` - - -# CI/CD Pipeline Instructions - -## Workflow Architecture (Tiered + Merge Queue) -Five workflows split by trigger and tier. PRs get fast feedback; the heavy -integration suite runs only at merge time via GitHub Merge Queue -(microsoft/apm#770). - -1. **`ci.yml`** - Tier 1, runs on `pull_request` AND `merge_group` - - **Linux-only** (ubuntu-24.04). Combined `build-and-test` job: unit tests + binary build in a single runner. No secrets needed. - - Uploads Linux x86_64 binary artifact for downstream integration testing. - - Runs in both PR context (fast feedback for contributors) and merge_group - context (against the tentative merge commit before queue auto-merges). -2. **`ci-integration.yml`** - Tier 2, `merge_group` trigger only - - **Linux-only**. Builds binary inline, then runs smoke + integration + - release-validation against the tentative merge commit. - - Trust boundary is the write-access grant (only users with write can - enqueue a PR). No environment approval gate. - - Inlines the binary build instead of fetching from `ci.yml` to avoid - cross-workflow artifact plumbing across triggers. - - **Never add a `pull_request` or `pull_request_target` trigger here.** - This file holds production secrets (`GH_CLI_PAT`, `ADO_APM_PAT`). - Required-check satisfaction at PR time is handled by `merge-gate.yml`, - which aggregates all required signals into a single `gate` check. -3. **`merge-gate.yml`** - single-authority PR-time aggregator - - Triggers on `pull_request` only (single trigger - dual-trigger with - `pull_request_target` produces SUCCESS+CANCELLED check-run twins via - `cancel-in-progress` and poisons branch protection's rollup). - - One job named `gate`. Polls the Checks API for all entries in the - workflow's `EXPECTED_CHECKS` env var; aggregates pass/fail into a - single check-run. - - Branch protection requires ONLY this one check (`gate`). Adding, - renaming, or removing an underlying check is a `merge-gate.yml` edit, - never a ruleset edit. Tide / bors single-authority pattern. - - Recovery if the `pull_request` webhook is dropped: empty commit, - `gh workflow run merge-gate.yml -f pr_number=NNN`, or close+reopen. - - `.github/CODEOWNERS` requires Lead Maintainer review for any change - to `.github/workflows/**`. -4. **`build-release.yml`** - `push` to main, tags, schedule, `workflow_dispatch` - - **Linux + Windows** run combined `build-and-test` (unit tests + binary build in one job). Unit tests run on every push for platform-regression signal; **smoke tests are gated to tag/schedule/dispatch only** (promotion boundaries) to avoid duplicating `ci-integration.yml`'s merge-time smoke and to cut redundant codex-binary downloads. - - **macOS Intel** uses `build-and-validate-macos-intel` (root node, runs own unit tests - no dependency on `build-and-test`). Builds the binary on every push for early regression feedback; integration + release-validation phases conditional on tag/schedule/dispatch. - - **macOS ARM** uses `build-and-validate-macos-arm` (root node, tag/schedule/dispatch only - ARM runners are extremely scarce with 2-4h+ queue waits). Only requested when the binary is actually needed for a release. - - Secrets always available. Full 5-platform binary output (linux x86_64/arm64, darwin x86_64/arm64, windows x86_64). -5. **`ci-runtime.yml`** - nightly schedule, manual dispatch, path-filtered push - - **Linux x86_64 only**. Live inference smoke tests (`apm run`) isolated from release pipeline. - - Uses `GH_MODELS_PAT` for GitHub Models API access. - - Failures do not block releases - annotated as warnings. - -## Platform Testing Strategy -- **PR time**: Linux-only combined build-and-test in `ci.yml`. Catches logic bugs and dependency issues before merge. Windows + macOS are tested post-merge (platform-specific issues are rare and the full matrix runs on every push to main). -- **Post-merge**: Full 5-platform matrix (linux x86_64/arm64, darwin x86_64/arm64, windows x86_64) catches remaining platform-specific issues on main. -- **Rationale**: ci.yml has always been Linux-only - Windows and macOS are covered by `build-release.yml` on every push to main. This keeps PR feedback fast while still catching platform issues before release. - -## PyInstaller Binary Packaging -- **CRITICAL**: Uses `--onedir` mode (NOT `--onefile`) for faster CLI startup performance -- **Binary Structure**: Creates `dist/{binary_name}/apm` (nested directory containing executable + dependencies) -- **Platform Naming**: `apm-{platform}-{arch}` (e.g., `apm-darwin-arm64`, `apm-linux-x86_64`) -- **Spec File**: `build/apm.spec` handles data bundling, hidden imports, and UPX compression - -## Artifact Flow Quirks -- **Upload**: Artifacts include both binary directory + test scripts for isolation testing -- **Download**: GitHub Actions creates nested structure: `{artifact_name}/dist/{binary_name}/apm` -- **Release Prep**: Extract binary from nested path using `tar -czf "${binary}.tar.gz" -C "${artifact_dir}/dist" "${binary}"` - -## Critical Testing Phases -1. **Integration Tests**: Full source code access for comprehensive testing -2. **Release Validation**: ISOLATION testing - no source checkout, validates exact shipped binary experience -3. **Path Resolution**: Use symlinks and PATH manipulation for isolated binary testing - -## Inference Testing (Decoupled) -- Live inference tests (`apm run`) are **isolated** in `ci-runtime.yml` - they do NOT gate releases -- `APM_RUN_INFERENCE_TESTS=1` env var enables inference in test scripts; absent = skipped -- `GH_MODELS_PAT` is only used in `ci-runtime.yml` and Tier 2 smoke-test job - NOT in integration-tests or release-validation -- Rationale: 8 inference executions x 2% failure rate = 14.9% false-negative per release; APM core UVPs require zero live inference - -## Release Flow Dependencies -- **PR workflow**: Tier 1 only - ci.yml (build-and-test, Linux-only) provides fast feedback. Tier 2 does not run until enqueued. -- **Merge queue workflow**: ci.yml (Tier 1 against tentative merge ref) + ci-integration.yml (Tier 2: build -> smoke-test -> integration-tests -> release-validation). Queue auto-merges on success; ejects on failure. -- **Push/Release workflow (Linux + Windows)**: build-and-test -> integration-tests -> release-validation -> create-release -> publish-pypi -> update-homebrew (gh-aw-compat runs in parallel, informational) -- **Push/Release workflow (macOS Intel)**: build-and-validate-macos-intel (root node: unit tests + build always + conditional integration/release-validation) -> create-release -- **Push/Release workflow (macOS ARM)**: build-and-validate-macos-arm (root node, tag/schedule/dispatch only; all phases run) -> create-release -- **Tag Triggers**: Only `v*.*.*` tags trigger full release pipeline -- **Artifact Retention**: 30 days for debugging failed releases -- **Cross-workflow artifacts**: ci-integration.yml builds the binary inline (no cross-workflow artifact transfer); build-release.yml jobs share artifacts within the same workflow run. - -## Branch Protection & Required Checks -- **Single required check**: branch protection (`main-protection` ruleset id 9294522) requires exactly one status check context: `gate` from `merge-gate.yml`. All other PR-time signals are aggregated by that workflow's poll loop. -- **CRITICAL ruleset gotcha**: the ruleset `context` must be the literal check-run name `gate`. `Merge Gate / gate` is only how GitHub may render the workflow and job together in the UI; it is not the context value to store in the ruleset. If the ruleset stores `Merge Gate / gate`, GitHub waits forever with "Expected - Waiting for status to be reported" because no check-run with that literal name is posted. -- **How the name is derived**: GitHub matches the check by `integration_id` (`15368` = github-actions) plus the emitted check-run name. That emitted name comes from the job `name:` if one is set; otherwise it falls back to the job id. In `merge-gate.yml` the job id is `gate` and `name: gate`, so the emitted check-run name is `gate` -- that is the exact string the ruleset must require. -- **Adding a new aggregated check**: add it to `EXPECTED_CHECKS` in `merge-gate.yml`. Do not change the ruleset unless you intentionally rename the merge gate job's emitted check-run name, in which case the ruleset `context` must be updated to the new exact name. - -## Trust Model -- **PR push (any contributor, including forks)**: Runs Tier 1 only. No CI secrets exposed. PR code is checked out and tested in an unprivileged context. -- **merge_group (write access required)**: Runs Tier 1 + Tier 2. Tier 2 sees secrets. The `gh-readonly-queue/main/*` ref is created by GitHub from the PR merged into main; only users with write access can trigger this by enqueueing a PR. -- **Trust boundary = write-access grant**, managed in repo Settings -> Collaborators. Write access is granted only to vetted contributors. -- **No environment approval gate** is required because the act of enqueueing IS the trust assertion. This replaces the previous `integration-tests` environment approval flow. - -## Key Environment Variables -- `PYTHON_VERSION: '3.12'` - Standardized across all jobs -- `GITHUB_TOKEN` - Fallback token for compatibility (GitHub Actions built-in) -- `APM_RUN_INFERENCE_TESTS` - When `1`, enables live inference tests in validation scripts - -## Performance Considerations -- **Combined build-and-test**: Eliminates ~1.5m runner re-provisioning overhead by running unit tests and binary build in the same job. -- **macOS as root nodes**: macOS consolidated jobs run their own unit tests and start immediately - no dependency on Linux/Windows test completion. -- **Native uv caching**: `setup-uv` action with `enable-cache: true` replaces manual `actions/cache@v3` blocks. -- **Targeted setup-node usage**: Node.js is only installed in `ci-runtime.yml`, macOS consolidated jobs, and integration-tests/release-validation phases (for `apm runtime setup copilot` -> npm install). -- **macOS runner consolidation**: Each macOS arch has a single consolidated job (build + integration + release-validation). Intel (`build-and-validate-macos-intel`) runs on every push since Intel runners are plentiful. ARM (`build-and-validate-macos-arm`) is gated to tag/schedule/dispatch only since ARM runners are extremely scarce (2-4h+ queue waits). This avoids serial re-queuing of runners across multiple jobs. -- **Unit tests skip macOS**: Python unit tests are platform-agnostic; Linux + Windows coverage is sufficient. macOS-specific validation (binary build, integration tests, release validation) still runs via the consolidated job. -- **Tier 2 runs once per merged PR**, not per WIP push, since it triggers on `merge_group` only. Saves the bulk of integration minutes that the previous per-push flow burned. -- UPX compression when available (reduces binary size ~50%) -- Python optimization level 2 in PyInstaller -- Aggressive module exclusions (tkinter, matplotlib, etc.) - ---- -*This file was generated by APM CLI. Do not edit manually.* -*To regenerate: `apm compile`* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 694b9e40e..6a6db213f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,11 +46,12 @@ jobs: run: uv sync --extra dev --extra build - name: Verify agent outputs are up to date - # Dogfooding: APM compiles AGENTS.md, CLAUDE.md, and - # .github/copilot-instructions.md from .apm/ primitives. This gate - # fails if committed outputs drift from what `apm compile` would - # regenerate. Run `apm compile` locally and commit the result. - run: uv run apm compile --check + # Dogfooding: we commit only the outputs GitHub-hosted agents consume + # (.github/copilot-instructions.md and the .github/instructions|agents|skills + # trees). AGENTS.md and CLAUDE.md are regenerated by each developer on + # checkout (see CONTRIBUTING.md), so this gate is scoped to the copilot + # target. Run `apm compile` locally after editing anything under .apm/. + run: uv run apm compile -t copilot --check - name: Check YAML encoding safety run: | diff --git a/.gitignore b/.gitignore index 4744f08e5..8a0a8df01 100644 --- a/.gitignore +++ b/.gitignore @@ -61,8 +61,19 @@ docs/wip/ *.log .env.local .env.*.local -# AGENTS.md and CLAUDE.md are generated by `apm compile` and committed. -# Template AGENTS.md fixtures are excluded via a directory pattern in templates/. + +# Generated by `apm compile` from .apm/ primitives. +# `AGENTS.md` and `.github/**` outputs ARE committed: GitHub-hosted consumers +# (Copilot chat / PR, Agentic Workflows, Cloud Agents) read them directly +# from the repo with no build step. `CLAUDE.md` is only consumed by Claude +# Code, which runs locally on a developer's machine -- so we gitignore it +# and let each contributor regenerate it via `apm compile` after checkout. +# See CONTRIBUTING.md -> Recompiling agent outputs. +CLAUDE.md +**/CLAUDE.md +!templates/**/CLAUDE.md +!docs/src/**/CLAUDE.md +!tests/fixtures/**/CLAUDE.md PRD.md PRD*.md WIP/ diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index ead7af806..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,96 +0,0 @@ -# CLAUDE.md - - - - -# Project Standards - -## Files matching `**` - - -# Rules to keep documentation up-to-date - -- Rule 1: Whenever changes are made to the codebase, it is important to also update the documentation to reflect those changes. You must ensure that the following documentation is updated: [Starlight content pages in docs/src/content/docs/](../../docs/src/content/docs/). Each page uses Starlight frontmatter (title, sidebar order). Cross-page links use relative paths (e.g., `../../guides/compilation/`). - -- Rule 2: The main [README.md](../../README.md) file is a special case that requires user approval before changes, so, if there is a deviation in the code that affects what is stated in the main [README.md](../../README.md) file, you must warn the user and describe the drift and [README.md](../../README.md) update proposal, and wait for confirmation before updating it. - -- Rule 3: Documentation is meant to be very simple and straightforward, we must avoid bloating it with unnecessary information. It must be pragmatic, to the point, succinct and practical. - -- Rule 4: When changing CLI commands, flags, dependency formats, authentication flow, policy schema, or primitive file formats, also update the corresponding resource files in [packages/apm-guide/.apm/skills/apm-usage/](../../packages/apm-guide/.apm/skills/apm-usage/). Map changes to the correct file: commands.md for CLI changes, dependencies.md for reference formats, authentication.md for token resolution, governance.md for policy schema, package-authoring.md for primitive formats. - - -# Encoding Rules - -## Constraint - -All source code files and CLI output strings must stay within **printable ASCII** (U+0020–U+007E). - -Do NOT use: -- Emojis (e.g. `🚀`, `✨`, `❌`) -- Unicode box-drawing characters (e.g. `─`, `│`, `┌`) -- Em dashes (`—`), en dashes (`–`), curly quotes (`"`, `"`, `'`, `'`) -- Any character outside the ASCII range (codepoint > U+007E) - -**Why**: Windows `cp1252` terminals raise `UnicodeEncodeError: 'charmap' codec can't encode character` for any character outside cp1252. Keeping output within ASCII guarantees identical behaviour on every platform without dual-path fallback logic. - -## Status symbol convention - -Use ASCII bracket notation consistently across all CLI output, help text, and log messages: - -| Symbol | Meaning | -|--------|----------------------| -| `[+]` | success / confirmed | -| `[!]` | warning | -| `[x]` | error | -| `[i]` | info | -| `[*]` | action / processing | -| `[>]` | running / progress | - -These map directly to the `STATUS_SYMBOLS` dict in `src/apm_cli/utils/console.py`. - -## Scope - -This rule applies to: -- Python source files (`*.py`) -- CLI help strings and command output -- Markdown documentation and instruction files under `.github/` -- Shell scripts and CI workflow files - -Exception: binary assets and third-party vendored files are excluded. - -## Files matching `**/*.py` - - -Use type hints for all function parameters and return values. -Follow PEP 8 style guidelines. -Write comprehensive docstrings. - -## Files matching `CHANGELOG.md` - - -# Changelog Format - -This project follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and [Semantic Versioning](https://semver.org/). - -## Structure - -- New entries go under `## [Unreleased]`. -- Released versions use `## [X.Y.Z] - YYYY-MM-DD`. -- Group entries by type: `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`. - -## Entry format - -- One line per PR: concise description ending with `(#PR_NUMBER)`. -- Credit external contributors inline: `— by @username (#PR_NUMBER)`. -- Combine related PRs into a single line when they form one logical change: `(#251, #256, #258)`. -- Use backticks for code references: commands, file names, config keys, classes. - -## Rules - -- Every merged PR that changes code, tests, docs, or dependencies must have a changelog entry. -- Do NOT include version-bump or release-machinery PRs (e.g., "chore: bump to vX.Y.Z"). -- When releasing, move Unreleased entries into a new versioned section — never delete them. - ---- -*This file was generated by APM CLI. Do not edit manually.* -*To regenerate: `apm compile`* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 858d0258a..274a81fa3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -131,38 +131,80 @@ pytest tests/unit tests/test_console.py -x APM uses APM to manage its own agent primitives. The `.apm/` tree at the repo root is the **source of truth** for all authored instructions, agents, and -skills. The following files are **generated** by `apm compile` and MUST NOT be -hand-edited: +skills. Compiled outputs under `AGENTS.md`, `CLAUDE.md`, and `.github/**` are +generated by `apm compile` and MUST NOT be hand-edited. -- `AGENTS.md` (root) and the distributed `AGENTS.md` files under subdirectories -- `CLAUDE.md` (root) and the distributed `CLAUDE.md` files under subdirectories -- `.github/copilot-instructions.md` -- Files under `.github/instructions/`, `.github/agents/`, `.github/skills/` +### What we commit, and why -Any edits to these paths will be overwritten on the next compile. +APM runs on many surfaces. Some of them -- GitHub Copilot in PRs, GitHub +Agentic Workflows, Copilot Cloud Agents -- read their configuration directly +from files in the repo and have no way to run `apm compile` first. All other +surfaces -- Claude Code, Codex CLI, and any IDE agent -- run on a developer's +machine, where `apm compile` is a one-liner. -**Workflow when you change anything under `.apm/`:** +We optimise for that asymmetry: commit only what GitHub-hosted consumers need, +regenerate everything else locally. + +| Output | Committed? | Consumer | +|---|---|---| +| `.github/copilot-instructions.md` | **yes** | GitHub Copilot (chat / PR / cloud agent) | +| `.github/instructions/**` | **yes** | GitHub Copilot, Agentic Workflows | +| `.github/agents/**`, `.github/skills/**` | **yes** | GitHub Copilot agents and skills | +| `AGENTS.md`, `**/AGENTS.md` | **yes** | GitHub Copilot (repo-level instructions, read natively from the repo) | +| `CLAUDE.md`, `**/CLAUDE.md` | **no** (gitignored) | Claude Code (local only) | + +Local-only agent runtimes (Claude Code, Codex CLI, other IDE agents) are not +a reason to commit generated outputs -- they run on a developer's machine +where `apm compile` is a one-liner. We commit a file only when a GitHub-hosted +consumer would otherwise have no way to read it. + +If you work on APM with Claude Code locally, run `apm compile` once after +cloning (and after pulling changes that touch `.apm/`) to materialise +`CLAUDE.md` files in your working tree. They are gitignored, so nothing you +regenerate will show up in `git status`. + +### Workflow when you change anything under `.apm/` ```bash # 1. Edit the source primitive under .apm/ $EDITOR .apm/instructions/.instructions.md -# 2. Regenerate all outputs +# 2. Regenerate all outputs locally (also updates your own CLAUDE.md files) apm compile -# 3. Commit the source change AND the regenerated outputs in the same PR -git add .apm/ AGENTS.md CLAUDE.md .github/ +# 3. Commit the source change and the regenerated outputs +git add .apm/ AGENTS.md .github/ git commit -m "..." ``` -If you only touched `.apm/` and forgot step 2, CI will catch it: the Tier 1 -`compile-check` job runs `apm compile --check`, which exits non-zero when any -generated output has drifted from its source. The failure message tells you -exactly which file is stale and directs you to run `apm compile` locally. +`CLAUDE.md` files are gitignored and stay on your machine. + +### CI drift gate + +If you edit `.apm/` but forget to regenerate, CI will catch it. The Tier 1 +`compile-check` job runs: + +```bash +apm compile -t copilot --check +``` -Generated files are marked `linguist-generated=true` in `.gitattributes`, so -GitHub collapses them by default in PR diffs -- reviewers focus on the `.apm/` -source change, not the downstream regeneration noise. +This verifies that the committed `AGENTS.md` and `.github/**` outputs are in +sync with their `.apm/` sources. It exits non-zero on drift with a report +pointing at the stale files. The check is scoped to the `copilot` target +because those are the only outputs we commit; `CLAUDE.md` sync is a local +developer concern. + +> Note: if you run `apm compile` locally (without `-t copilot`), CLAUDE.md +> files are regenerated in your working tree. They are gitignored, so they +> won't show in `git status` or be committed -- but running +> `apm compile -t copilot --check` afterwards will flag them as out-of-scope +> stale files. CI sees a fresh clone with no CLAUDE.md present, so the gate +> passes there. To mirror CI locally, delete the CLAUDE.md files first or +> compile with `-t copilot`. + +Generated files that we do commit are marked `linguist-generated=true` in +`.gitattributes`, so GitHub collapses them by default in PR diffs -- reviewers +focus on the `.apm/` source change, not the downstream regeneration noise. ## Coding Style diff --git a/README.md b/README.md index 6f6962a89..bfd2b7f74 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ Use agentrc to author high-quality instructions, then package them with APM to s ## Dogfooding -APM uses APM to manage its own agent primitives. The `.apm/` tree is the source of truth; `AGENTS.md`, `CLAUDE.md`, and `.github/copilot-instructions.md` are generated by `apm compile` and CI enforces sync via `apm compile --check`. See [CONTRIBUTING.md -- Recompiling agent outputs](CONTRIBUTING.md#recompiling-agent-outputs) for the workflow. +APM uses APM to manage its own agent primitives. The `.apm/` tree is the source of truth. GitHub-consumed outputs (`AGENTS.md`, `.github/copilot-instructions.md`, `.github/instructions/**`, `.github/agents/**`, `.github/skills/**`) are pre-built and committed so GitHub Copilot, Agentic Workflows, and Cloud Agents work out of the box. `CLAUDE.md` is gitignored -- run `apm compile` after checkout to materialise it locally for Claude Code. CI gates drift via `apm compile -t copilot --check`. See [CONTRIBUTING.md -- Recompiling agent outputs](CONTRIBUTING.md#recompiling-agent-outputs). ## Community diff --git a/docs/src/content/docs/reference/cli-commands.md b/docs/src/content/docs/reference/cli-commands.md index 6006f6fc5..f564d2779 100644 --- a/docs/src/content/docs/reference/cli-commands.md +++ b/docs/src/content/docs/reference/cli-commands.md @@ -1358,9 +1358,9 @@ apm compile [OPTIONS] - `-v, --verbose` - Show detailed source attribution and optimizer analysis - `--local-only` - Ignore dependencies, compile only local primitives - `--clean` - Remove orphaned AGENTS.md files that are no longer generated -- `--check` - Read-only drift verification. Compiles in-memory and compares against files on disk without writing. See [Drift verification](#drift-verification-check) below. +- `--check` - Read-only drift verification. Compiles in-memory and compares against files on disk without writing. See the **Drift verification** section below. -**Drift verification (`--check`):** +**Drift verification (`--check`)** Use `--check` in CI to assert that committed generated outputs are in sync with their `.apm/` sources. The flag never writes to disk; it compiles in memory, diffs against the existing files, and exits with one of three codes: @@ -1372,11 +1372,13 @@ Use `--check` in CI to assert that committed generated outputs are in sync with Stdout is kept empty on exit `0` and `1` (reserved for a future `--json` report). The drift report is written to stderr and lists each drifted path plus whether the cause is content drift or a stale file. -Example CI step: +Example CI step (drift gate scoped to the outputs you commit): ```yaml - name: Verify generated outputs are in sync - run: apm compile --check + # Scope to the targets whose outputs are committed to the repo. + # Omit -t to check every generated output in the working tree. + run: apm compile -t copilot --check ``` **Target Auto-Detection:** diff --git a/src/apm_cli/CLAUDE.md b/src/apm_cli/CLAUDE.md deleted file mode 100644 index 54cf1edb8..000000000 --- a/src/apm_cli/CLAUDE.md +++ /dev/null @@ -1,171 +0,0 @@ -# CLAUDE.md - - - - -# Project Standards - -## Files matching `src/apm_cli/cli.py` - - -# CLI Design Guidelines - -## Visual Design Standards - -### Rich Library Usage -- **ALWAYS** use Rich library for visual output when available -- Provide graceful fallbacks to colorama for compatibility -- Use the established `console` instance with custom theme -- Wrap Rich imports in try/catch with colorama fallbacks - -### Command Help Text -- Keep command help strings plain ASCII — no emojis -- Format: `help="Initialize a new APM project"` - -### Status Symbols & Feedback -- Use `STATUS_SYMBOLS` dict for consistent ASCII bracket notation: - - `[+]` success / confirmed - - `[>]` running / execution / progress - - `[*]` action / configuration / processing - - `[i]` information / tips - - `[#]` lists / metrics - - `[!]` warnings - - `[x]` errors -- Use helper functions: `_rich_success()`, `_rich_error()`, `_rich_info()`, `_rich_warning()` -- Pass the appropriate key from `STATUS_SYMBOLS` via the `symbol=` parameter (e.g. `symbol="check"`, `symbol="warning"`) - -### Structured Output -- **Tables**: Use Rich tables for structured data (scripts, models, config, runtimes) -- **Panels**: Use Rich panels for grouped content, next steps, examples -- **Consistent Spacing**: Add empty lines between sections with `console.print()` or `click.echo()` - -### Error Handling -- Use `_rich_error()` for all error messages -- Always include contextual symbols -- Provide actionable suggestions when possible -- Maintain consistent error message format - -### Interactive Elements -- Use Rich `Prompt.ask()` and `Confirm.ask()` when available -- Provide click fallbacks for compatibility -- Display confirmations in Rich panels when possible - -## Code Organization - -### Helper Functions -- Use existing helper functions: `_rich_echo()`, `_rich_panel()`, `_create_files_table()` -- Create new helpers following the same pattern -- Always include Rich/colorama fallback logic - -### Color Scheme -- Primary: cyan for titles and highlights -- Success: green with `[+]` symbol -- Warning: yellow with `[!]` symbol -- Error: red with `[x]` symbol -- Info: blue with `[i]` symbol -- Muted: dim white for secondary text - -### Table Design -- Include meaningful titles (plain ASCII, no emojis) -- Use semantic column styling (bold for names, muted for details) -- Keep tables clean with appropriate padding -- Show status with bracket symbols in dedicated columns - -## Implementation Patterns - -### Command Structure -```python -@cli.command(help="Action description") -@click.option(...) -def command_name(...): - """Detailed docstring.""" - try: - _rich_info("Starting operation...", symbol="gear") - - # Main logic here - - _rich_success("Operation complete!", symbol="check") - except Exception as e: - _rich_error(f"Error: {e}", symbol="error") - sys.exit(1) -``` - -### Table Creation -```python -try: - table = Table(title="Title", show_header=True, header_style="bold cyan") - table.add_column("Name", style="bold white") - table.add_column("Details", style="white") - console.print(table) -except (ImportError, NameError): - # Colorama fallback -``` - -### Panel Usage -```python -try: - _rich_panel(content, title="Section Title", style="cyan") -except (ImportError, NameError): - # Simple text fallback -``` - -## Quality Standards - -### User Experience -- Every action should have clear visual feedback -- Group related information in panels or tables -- Use consistent symbols throughout the application -- Provide helpful next steps and examples - -### Accessibility -- Maintain colorama fallbacks for all Rich features -- Use semantic text alongside visual elements -- Ensure information is conveyed through text, not just color - -### Performance -- Import Rich modules only when needed -- Handle import failures gracefully -- Don't block on visual enhancements - -## Examples to Follow - -- **init command**: Shows Rich panels, file tables, next steps -- **list command**: Professional table with default script indicators -- **preview command**: Side-by-side panels for original/compiled -- **config command**: Clean configuration display - -## What NOT to Do - -- **Never** use plain `click.echo()` without styling -- **Never** mix color schemes or symbols inconsistently -- **Never** create walls of text without visual structure -- **Never** forget Rich import fallbacks -- **Never** sacrifice functionality for visuals -- **Never** use emojis or non-ASCII characters in source code or CLI output - -## Documentation Sync Requirements - -### CLI Reference Documentation -- **ALWAYS** update `docs/cli-reference.md` when adding, modifying, or removing CLI commands -- **ALWAYS** update command help text, options, arguments, and examples in the reference -- **ALWAYS** verify examples in the documentation actually work with the current implementation -- **ALWAYS** keep the command list in sync with available commands - -### Documentation Update Checklist -When changing CLI functionality, update these sections in `docs/cli-reference.md`: -- Command syntax and arguments -- Available options and flags -- Usage examples -- Return codes and error handling -- Quick reference sections - -### Documentation Standards -- Use plain ASCII text in documentation (no emojis in CLI help text or output examples) -- Include realistic, working examples that users can copy-paste -- Document both success and error scenarios -- Keep examples current with the latest syntax -- Maintain consistency between CLI help and reference documentation - ---- -*This file was generated by APM CLI. Do not edit manually.* -*To regenerate: `apm compile`* diff --git a/src/apm_cli/integration/CLAUDE.md b/src/apm_cli/integration/CLAUDE.md deleted file mode 100644 index fc3471b17..000000000 --- a/src/apm_cli/integration/CLAUDE.md +++ /dev/null @@ -1,72 +0,0 @@ -# CLAUDE.md - - - - -# Project Standards - -## Files matching `src/apm_cli/integration/**` - - -# Integrator Architecture - -## Design philosophy - -APM runs inside repositories of any size — from single-package repos to monorepos with thousands of packages and deep dependency trees. Every integrator must assume it will operate at that scale. The architecture is built around two principles: - -1. **One base, many file types.** All file-level integrators share a single `BaseIntegrator` infrastructure for collision detection, manifest-based sync, path security, link resolution, and file discovery. New integrators add *what* to deploy, never *how* to deploy. When logic belongs to more than one integrator, push it into `BaseIntegrator`. -2. **Pay only for what you touch.** Operations must be proportional to the files a single package deploys, not the size of the workspace or the total managed-files set. Pre-normalize once, partition once, look up in O(1). Avoid full-tree walks, per-file parent cleanup, or repeated set scans. - -When evolving integration logic — new file types, richer transforms, cross-package awareness — preserve these properties. If a change would violate either principle, refactor the base class first. - -## Required structure - -Every file-level integrator **must** extend `BaseIntegrator` and return `IntegrationResult`. - -```python -from apm_cli.integration.base_integrator import BaseIntegrator, IntegrationResult - -class FooIntegrator(BaseIntegrator): - def find_foo_files(self, package_path: Path) -> List[Path]: ... - def copy_foo(self, source: Path, target: Path) -> int: ... - def integrate_package_foos(self, package_info, project_root: Path, - force: bool = False, - managed_files: set = None) -> IntegrationResult: ... - def sync_integration(self, apm_package, project_root: Path, - managed_files: set = None) -> Dict[str, int]: ... -``` - -## Base-class methods — use, don't reimplement - -Before writing custom logic, check whether `BaseIntegrator` already solves the problem. Duplicating behaviour that exists in the base class creates drift, bugs, and performance regressions. - -| Operation | Use | Never | -|---|---|---| -| Collision detection | `self.check_collision(target_path, rel_path, managed_files, force)` | Custom existence checks | -| Link resolution | `self.init_link_resolver()` + `self.resolve_links()` | Direct `UnifiedLinkResolver` calls | -| File discovery | `self.find_files_by_glob(path, pattern, subdirs=)` | Ad-hoc `os.walk` / recursive globs | -| Path validation | `BaseIntegrator.validate_deploy_path()` | Inline `..` or prefix checks | -| File removal (sync) | `self.sync_remove_files(project_root, managed_files, prefix=, legacy_glob_dir=, legacy_glob_pattern=)` | Manual scan-and-delete | -| Empty-dir cleanup | `BaseIntegrator.cleanup_empty_parents(deleted, stop_at)` | Per-file parent removal loops | - -If you need an operation the base class does not support, **add it to `BaseIntegrator`** so every integrator benefits. - -## Wiring checklist (cli.py) - -- **Install path**: record each `result.target_paths` entry in `dep_deployed` using `.as_posix()`. -- **Uninstall path**: call `BaseIntegrator.partition_managed_files()` once, pass the appropriate bucket to `sync_integration()`. -- **Exports**: add the new integrator to `src/apm_cli/integration/__init__.py`. - -## Performance guidance - -The specific techniques below exist to serve the "pay only for what you touch" principle. As the codebase evolves, new code must uphold the same standard — if a new feature would regress install/uninstall to O(N × M) where N is packages and M is managed files, find a better design. - -- `managed_files` must be pre-normalized with `normalize_managed_files()` for **O(1)** set lookups — never iterate the set to find a path. -- `partition_managed_files()` runs a **single O(M) pass** over managed files — do not filter per-integrator. -- `cleanup_empty_parents()` does a **bottom-up batch** — never call `rmdir()` per deleted file. -- File-discovery globs must be **scoped** to known subdirectories, not walk the entire package tree. -- All path strings stored in `apm.lock` must use **forward slashes** (`.as_posix()`). - ---- -*This file was generated by APM CLI. Do not edit manually.* -*To regenerate: `apm compile`* diff --git a/tests/CLAUDE.md b/tests/CLAUDE.md deleted file mode 100644 index 0bd14b0a0..000000000 --- a/tests/CLAUDE.md +++ /dev/null @@ -1,118 +0,0 @@ -# CLAUDE.md - - - - -# Project Standards - -## Files matching `tests/**` - - -# Test Conventions - -## URL assertions: use `urllib.parse`, never substring - -Any assertion that a URL appears in or matches some output **must** parse the -URL with `urllib.parse.urlparse` and compare on a parsed component -(`hostname`, `port`, `scheme`, `path`). Substring assertions like -`assert "host.example.com" in msg` or `assert "https://x" in url` are flagged -by CodeQL as `py/incomplete-url-substring-sanitization` (high severity, "the -string may be at an arbitrary position in the URL") and **will fail CI**. - -This rule applies regardless of whether the value being asserted looks like a -"safe" hostname — CodeQL is a static check and cannot infer that `host` in -`assert host in msg` is bounded; the alert fires anyway. - -### Wrong - -```python -# Substring match -- CodeQL py/incomplete-url-substring-sanitization -assert "registry.example.com" in msg -assert "https://api.github.com/v0/servers" in url -assert "127.0.0.1" in warning_text - -# Set membership of substring -- still flagged (CodeQL can't infer set type) -hosts = {urlparse(tok).hostname for tok in msg.split() if "://" in tok} -assert "poisoned.example.com" in hosts -``` - -### Right - -```python -from urllib.parse import urlparse - -# Direct hostname equality on a parsed URL token -urls = [tok for tok in msg.split() if "://" in tok] -assert len(urls) == 1 -assert urlparse(urls[0]).hostname == "registry.example.com" - -# Set equality (not membership) when multiple URLs are expected -hosts = {urlparse(tok.strip("()")).hostname for tok in msg.split() if "://" in tok} -assert hosts == {"a.example.com", "b.example.com"} - -# Component-level checks for path / scheme / port -parsed = urlparse(url) -assert parsed.scheme == "https" -assert parsed.hostname == "api.github.com" -assert parsed.path == "/v0/servers" -``` - -### Helper pattern for multi-URL output - -When asserting against logger / CLI output that may contain multiple URLs, -extract them with a small helper and assert on the parsed tuple: - -```python -def _printed_urls(text: str) -> list[tuple[str, str, str]]: - """Extract (scheme, hostname, path) tuples from any URLs in text.""" - from urllib.parse import urlparse - out = [] - for token in text.split(): - cleaned = token.strip("(),.;'\"") - if "://" not in cleaned: - continue - p = urlparse(cleaned) - out.append((p.scheme, p.hostname or "", p.path)) - return out - -assert ("https", "registry.example.com", "/v0/servers") in _printed_urls(msg) -``` - -`tests/unit/test_mcp_command.py` already uses this pattern; reuse it (or -copy it) rather than inventing a new substring check. - -## Why the rule applies even to "obviously safe" tests - -The CodeQL rule is intentionally conservative: a substring assertion against a -URL string is the same code shape as a security-critical sanitizer check, and -the analyzer cannot tell them apart. Treating every URL assertion uniformly -through `urlparse` keeps CI green AND reinforces the security pattern that -production code must follow (see -`src/apm_cli/install/mcp_registry.py::_redact_url_credentials` and -`src/apm_cli/install/mcp_registry.py::_is_local_or_metadata_host`). - -## Other rules - -- **No live network calls.** Tests must never hit a real HTTP endpoint; use - `unittest.mock.patch('requests.Session.get')` or - `monkeypatch.setattr(client.session, "get", fake)`. Live-inference tests - are isolated to `ci-runtime.yml` and gated by `APM_RUN_INFERENCE_TESTS=1`. - -- **Patch where the name is looked up.** When a function moved to - `apm_cli/install/phases/X.py` is still patched by tests at - `apm_cli.commands.install.X`, the patch silently no-ops. Either patch at - the new canonical path, or use module-attribute access in the call site - (`X_mod.function`) so canonical patches survive the move. See - `src/apm_cli/install/phases/integrate.py:888` for the pattern. - -- **Reuse existing fixtures.** Common fixtures live in `tests/conftest.py` - and `tests/unit/install/conftest.py`. Don't re-implement temp-dir or - mock-logger fixtures inline. - -- **Targeted runs during iteration.** Run the specific test file first - (`uv run pytest tests/unit/install/test_X.py -x`) before running the - full suite (`uv run pytest tests/unit tests/test_console.py`). - ---- -*This file was generated by APM CLI. Do not edit manually.* -*To regenerate: `apm compile`*