Skip to content

Commit 7a85544

Browse files
Add comprehensive tests for directory exclusion patterns
Co-authored-by: danielmeppiel <51440732+danielmeppiel@users.noreply.github.com>
1 parent 632aab7 commit 7a85544

3 files changed

Lines changed: 337 additions & 17 deletions

File tree

‎src/apm_cli/compilation/context_optimizer.py‎

Lines changed: 78 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -470,30 +470,91 @@ def _should_exclude_path(self, path: Path) -> bool:
470470
# Path is not relative to base_dir, don't exclude
471471
return False
472472

473-
# Convert to string with forward slashes for consistent glob matching
474-
rel_path_str = str(rel_path).replace(os.sep, '/')
475-
476473
# Check each exclusion pattern
477474
for pattern in self._exclude_patterns:
478-
# Normalize pattern to use forward slashes
479-
normalized_pattern = pattern.replace(os.sep, '/')
480-
481-
# Use fnmatch for glob pattern matching
482-
if fnmatch.fnmatch(rel_path_str, normalized_pattern):
475+
if self._matches_pattern(rel_path, pattern):
483476
return True
477+
478+
return False
479+
480+
def _matches_pattern(self, rel_path: Path, pattern: str) -> bool:
481+
"""Check if a relative path matches an exclusion pattern.
482+
483+
Supports glob patterns including ** for recursive matching.
484+
485+
Args:
486+
rel_path: Path relative to base_dir
487+
pattern: Exclusion pattern (glob syntax)
484488
485-
# Also check if the path starts with the pattern (for directory matching)
486-
# This handles cases like "apm_modules/" matching "apm_modules/foo/bar"
487-
if normalized_pattern.endswith('/'):
488-
if rel_path_str.startswith(normalized_pattern) or rel_path_str == normalized_pattern.rstrip('/'):
489-
return True
490-
else:
491-
# Check if pattern with trailing slash would match
492-
if rel_path_str.startswith(normalized_pattern + '/') or rel_path_str == normalized_pattern:
493-
return True
489+
Returns:
490+
True if path matches pattern, False otherwise
491+
"""
492+
# Normalize pattern to use forward slashes
493+
normalized_pattern = pattern.replace(os.sep, '/')
494+
495+
# Convert path to string with forward slashes
496+
rel_path_str = str(rel_path).replace(os.sep, '/')
497+
498+
# Handle ** patterns (match any number of directories)
499+
if '**' in normalized_pattern:
500+
# Convert ** glob to regex-like matching
501+
# Split pattern into parts
502+
parts = normalized_pattern.split('/')
503+
path_parts = rel_path_str.split('/')
504+
505+
# Try to match using recursive logic
506+
return self._match_glob_recursive(path_parts, parts)
507+
508+
# Simple fnmatch for patterns without **
509+
if fnmatch.fnmatch(rel_path_str, normalized_pattern):
510+
return True
511+
512+
# Also check if the path starts with the pattern (for directory matching)
513+
# This handles cases like "apm_modules/" matching "apm_modules/foo/bar"
514+
if normalized_pattern.endswith('/'):
515+
if rel_path_str.startswith(normalized_pattern) or rel_path_str == normalized_pattern.rstrip('/'):
516+
return True
517+
else:
518+
# Check if pattern with trailing slash would match
519+
if rel_path_str.startswith(normalized_pattern + '/') or rel_path_str == normalized_pattern:
520+
return True
494521

495522
return False
496523

