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.""" 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" },