Skip to content
Merged
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
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
110 changes: 4 additions & 106 deletions src/apm_cli/compilation/context_optimizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
PlacementStrategy, PlacementSummary
)
from ..utils.paths import portable_relpath
from ..utils.exclude import should_exclude, validate_exclude_patterns

# CRITICAL: Shadow Click commands to prevent namespace collision
# When this module is imported during 'apm compile', Click's active context
Expand Down Expand Up @@ -132,8 +133,8 @@ def __init__(self, base_dir: str = ".", exclude_patterns: Optional[List[str]] =
self._errors: List[str] = []
self._start_time: Optional[float] = None

# Configurable exclusion patterns
self._exclude_patterns = exclude_patterns or []
# Configurable exclusion patterns (validated at init time)
self._exclude_patterns = validate_exclude_patterns(exclude_patterns)

def enable_timing(self, verbose: bool = False):
"""Enable performance timing instrumentation."""
Expand Down Expand Up @@ -503,110 +504,7 @@ def _should_exclude_path(self, path: Path) -> bool:
Returns:
True if path should be excluded, False otherwise
"""
if not self._exclude_patterns:
return False

# Get path relative to base_dir for pattern matching
# Resolve the path first to handle cross-platform differences
# (e.g., on Windows Path('/test') != Path('C:/test') after resolve)
try:
resolved = path.resolve()
except (OSError, FileNotFoundError):
resolved = path.absolute()
try:
rel_path = resolved.relative_to(self.base_dir.resolve())
except ValueError:
# Path is not relative to base_dir, don't exclude
return False

# Check each exclusion pattern
for pattern in self._exclude_patterns:
if self._matches_pattern(rel_path, pattern):
return True

return False

def _matches_pattern(self, rel_path: Path, pattern: str) -> bool:
"""Check if a relative path matches an exclusion pattern.

Supports glob patterns including ** for recursive matching.

Args:
rel_path: Path relative to base_dir
pattern: Exclusion pattern (glob syntax)

Returns:
True if path matches pattern, False otherwise
"""
# Normalize both pattern and path to use forward slashes for consistent matching
# This handles Windows paths (backslashes) and Unix paths (forward slashes)
# Users can provide patterns with either separator
normalized_pattern = pattern.replace('\\', '/').replace(os.sep, '/')

# Convert path to string with forward slashes
rel_path_str = str(rel_path).replace(os.sep, '/')

# Handle ** patterns (match any number of directories)
if '**' in normalized_pattern:
# Convert ** glob to regex-like matching
# Split pattern into parts
parts = normalized_pattern.split('/')
path_parts = rel_path_str.split('/')

# Try to match using recursive logic
return self._match_glob_recursive(path_parts, parts)

# Simple fnmatch for patterns without **
if fnmatch.fnmatch(rel_path_str, normalized_pattern):
return True

# Also check if the path starts with the pattern (for directory matching)
# This handles cases like "apm_modules/" matching "apm_modules/foo/bar"
if normalized_pattern.endswith('/'):
if rel_path_str.startswith(normalized_pattern) or rel_path_str == normalized_pattern.rstrip('/'):
return True
else:
# Check if pattern with trailing slash would match
if rel_path_str.startswith(normalized_pattern + '/') or rel_path_str == normalized_pattern:
return True

return False

def _match_glob_recursive(self, 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:
# Check if remaining pattern parts are all ** or empty
# Empty parts can occur from patterns like "foo/" which split to ['foo', '']
# or from consecutive slashes like "foo//bar"
return all(p == '**' or p == '' for p in pattern_parts)

pattern_part = pattern_parts[0]

if pattern_part == '**':
# ** matches zero or more directories
# Try matching with zero directories
if self._match_glob_recursive(path_parts, pattern_parts[1:]):
return True
# Try matching with one or more directories
if self._match_glob_recursive(path_parts[1:], pattern_parts):
return True
return False
else:
# Regular pattern part - must match current path part
if fnmatch.fnmatch(path_parts[0], pattern_part):
return self._match_glob_recursive(path_parts[1:], pattern_parts[1:])
return False
return should_exclude(path, self.base_dir, self._exclude_patterns)

def _find_optimal_placements(
self,
Expand Down
57 changes: 47 additions & 10 deletions src/apm_cli/primitives/discovery.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
"""Discovery functionality for primitive files."""

import logging
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
from ..utils.exclude import should_exclude, validate_exclude_patterns

logger = logging.getLogger(__name__)
from ..models.apm_package import APMPackage
from ..deps.lockfile import LockFile

Expand Down Expand Up @@ -52,38 +56,50 @@
}


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)
safe_patterns = validate_exclude_patterns(exclude_patterns)

# 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_path, base_path, safe_patterns):
logger.debug("Excluded by pattern: %s", file_path)
continue
Comment on lines 79 to +86
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

discover_primitives() is used for --local-only ("Ignore dependencies"), but it does not filter out files under apm_modules/. Because LOCAL_PRIMITIVE_PATTERNS includes recursive "/.apm/..." and "/*.instructions.md" patterns, primitives from installed dependencies can still be discovered in local-only mode. Consider applying the same apm_modules exclusion used in scan_local_primitives(), or refactoring local-only discovery to reuse scan_local_primitives().

Copilot uses AI. Check for mistakes.
try:
primitive = parse_primitive_file(file_path, source="local")
collection.add_primitive(primitive)
except Exception as e:
print(f"Warning: Failed to parse {file_path}: {e}")

# Discover SKILL.md at project root
_discover_local_skill(base_dir, collection)
_discover_local_skill(base_dir, collection, exclude_patterns=safe_patterns)

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,17 +109,19 @@ 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()
safe_patterns = validate_exclude_patterns(exclude_patterns)

# Phase 1: Local primitives (highest priority)
scan_local_primitives(base_dir, collection)
scan_local_primitives(base_dir, collection, exclude_patterns=safe_patterns)

# Phase 1b: Local SKILL.md
_discover_local_skill(base_dir, collection)
_discover_local_skill(base_dir, collection, exclude_patterns=safe_patterns)

# Phase 2: Dependency primitives (lower priority, with conflict detection)
# Plugins are normalized into standard APM packages during install
Expand All @@ -113,12 +131,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]]): Pre-validated exclude patterns.
"""
# Find and parse files for each primitive type
for primitive_type, patterns in LOCAL_PRIMITIVE_PATTERNS.items():
Expand All @@ -131,8 +154,13 @@ 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_path, base_path, exclude_patterns):
logger.debug("Excluded by pattern: %s", file_path)
continue
local_files.append(file_path)

