Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions src/apm_cli/compilation/agents_compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down
130 changes: 123 additions & 7 deletions src/apm_cli/primitives/discovery.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -52,25 +53,32 @@
}


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
in both .apm/ and .github/ directory structures, plus SKILL.md at root.

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)
Expand All @@ -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:
Expand All @@ -93,14 +104,15 @@ 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.
"""
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)
Expand All @@ -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():
Expand All @@ -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:
Expand All @@ -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.

Expand Down
4 changes: 3 additions & 1 deletion tests/unit/compilation/test_agents_compiler_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)


# ---------------------------------------------------------------------------
Expand Down
135 changes: 135 additions & 0 deletions tests/unit/primitives/test_discovery_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
Loading