From 906733e9f6ad703d2602e18c370f1c185cda8d00 Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Tue, 13 Oct 2026 15:11:41 +0200 Subject: [PATCH] feat(analyzer): support exclude patterns and directory pruning (ticket-007) --- code2logic/analyzer.py | 48 +++++++++++++++++++++++++++++- code2logic/cli.py | 8 +++++ project/TICKETS.md | 1 + project/ticket-007/README.md | 23 +++++++++++++++ project/ticket-007/intent.json | 25 ++++++++++++++++ tests/test_excludes.py | 54 ++++++++++++++++++++++++++++++++++ 6 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 project/ticket-007/README.md create mode 100644 project/ticket-007/intent.json create mode 100644 tests/test_excludes.py diff --git a/code2logic/analyzer.py b/code2logic/analyzer.py index 01b5827..4106465 100644 --- a/code2logic/analyzer.py +++ b/code2logic/analyzer.py @@ -99,6 +99,12 @@ class ProjectAnalyzer: ".mypy_cache", ".ruff_cache", ".cache", + ".subactor", + ".worktrees", + ".planfile", + ".benchmarks", + "managed_components", + "workshop", } # Files to ignore @@ -132,6 +138,7 @@ def __init__( include_private: bool = False, enable_similarity: bool = True, respect_gitignore: bool = True, + exclude: Optional[List[str]] = None, ): """ Initialize the project analyzer. @@ -142,12 +149,14 @@ def __init__( verbose: Whether to print status messages include_private: Whether to include private functions/classes enable_similarity: Whether to enable similarity detection + exclude: Optional list of exclusion glob patterns or directories """ self.root_path = Path(root_path).resolve() self.verbose = verbose self.include_private = include_private self.enable_similarity = enable_similarity self.respect_gitignore = respect_gitignore + self.exclude_patterns: List[str] = list(exclude or []) self.modules: List[ModuleInfo] = [] self.languages: Dict[str, int] = defaultdict(int) @@ -164,6 +173,30 @@ def __init__( if verbose: self._print_status() + def _is_excluded(self, path: Path | str) -> bool: + """Check if a path matches any exclude patterns.""" + if not self.exclude_patterns: + return False + import fnmatch + + try: + rel = str(Path(path).relative_to(self.root_path)).replace("\\", "/") + except Exception: + rel = str(path).replace("\\", "/") + + parts = set(rel.split("/")) + for pattern in self.exclude_patterns: + clean = pattern.rstrip("/").replace("\\", "/") + if clean in parts: + return True + if fnmatch.fnmatch(rel, pattern) or fnmatch.fnmatch(rel, clean): + return True + if pattern.endswith("/**") and ( + rel == pattern[:-3] or rel.startswith(pattern[:-2]) + ): + return True + return False + def _print_status(self): """Print library availability status.""" parts = [] @@ -275,6 +308,12 @@ def _scan_files(self): if filename in self.IGNORE_FILES: continue + if any(part in self.IGNORE_DIRS for part in fp.parts): + continue + + if self._is_excluded(fp): + continue + ext = fp.suffix.lower() language = self.LANGUAGE_EXTENSIONS.get(ext) if language is None and ext == "": @@ -342,7 +381,11 @@ def _scan_files(self): else: for root, dirnames, filenames in os.walk(self.root_path): - dirnames[:] = [d for d in dirnames if d not in self.IGNORE_DIRS] + dirnames[:] = [ + d + for d in dirnames + if d not in self.IGNORE_DIRS and not self._is_excluded(Path(root) / d) + ] for filename in filenames: files_seen += 1 fp = Path(root) / filename @@ -350,6 +393,9 @@ def _scan_files(self): if filename in self.IGNORE_FILES: continue + if self._is_excluded(fp): + continue + ext = fp.suffix.lower() language = self.LANGUAGE_EXTENSIONS.get(ext) if language is None and ext == "": diff --git a/code2logic/cli.py b/code2logic/cli.py index f76ac78..52abe4b 100644 --- a/code2logic/cli.py +++ b/code2logic/cli.py @@ -765,6 +765,13 @@ def main(argv=None): action="store_true", help="Do not respect .gitignore (scan all files under path)", ) + parser.add_argument( + "--exclude", + "-e", + nargs="+", + default=[], + help="Patterns or directories to exclude from analysis", + ) parser.add_argument( "--no-similarity", action="store_true", @@ -957,6 +964,7 @@ def main(argv=None): verbose=args.verbose or args.debug, enable_similarity=not args.no_similarity, respect_gitignore=not args.no_gitignore, + exclude=args.exclude, ) project = analyzer.analyze() analyze_time = time.time() - analyze_start diff --git a/project/TICKETS.md b/project/TICKETS.md index dfb488c..b73eb25 100644 --- a/project/TICKETS.md +++ b/project/TICKETS.md @@ -7,4 +7,5 @@ | **ticket-003** | [`README.md`](./ticket-003/README.md) | - | - | - | - | - | | **ticket-004** | [`README.md`](./ticket-004/README.md) | - | - | - | - | - | | **ticket-005** | [`README.md`](./ticket-005/README.md) | - | - | - | - | - | +| **ticket-006** | [`README.md`](./ticket-006/README.md) | - | - | - | - | - | diff --git a/project/ticket-007/README.md b/project/ticket-007/README.md new file mode 100644 index 0000000..e5c8d41 --- /dev/null +++ b/project/ticket-007/README.md @@ -0,0 +1,23 @@ +# Ticket 007: Support exclude patterns and directory pruning in code2logic + +- **ID**: ticket-007 +- **Owner**: agent:antigravity +- **Status**: IN_PROGRESS +- **Workflow state**: EDIT +- **Created**: 2026-10-13 + +## Goal and scope + +Support custom `--exclude` patterns in CLI and `ProjectAnalyzer`, expand default `IGNORE_DIRS` to prune `.subactor`, `.worktrees`, `.planfile`, `.benchmarks`, `managed_components`, and `workshop`, and filter ignored directories and excluded paths during both git file listing and directory walking. + +## Acceptance criteria + +- [x] AC-01: Add `.subactor`, `.worktrees`, `.planfile`, `.benchmarks`, `managed_components`, and `workshop` to default `IGNORE_DIRS`. +- [x] AC-02: Support `--exclude` / `-e` CLI option in `code2logic` and `exclude` parameter in `ProjectAnalyzer`. +- [x] AC-03: Filter excluded paths and ignored directories in `_scan_files`. +- [x] AC-04: Full test suite passes. + +## Tracking boundary + +This directory contains the minimal reviewed intent. Optional participant prose +and raw command logs are not required delivery output. diff --git a/project/ticket-007/intent.json b/project/ticket-007/intent.json new file mode 100644 index 0000000..93c9f23 --- /dev/null +++ b/project/ticket-007/intent.json @@ -0,0 +1,25 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-007", + "summary": "Support exclude patterns and directory pruning in code2logic", + "workstream": "application", + "classification": { + "kind": "SERVICE", + "priority": "P2", + "origin": "health" + }, + "allowedPaths": [ + "project/ticket-007/**", + "TODO.md", + "project/TICKETS.md", + "code2logic/**", + "tests/**" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md" + ], + "stacks": [], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null +} diff --git a/tests/test_excludes.py b/tests/test_excludes.py new file mode 100644 index 0000000..1d3b91a --- /dev/null +++ b/tests/test_excludes.py @@ -0,0 +1,54 @@ +"""Tests for exclude patterns and directory pruning in ProjectAnalyzer.""" + +import tempfile +from pathlib import Path +from code2logic.analyzer import ProjectAnalyzer + + +def test_project_analyzer_exclude_directories(): + """Verify that custom exclude patterns exclude directories and files.""" + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + (root / "src").mkdir() + (root / "src" / "app.py").write_text("def run(): pass\n") + (root / "vendor").mkdir() + (root / "vendor" / "lib.py").write_text("def vendored(): pass\n") + (root / "custom_ignored").mkdir() + (root / "custom_ignored" / "foo.py").write_text("def foo(): pass\n") + + analyzer = ProjectAnalyzer( + str(root), + use_treesitter=False, + respect_gitignore=False, + exclude=["custom_ignored", "custom_ignored/**"], + ) + project = analyzer.analyze() + + module_paths = [m.path for m in project.modules] + assert any("app.py" in p for p in module_paths) + assert not any("foo.py" in p for p in module_paths) + # vendor is in default IGNORE_DIRS + assert not any("lib.py" in p for p in module_paths) + + +def test_default_ignore_dirs(): + """Verify default IGNORE_DIRS includes .subactor, .worktrees, etc.""" + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + (root / "main.py").write_text("def main(): pass\n") + (root / ".subactor").mkdir() + (root / ".subactor" / "hidden.py").write_text("def sub(): pass\n") + (root / ".worktrees").mkdir() + (root / ".worktrees" / "wt.py").write_text("def wt(): pass\n") + + analyzer = ProjectAnalyzer( + str(root), + use_treesitter=False, + respect_gitignore=False, + ) + project = analyzer.analyze() + + module_paths = [m.path for m in project.modules] + assert any("main.py" in p for p in module_paths) + assert not any("hidden.py" in p for p in module_paths) + assert not any("wt.py" in p for p in module_paths)