diff --git a/docs/cli-reference.md b/docs/cli-reference.md index ce9a3120..a82ad48a 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -724,7 +724,7 @@ Use the `exclude` field to skip directories during compilation. This improves pe - `coverage/**` - Matches "coverage" and all subdirectories - `projects/**/apm/**` - Complex nested matching with `**` -**Default exclusions** (always applied): +**Default exclusions** (always applied, matched on exact path components): - `node_modules`, `__pycache__`, `.git`, `dist`, `build`, `apm_modules` - Hidden directories (starting with `.`) diff --git a/docs/compilation.md b/docs/compilation.md index 49dbc9a5..df3edec4 100644 --- a/docs/compilation.md +++ b/docs/compilation.md @@ -290,7 +290,7 @@ Use the `exclude` field to skip directories during compilation, improving perfor - Prevent duplicate instruction discovery **Default Exclusions:** -APM always excludes these directories (no configuration needed): +APM always excludes directories whose path contains an exact component matching one of these names (no configuration needed). A directory named `rebuild/` is **not** excluded just because it contains `build` as a substring. - `node_modules` - `__pycache__` - `.git` diff --git a/src/apm_cli/compilation/context_optimizer.py b/src/apm_cli/compilation/context_optimizer.py index 07e85f5f..0910cc25 100644 --- a/src/apm_cli/compilation/context_optimizer.py +++ b/src/apm_cli/compilation/context_optimizer.py @@ -429,8 +429,8 @@ def _analyze_project_structure(self) -> None: if any(part.startswith('.') for part in current_path.parts[len(self.base_dir.parts):]): continue - # Default hardcoded exclusions for backwards compatibility - if any(ignore in str(current_path) for ignore in DEFAULT_EXCLUDED_DIRNAMES): + # Default hardcoded exclusions — match on exact path components + if any(part in DEFAULT_EXCLUDED_DIRNAMES for part in relative_path.parts): continue # Apply configurable exclusion patterns diff --git a/tests/unit/compilation/test_context_optimizer.py b/tests/unit/compilation/test_context_optimizer.py index 10da05cb..e7c637b9 100644 --- a/tests/unit/compilation/test_context_optimizer.py +++ b/tests/unit/compilation/test_context_optimizer.py @@ -677,6 +677,60 @@ def test_default_exclusions_still_work(self): assert base_path / "custom_exclude" not in cached_dirs # Custom exclusion +class TestSubstringExclusionFalsePositives: + """Test that directory exclusions use component matching, not substring matching (Fixes #158).""" + + def test_directory_containing_exclusion_token_not_excluded(self): + """Directories like 'rebuild/' must NOT be excluded just because 'build' is a substring.""" + with tempfile.TemporaryDirectory() as tmpdir: + base = Path(tmpdir).resolve() + for name in ["rebuild", "apm_modules_guide", "redistribution", "node_modules_compat"]: + d = base / "src" / name + d.mkdir(parents=True, exist_ok=True) + (d / "file.py").touch() + + optimizer = ContextOptimizer(base_dir=str(base)) + optimizer._analyze_project_structure() + + cached = set(optimizer._directory_cache.keys()) + for name in ["rebuild", "apm_modules_guide", "redistribution", "node_modules_compat"]: + assert base / "src" / name in cached, f"'{name}' was incorrectly excluded" + + def test_exact_exclusion_names_still_excluded(self): + """Directories exactly named 'build', 'dist', etc. must still be excluded.""" + with tempfile.TemporaryDirectory() as tmpdir: + base = Path(tmpdir).resolve() + for name in ["build", "dist", "node_modules", "__pycache__"]: + d = base / name + d.mkdir(parents=True, exist_ok=True) + (d / "file.py").touch() + (base / "src").mkdir(exist_ok=True) + (base / "src" / "app.py").touch() + + optimizer = ContextOptimizer(base_dir=str(base)) + optimizer._analyze_project_structure() + + cached = set(optimizer._directory_cache.keys()) + assert base / "src" in cached + for name in ["build", "dist", "node_modules", "__pycache__"]: + assert base / name not in cached, f"'{name}' should be excluded" + + def test_nested_exclusion_name_still_excluded(self): + """A directory named 'build' nested under a non-excluded parent must still be excluded.""" + with tempfile.TemporaryDirectory() as tmpdir: + base = Path(tmpdir).resolve() + (base / "src" / "build").mkdir(parents=True) + (base / "src" / "build" / "output.js").touch() + (base / "src" / "app.py").touch() + + optimizer = ContextOptimizer(base_dir=str(base)) + optimizer._analyze_project_structure() + + cached = set(optimizer._directory_cache.keys()) + assert base / "src" in cached + assert base / "src" / "build" not in cached + + class TestExpandGlobPattern: """Test _expand_glob_pattern brace expansion."""