From a1aaf37d0df40a877e2391c7b1dcd12d4f52455b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:52:04 +0000 Subject: [PATCH 1/3] Initial plan From a00ca776f637e51c54cff34776bad7d3fdd27321 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:54:23 +0000 Subject: [PATCH 2/3] Initial plan for compilation.exclude primitive discovery fix Agent-Logs-Url: https://github.com/microsoft/apm/sessions/6aae940e-6c6a-40f5-9f06-994570e01fa7 Co-authored-by: danielmeppiel <51440732+danielmeppiel@users.noreply.github.com> --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 83746999..16a1330a 100644 --- a/uv.lock +++ b/uv.lock @@ -179,7 +179,7 @@ wheels = [ [[package]] name = "apm-cli" -version = "0.8.5" +version = "0.8.6" source = { editable = "." } dependencies = [ { name = "click" }, From 44658e5b3f6fd5e5d056a24e7f5a2abcbbb53c47 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 27 Mar 2026 11:59:59 +0000 Subject: [PATCH 3/3] fix: compilation.exclude patterns now filter primitive discovery Propagate exclude patterns from CompilationConfig to the primitive discovery phase so that .instructions.md files (and other primitives) inside excluded directories are never discovered or compiled. Fixes #464 Agent-Logs-Url: https://github.com/microsoft/apm/sessions/6aae940e-6c6a-40f5-9f06-994570e01fa7 Co-authored-by: danielmeppiel <51440732+danielmeppiel@users.noreply.github.com> --- CHANGELOG.md | 4 + src/apm_cli/compilation/agents_compiler.py | 10 +- src/apm_cli/primitives/discovery.py | 130 ++++++++++++++++- .../test_agents_compiler_coverage.py | 4 +- .../unit/primitives/test_discovery_parser.py | 135 ++++++++++++++++++ 5 files changed, 273 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c38126a..c9a9eada 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- `compilation.exclude` patterns now filter primitive discovery, preventing `.instructions.md` files in excluded directories from leaking into compiled output (#464) + ## [0.8.6] - 2026-03-27 ### Added diff --git a/src/apm_cli/compilation/agents_compiler.py b/src/apm_cli/compilation/agents_compiler.py index a1619648..ca670abd 100644 --- a/src/apm_cli/compilation/agents_compiler.py +++ b/src/apm_cli/compilation/agents_compiler.py @@ -187,11 +187,17 @@ def compile(self, config: CompilationConfig, primitives: Optional[PrimitiveColle if primitives is None: if config.local_only: # Use basic discovery for local-only mode - primitives = discover_primitives(str(self.base_dir)) + primitives = discover_primitives( + str(self.base_dir), + exclude_patterns=config.exclude, + ) else: # Use enhanced discovery with dependencies (Task 4 integration) from ..primitives.discovery import discover_primitives_with_dependencies - primitives = discover_primitives_with_dependencies(str(self.base_dir)) + primitives = discover_primitives_with_dependencies( + str(self.base_dir), + exclude_patterns=config.exclude, + ) # Route to targets based on config.target results: List[CompilationResult] = [] diff --git a/src/apm_cli/primitives/discovery.py b/src/apm_cli/primitives/discovery.py index bc9d97be..a8dfe62a 100644 --- a/src/apm_cli/primitives/discovery.py +++ b/src/apm_cli/primitives/discovery.py @@ -1,9 +1,10 @@ """Discovery functionality for primitive files.""" +import fnmatch import os import glob from pathlib import Path -from typing import List, Dict +from typing import List, Dict, Optional from .models import PrimitiveCollection from .parser import parse_primitive_file, parse_skill_file @@ -52,7 +53,10 @@ } -def discover_primitives(base_dir: str = ".") -> PrimitiveCollection: +def discover_primitives( + base_dir: str = ".", + exclude_patterns: Optional[List[str]] = None, +) -> PrimitiveCollection: """Find all APM primitive files in the project. Searches for .chatmode.md, .instructions.md, .context.md, .memory.md files @@ -60,17 +64,21 @@ def discover_primitives(base_dir: str = ".") -> PrimitiveCollection: Args: base_dir (str): Base directory to search in. Defaults to current directory. + exclude_patterns (Optional[List[str]]): Glob patterns for paths to exclude. Returns: PrimitiveCollection: Collection of discovered and parsed primitives. """ collection = PrimitiveCollection() + base_path = Path(base_dir) # Find and parse files for each primitive type for primitive_type, patterns in LOCAL_PRIMITIVE_PATTERNS.items(): files = find_primitive_files(base_dir, patterns) for file_path in files: + if _should_exclude_file(file_path, base_path, exclude_patterns): + continue try: primitive = parse_primitive_file(file_path, source="local") collection.add_primitive(primitive) @@ -83,7 +91,10 @@ def discover_primitives(base_dir: str = ".") -> PrimitiveCollection: return collection -def discover_primitives_with_dependencies(base_dir: str = ".") -> PrimitiveCollection: +def discover_primitives_with_dependencies( + base_dir: str = ".", + exclude_patterns: Optional[List[str]] = None, +) -> PrimitiveCollection: """Enhanced primitive discovery including dependency sources. Priority Order: @@ -93,6 +104,7 @@ def discover_primitives_with_dependencies(base_dir: str = ".") -> PrimitiveColle Args: base_dir (str): Base directory to search in. Defaults to current directory. + exclude_patterns (Optional[List[str]]): Glob patterns for paths to exclude. Returns: PrimitiveCollection: Collection of discovered and parsed primitives with source tracking. @@ -100,7 +112,7 @@ def discover_primitives_with_dependencies(base_dir: str = ".") -> PrimitiveColle collection = PrimitiveCollection() # Phase 1: Local primitives (highest priority) - scan_local_primitives(base_dir, collection) + scan_local_primitives(base_dir, collection, exclude_patterns=exclude_patterns) # Phase 1b: Local SKILL.md _discover_local_skill(base_dir, collection) @@ -113,12 +125,17 @@ def discover_primitives_with_dependencies(base_dir: str = ".") -> PrimitiveColle return collection -def scan_local_primitives(base_dir: str, collection: PrimitiveCollection) -> None: +def scan_local_primitives( + base_dir: str, + collection: PrimitiveCollection, + exclude_patterns: Optional[List[str]] = None, +) -> None: """Scan local .apm/ directory for primitives. Args: base_dir (str): Base directory to search in. collection (PrimitiveCollection): Collection to add primitives to. + exclude_patterns (Optional[List[str]]): Glob patterns for paths to exclude. """ # Find and parse files for each primitive type for primitive_type, patterns in LOCAL_PRIMITIVE_PATTERNS.items(): @@ -131,8 +148,12 @@ def scan_local_primitives(base_dir: str, collection: PrimitiveCollection) -> Non for file_path in files: # Only include files that are NOT in apm_modules directory - if not _is_under_directory(file_path, apm_modules_path): - local_files.append(file_path) + if _is_under_directory(file_path, apm_modules_path): + continue + # Apply compilation.exclude patterns + if _should_exclude_file(file_path, base_path, exclude_patterns): + continue + local_files.append(file_path) for file_path in local_files: try: @@ -159,6 +180,101 @@ def _is_under_directory(file_path: Path, directory: Path) -> bool: return False +def _should_exclude_file( + file_path: Path, + base_dir: Path, + exclude_patterns: Optional[List[str]], +) -> bool: + """Check if a file path should be excluded based on exclude patterns. + + Args: + file_path: Absolute path of the discovered file. + base_dir: Base directory of the project. + exclude_patterns: Glob patterns for paths to exclude. + + Returns: + True if the file should be excluded, False otherwise. + """ + if not exclude_patterns: + return False + + try: + rel_path = file_path.resolve().relative_to(base_dir.resolve()) + except ValueError: + return False + + rel_path_str = rel_path.as_posix() + + for pattern in exclude_patterns: + normalized = pattern.replace('\\', '/') + if _matches_exclude_pattern(rel_path_str, normalized): + return True + + return False + + +def _matches_exclude_pattern(rel_path_str: str, pattern: str) -> bool: + """Check if a relative path string matches an exclusion pattern. + + Supports glob patterns including ** for recursive matching. + + Args: + rel_path_str: Forward-slash-normalized path relative to base_dir. + pattern: Forward-slash-normalized exclusion pattern. + + Returns: + True if path matches pattern, False otherwise. + """ + if '**' in pattern: + path_parts = rel_path_str.split('/') + pattern_parts = pattern.split('/') + return _match_glob_parts(path_parts, pattern_parts) + + if fnmatch.fnmatch(rel_path_str, pattern): + return True + + # Directory prefix matching + if pattern.endswith('/'): + if rel_path_str.startswith(pattern) or rel_path_str == pattern.rstrip('/'): + return True + else: + if rel_path_str.startswith(pattern + '/') or rel_path_str == pattern: + return True + + return False + + +def _match_glob_parts(path_parts: list, pattern_parts: list) -> bool: + """Recursively match path parts against pattern parts with ** support. + + Args: + path_parts: List of path components. + pattern_parts: List of pattern components. + + Returns: + True if path matches pattern, False otherwise. + """ + if not pattern_parts: + return not path_parts + + if not path_parts: + return all(p == '**' or p == '' for p in pattern_parts) + + part = pattern_parts[0] + + if part == '**': + # ** matches zero or more directories + if _match_glob_parts(path_parts, pattern_parts[1:]): + return True + if _match_glob_parts(path_parts[1:], pattern_parts): + return True + return False + else: + if fnmatch.fnmatch(path_parts[0], part): + return _match_glob_parts(path_parts[1:], pattern_parts[1:]) + return False + + def scan_dependency_primitives(base_dir: str, collection: PrimitiveCollection) -> None: """Scan all dependencies in apm_modules/ with priority handling. diff --git a/tests/unit/compilation/test_agents_compiler_coverage.py b/tests/unit/compilation/test_agents_compiler_coverage.py index 9274fe6e..2790a8aa 100644 --- a/tests/unit/compilation/test_agents_compiler_coverage.py +++ b/tests/unit/compilation/test_agents_compiler_coverage.py @@ -205,7 +205,9 @@ def test_compile_local_only_calls_basic_discover(self): ) as mock_disc: result = compiler.compile(config) # no primitives passed → discovers - mock_disc.assert_called_once_with(str(compiler.base_dir)) + mock_disc.assert_called_once_with( + str(compiler.base_dir), exclude_patterns=config.exclude + ) # --------------------------------------------------------------------------- diff --git a/tests/unit/primitives/test_discovery_parser.py b/tests/unit/primitives/test_discovery_parser.py index 4d3fc8e8..c12b3019 100644 --- a/tests/unit/primitives/test_discovery_parser.py +++ b/tests/unit/primitives/test_discovery_parser.py @@ -613,6 +613,141 @@ def test_parse_error_warns_and_continues(self): self.assertEqual(collection.count(), 0) +class TestExcludePatternsInDiscovery(unittest.TestCase): + """Tests for compilation.exclude filtering during primitive discovery.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp() + + def tearDown(self): + import shutil + + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_scan_local_primitives_excludes_matching_directory(self): + """Primitives under excluded directories are filtered out.""" + base = Path(self.tmp) + # Local instruction (should be kept) + _write( + base / ".apm" / "instructions" / "general.instructions.md", + INSTRUCTION_CONTENT, + ) + # Instruction inside docs/ (should be excluded) + _write( + base + / "docs" + / "labs" + / ".github" + / "instructions" + / "react.instructions.md", + INSTRUCTION_CONTENT, + ) + collection = PrimitiveCollection() + scan_local_primitives( + self.tmp, collection, exclude_patterns=["docs/**"] + ) + self.assertEqual(len(collection.instructions), 1) + + def test_scan_local_primitives_no_exclude_discovers_all(self): + """Without exclude patterns, all primitives are discovered.""" + base = Path(self.tmp) + _write( + base / ".apm" / "instructions" / "general.instructions.md", + INSTRUCTION_CONTENT, + ) + _write( + base + / "docs" + / "labs" + / ".github" + / "instructions" + / "react.instructions.md", + INSTRUCTION_CONTENT, + ) + collection = PrimitiveCollection() + scan_local_primitives(self.tmp, collection, exclude_patterns=None) + self.assertEqual(len(collection.instructions), 2) + + def test_scan_local_primitives_multiple_exclude_patterns(self): + """Multiple exclude patterns each filter their respective files.""" + base = Path(self.tmp) + _write( + base / ".apm" / "instructions" / "kept.instructions.md", + INSTRUCTION_CONTENT, + ) + _write( + base + / "docs" + / ".github" + / "instructions" + / "a.instructions.md", + INSTRUCTION_CONTENT, + ) + _write( + base + / "tmp" + / ".github" + / "instructions" + / "b.instructions.md", + INSTRUCTION_CONTENT, + ) + collection = PrimitiveCollection() + scan_local_primitives( + self.tmp, collection, exclude_patterns=["docs/**", "tmp/**"] + ) + self.assertEqual(len(collection.instructions), 1) + + def test_discover_primitives_respects_exclude(self): + """discover_primitives() filters with exclude_patterns.""" + base = Path(self.tmp) + _write( + base / ".apm" / "instructions" / "general.instructions.md", + INSTRUCTION_CONTENT, + ) + _write( + base + / "docs" + / ".github" + / "instructions" + / "leak.instructions.md", + INSTRUCTION_CONTENT, + ) + from apm_cli.primitives.discovery import discover_primitives + + collection = discover_primitives( + self.tmp, exclude_patterns=["docs/**"] + ) + self.assertEqual(len(collection.instructions), 1) + + def test_discover_primitives_with_dependencies_respects_exclude(self): + """discover_primitives_with_dependencies() filters with exclude_patterns.""" + base = Path(self.tmp) + _write( + base / ".apm" / "instructions" / "general.instructions.md", + INSTRUCTION_CONTENT, + ) + _write( + base + / "docs" + / ".github" + / "instructions" + / "leak.instructions.md", + INSTRUCTION_CONTENT, + ) + # Create minimal apm.yml for the function to work + (base / "apm.yml").write_text( + "name: test\nversion: 1.0.0\n", encoding="utf-8" + ) + from apm_cli.primitives.discovery import ( + discover_primitives_with_dependencies, + ) + + collection = discover_primitives_with_dependencies( + self.tmp, exclude_patterns=["docs/**"] + ) + self.assertEqual(len(collection.instructions), 1) + + class TestIsReadable(unittest.TestCase): """Tests for _is_readable."""