524+
def _match_glob_recursive(self, path_parts: list, pattern_parts: list) -> bool:
525+
"""Recursively match path parts against pattern parts with ** support.
526+
527+
Args:
528+
path_parts: List of path components
529+
pattern_parts: List of pattern components
530+
531+
Returns:
532+
True if path matches pattern, False otherwise
533+
"""
534+
if not pattern_parts:
535+
return not path_parts
536+
537+
if not path_parts:
538+
# Check if remaining pattern parts are all ** or empty
539+
return all(p == '**' or p == '' for p in pattern_parts)
540+
541+
pattern_part = pattern_parts[0]
542+
543+
if pattern_part == '**':
544+
# ** matches zero or more directories
545+
# Try matching with zero directories
546+
if self._match_glob_recursive(path_parts, pattern_parts[1:]):
547+
return True
548+
# Try matching with one or more directories
549+
if self._match_glob_recursive(path_parts[1:], pattern_parts):
550+
return True
551+
return False
552+
else:
553+
# Regular pattern part - must match current path part
554+
if fnmatch.fnmatch(path_parts[0], pattern_part):
555+
return self._match_glob_recursive(path_parts[1:], pattern_parts[1:])
556+
return False
557+
497558
def _find_optimal_placements(
498559
self,
499560
instruction: Instruction,

‎tests/unit/compilation/test_compilation.py‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,102 @@ def test_compilation_config_from_apm_yml(self):
375375

376376
# Clean up
377377
Path('apm.yml').unlink()
378+
379+
def test_compilation_config_exclude_patterns_from_yml(self):
380+
"""Test loading exclude patterns from apm.yml."""
381+
# Create test apm.yml with exclude patterns
382+
test_config = {
383+
'name': 'test-project',
384+
'version': '1.0.0',
385+
'compilation': {
386+
'exclude': [
387+
'apm_modules/**',
388+
'tmp/**',
389+
'projects/packages/apm/**'
390+
]
391+
}
392+
}
393+
394+
with open('apm.yml', 'w') as f:
395+
yaml.dump(test_config, f)
396+
397+
# Test config loading
398+
config = CompilationConfig.from_apm_yml()
399+
self.assertIsNotNone(config.exclude)
400+
self.assertEqual(len(config.exclude), 3)
401+
self.assertIn('apm_modules/**', config.exclude)
402+
self.assertIn('tmp/**', config.exclude)
403+
self.assertIn('projects/packages/apm/**', config.exclude)
404+
405+
# Clean up
406+
Path('apm.yml').unlink()
407+
408+
def test_compilation_config_exclude_patterns_single_string(self):
409+
"""Test loading a single exclude pattern as string from apm.yml."""
410+
# Create test apm.yml with single exclude pattern as string
411+
test_config = {
412+
'name': 'test-project',
413+
'version': '1.0.0',
414+
'compilation': {
415+
'exclude': 'tmp/**'
416+
}
417+
}
418+
419+
with open('apm.yml', 'w') as f:
420+
yaml.dump(test_config, f)
421+
422+
# Test config loading
423+
config = CompilationConfig.from_apm_yml()
424+
self.assertIsNotNone(config.exclude)
425+
self.assertEqual(len(config.exclude), 1)
426+
self.assertEqual(config.exclude[0], 'tmp/**')
427+
428+
# Clean up
429+
Path('apm.yml').unlink()
430+
431+
def test_compilation_config_no_exclude_patterns(self):
432+
"""Test that config initializes with empty list when no exclude patterns."""
433+
# Create test apm.yml without exclude patterns
434+
test_config = {
435+
'name': 'test-project',
436+
'version': '1.0.0',
437+
'compilation': {
438+
'output': 'AGENTS.md'
439+
}
440+
}
441+
442+
with open('apm.yml', 'w') as f:
443+
yaml.dump(test_config, f)
444+
445+
# Test config loading
446+
config = CompilationConfig.from_apm_yml()
447+
self.assertIsNotNone(config.exclude)
448+
self.assertEqual(len(config.exclude), 0)
449+
450+
# Clean up
451+
Path('apm.yml').unlink()
452+
453+
def test_compilation_config_exclude_patterns_override(self):
454+
"""Test that command-line overrides work for exclude patterns."""
455+
# Create test apm.yml with exclude patterns
456+
test_config = {
457+
'name': 'test-project',
458+
'version': '1.0.0',
459+
'compilation': {
460+
'exclude': ['apm_modules/**']
461+
}
462+
}
463+
464+
with open('apm.yml', 'w') as f:
465+
yaml.dump(test_config, f)
466+
467+
# Test config with override
468+
override_patterns = ['tmp/**', 'coverage/**']
469+
config = CompilationConfig.from_apm_yml(exclude=override_patterns)
470+
self.assertEqual(config.exclude, override_patterns)
471+
472+
# Clean up
473+
Path('apm.yml').unlink()
378474

379475

380476
if __name__ == '__main__':

‎tests/unit/compilation/test_context_optimizer.py‎

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,5 +514,168 @@ def test_real_project_optimization_benefits(self, temp_project):
514514
assert optimized_efficiency >= naive_efficiency or optimized_efficiency >= 0.3
515515

516516

517+
class TestDirectoryExclusion:
518+
"""Test directory exclusion patterns in ContextOptimizer."""
519+
520+
def test_should_exclude_path_no_patterns(self):
521+
"""Test that no exclusions occur when no patterns are provided."""
522+
optimizer = ContextOptimizer(base_dir="/test", exclude_patterns=None)
523+
assert not optimizer._should_exclude_path(Path("/test/src"))
524+
assert not optimizer._should_exclude_path(Path("/test/apm_modules"))
525+
526+
def test_should_exclude_path_simple_directory(self):
527+
"""Test exclusion of simple directory patterns."""
528+
optimizer = ContextOptimizer(
529+
base_dir="/test",
530+
exclude_patterns=["apm_modules", "tmp"]
531+
)
532+
assert optimizer._should_exclude_path(Path("/test/apm_modules"))
533+
assert optimizer._should_exclude_path(Path("/test/tmp"))
534+
assert not optimizer._should_exclude_path(Path("/test/src"))
535+
536+
def test_should_exclude_path_with_trailing_slash(self):
537+
"""Test exclusion patterns with trailing slashes."""
538+
optimizer = ContextOptimizer(
539+
base_dir="/test",
540+
exclude_patterns=["apm_modules/", "tmp/"]
541+
)
542+
assert optimizer._should_exclude_path(Path("/test/apm_modules"))
543+
assert optimizer._should_exclude_path(Path("/test/apm_modules/package"))
544+
assert optimizer._should_exclude_path(Path("/test/tmp"))
545+
assert not optimizer._should_exclude_path(Path("/test/src"))
546+
547+
def test_should_exclude_path_nested_directories(self):
548+
"""Test exclusion of nested directory patterns."""
549+
optimizer = ContextOptimizer(
550+
base_dir="/test",
551+
exclude_patterns=["projects/packages/apm"]
552+
)
553+
assert optimizer._should_exclude_path(Path("/test/projects/packages/apm"))
554+
assert optimizer._should_exclude_path(Path("/test/projects/packages/apm/src"))
555+
assert not optimizer._should_exclude_path(Path("/test/projects/packages/other"))
556+
557+
def test_should_exclude_path_glob_patterns(self):
558+
"""Test exclusion using glob patterns."""
559+
optimizer = ContextOptimizer(
560+
base_dir="/test",
561+
exclude_patterns=["**/test-fixtures", "coverage/**"]
562+
)
563+
assert optimizer._should_exclude_path(Path("/test/test-fixtures"))
564+
assert optimizer._should_exclude_path(Path("/test/src/test-fixtures"))
565+
assert optimizer._should_exclude_path(Path("/test/coverage"))
566+
assert optimizer._should_exclude_path(Path("/test/coverage/report"))
567+
assert not optimizer._should_exclude_path(Path("/test/src"))
568+
569+
def test_should_exclude_path_wildcard_patterns(self):
570+
"""Test exclusion using wildcard patterns."""
571+
optimizer = ContextOptimizer(
572+
base_dir="/test",
573+
exclude_patterns=["tmp*", "*cache*"]
574+
)
575+
assert optimizer._should_exclude_path(Path("/test/tmp"))
576+
assert optimizer._should_exclude_path(Path("/test/tmp123"))
577+
assert optimizer._should_exclude_path(Path("/test/cache"))
578+
assert optimizer._should_exclude_path(Path("/test/mycache"))
579+
assert not optimizer._should_exclude_path(Path("/test/src"))
580+
581+
def test_should_exclude_path_complex_glob(self):
582+
"""Test complex glob patterns."""
583+
optimizer = ContextOptimizer(
584+
base_dir="/test",
585+
exclude_patterns=["projects/**/apm/**", "**/node_modules/**"]
586+
)
587+
assert optimizer._should_exclude_path(Path("/test/projects/packages/apm"))
588+
assert optimizer._should_exclude_path(Path("/test/projects/packages/apm/src"))
589+
assert optimizer._should_exclude_path(Path("/test/node_modules"))
590+
assert optimizer._should_exclude_path(Path("/test/src/node_modules"))
591+
assert not optimizer._should_exclude_path(Path("/test/projects/other"))
592+
593+
def test_should_exclude_path_path_outside_base_dir(self):
594+
"""Test that paths outside base_dir are not excluded."""
595+
optimizer = ContextOptimizer(
596+
base_dir="/test",
597+
exclude_patterns=["apm_modules"]
598+
)
599+
# Path that's not relative to base_dir should not be excluded
600+
assert not optimizer._should_exclude_path(Path("/other/apm_modules"))
601+
602+
def test_analyze_project_structure_with_exclusions(self):
603+
"""Test that _analyze_project_structure respects exclusion patterns."""
604+
with tempfile.TemporaryDirectory() as tmpdir:
605+
base_path = Path(tmpdir)
606+
607+
# Create directory structure
608+
(base_path / "src").mkdir()
609+
(base_path / "src" / "file.py").touch()
610+
(base_path / "apm_modules").mkdir()
611+
(base_path / "apm_modules" / "file.py").touch()
612+
(base_path / "tmp").mkdir()
613+
(base_path / "tmp" / "file.py").touch()
614+
615+
# Create optimizer with exclusions
616+
optimizer = ContextOptimizer(
617+
base_dir=str(base_path),
618+
exclude_patterns=["apm_modules", "tmp"]
619+
)
620+
optimizer._analyze_project_structure()
621+
622+
# Check that excluded directories are not in cache
623+
cached_dirs = set(optimizer._directory_cache.keys())
624+
assert base_path / "src" in cached_dirs
625+
assert base_path / "apm_modules" not in cached_dirs
626+
assert base_path / "tmp" not in cached_dirs
627+
628+
def test_analyze_project_structure_with_nested_exclusions(self):
629+
"""Test that nested exclusions work correctly."""
630+
with tempfile.TemporaryDirectory() as tmpdir:
631+
base_path = Path(tmpdir)
632+
633+
# Create nested directory structure
634+
(base_path / "projects" / "packages" / "apm" / "src").mkdir(parents=True)
635+
(base_path / "projects" / "packages" / "apm" / "src" / "file.py").touch()
636+
(base_path / "projects" / "packages" / "other" / "src").mkdir(parents=True)
637+
(base_path / "projects" / "packages" / "other" / "src" / "file.py").touch()
638+
639+
# Create optimizer with nested exclusion
640+
optimizer = ContextOptimizer(
641+
base_dir=str(base_path),
642+
exclude_patterns=["projects/packages/apm/**"]
643+
)
644+
optimizer._analyze_project_structure()
645+
646+
# Check that excluded directories are not in cache
647+
cached_dirs = set(optimizer._directory_cache.keys())
648+
assert base_path / "projects" / "packages" / "apm" not in cached_dirs
649+
assert base_path / "projects" / "packages" / "apm" / "src" not in cached_dirs
650+
# Other directories should be present
651+
assert base_path / "projects" / "packages" / "other" / "src" in cached_dirs
652+
653+
def test_default_exclusions_still_work(self):
654+
"""Test that default hardcoded exclusions still work alongside custom patterns."""
655+
with tempfile.TemporaryDirectory() as tmpdir:
656+
base_path = Path(tmpdir)
657+
658+
# Create directories including default exclusions
659+
(base_path / "src").mkdir()
660+
(base_path / "src" / "file.py").touch()
661+
(base_path / "node_modules").mkdir()
662+
(base_path / "node_modules" / "file.js").touch()
663+
(base_path / "custom_exclude").mkdir()
664+
(base_path / "custom_exclude" / "file.py").touch()
665+
666+
# Create optimizer with custom exclusion only
667+
optimizer = ContextOptimizer(
668+
base_dir=str(base_path),
669+
exclude_patterns=["custom_exclude"]
670+
)
671+
optimizer._analyze_project_structure()
672+
673+
# Check that both default and custom exclusions work
674+
cached_dirs = set(optimizer._directory_cache.keys())
675+
assert base_path / "src" in cached_dirs
676+
assert base_path / "node_modules" not in cached_dirs # Default exclusion
677+
assert base_path / "custom_exclude" not in cached_dirs # Custom exclusion
678+
679+
517680
if __name__ == "__main__":
518681
pytest.main([__file__])

0 commit comments

Comments
 (0)