for file_path in local_files:
try:
Expand All @@ -159,6 +187,7 @@ def _is_under_directory(file_path: Path, directory: Path) -> bool:
return False



def scan_dependency_primitives(base_dir: str, collection: PrimitiveCollection) -> None:
"""Scan all dependencies in apm_modules/ with priority handling.

Expand Down Expand Up @@ -302,15 +331,23 @@ def scan_directory_with_source(directory: Path, collection: PrimitiveCollection,
_discover_skill_in_directory(directory, collection, source)


def _discover_local_skill(base_dir: str, collection: PrimitiveCollection) -> None:
def _discover_local_skill(
base_dir: str,
collection: PrimitiveCollection,
exclude_patterns: Optional[List[str]] = None,
) -> None:
"""Discover SKILL.md at the project root.

Args:
base_dir (str): Base directory to search in.
collection (PrimitiveCollection): Collection to add skill to.
exclude_patterns (Optional[List[str]]): Pre-validated exclude patterns.
"""
skill_path = Path(base_dir) / "SKILL.md"
if skill_path.exists() and _is_readable(skill_path):
if should_exclude(skill_path, Path(base_dir), exclude_patterns):
logger.debug("Excluded by pattern: %s", skill_path)
return
try:
skill = parse_skill_file(skill_path, source="local")
collection.add_primitive(skill)
Expand Down
Loading
Loading