From 6f4a9b327807f246ebd8544a9ac9a826e2e53160 Mon Sep 17 00:00:00 2001 From: Wolfvin Date: Tue, 30 Jun 2026 13:48:37 +0000 Subject: [PATCH] =?UTF-8?q?feat(config):=20add=20.codelensignore=20support?= =?UTF-8?q?=20=E2=80=94=203-tier=20ignore=20patterns=20(closes=20#55=20pha?= =?UTF-8?q?se-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/codelensignore.py | 436 +++++++++++++++++++++++++ scripts/commands/scan.py | 57 +++- scripts/data/default-codelensignore | 44 +++ tests/test_codelensignore.py | 487 ++++++++++++++++++++++++++++ 4 files changed, 1023 insertions(+), 1 deletion(-) create mode 100644 scripts/codelensignore.py create mode 100644 scripts/data/default-codelensignore create mode 100644 tests/test_codelensignore.py diff --git a/scripts/codelensignore.py b/scripts/codelensignore.py new file mode 100644 index 00000000..41bc34a6 --- /dev/null +++ b/scripts/codelensignore.py @@ -0,0 +1,436 @@ +"""3-tier .codelensignore support for CodeLens (issue #55). + +Loads ignore patterns from three sources, in priority order +(highest → lowest): + +1. **Workspace** — ``/.codelensignore`` +2. **User** — ``~/.codelensignore`` +3. **Builtin** — ``scripts/data/default-codelensignore`` + +Pattern syntax follows the gitignore spec (``**``, ``*``, ``?``, ``!`` +negation, ``/``-anchored patterns). The optional ``pathspec`` library +is used when available for full gitignore compatibility; otherwise we +fall back to a ``fnmatch``-based matcher that supports a useful subset +(negation via leading ``!``, ``*`` and ``?`` wildcards, ``/``-anchored +prefixes). The fallback is intentionally permissive — pattern authors +who need full gitignore semantics should install ``pathspec``. + +Precedence across the three tiers follows gitignore semantics: when a +path matches multiple patterns across tiers, the **last matching +pattern** wins. Concretely, builtin patterns are evaluated first, then +user, then workspace — so a ``!``-negation in the workspace file can +re-include a path that was ignored by the builtin or user tier. + +Public API: + is_ignored(path, project_root) -> bool + Return True if *path* is ignored by any tier's positive pattern + AND not re-included by a higher-priority ``!``-negation. + + load_patterns(project_root) -> list[str] + Return the merged pattern list (builtin + user + workspace). + + suggest_ignore_directories(project_root, top_n=10) -> list[dict] + Return top-N largest directories (by total file size) that are + NOT currently ignored — used by ``scan --suggest-ignore``. +""" + +from __future__ import annotations + +import fnmatch +import os +import re +from typing import List, Optional, Tuple + +# ── Optional pathspec dependency ────────────────────────────────── +# Gracefully degrade to fnmatch if pathspec is not installed. +try: # pragma: no cover - exercised on systems with/without pathspec + import pathspec # type: ignore + + _HAS_PATHSPEC = True +except ImportError: # pragma: no cover + _HAS_PATHSPEC = False + + +# ── Path constants ──────────────────────────────────────────────── + +_BUILTIN_PATH = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + 'data', + 'default-codelensignore', +) + + +def _user_ignore_path() -> str: + """Return the path to the user-level ~/.codelensignore file.""" + return os.path.join(os.path.expanduser('~'), '.codelensignore') + + +def _workspace_ignore_path(project_root: str) -> str: + """Return the path to the workspace-level .codelensignore file.""" + return os.path.join(project_root, '.codelensignore') + + +# ── Pattern loading ─────────────────────────────────────────────── + +def _read_patterns(path: str) -> List[str]: + """Read non-empty, non-comment pattern lines from a file. + + Args: + path: Filesystem path to an ignore file. + + Returns: + List of stripped pattern strings (comments and blank lines removed). + Returns ``[]`` if the file does not exist or cannot be read. + """ + if not path or not os.path.isfile(path): + return [] + try: + with open(path, 'r', encoding='utf-8', errors='ignore') as f: + lines = f.readlines() + except OSError: + return [] + + out: List[str] = [] + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith('#'): + continue + out.append(stripped) + return out + + +def builtin_patterns() -> List[str]: + """Return the builtin (lowest-priority) pattern list.""" + return _read_patterns(_BUILTIN_PATH) + + +def user_patterns() -> List[str]: + """Return the user-level pattern list from ~/.codelensignore.""" + return _read_patterns(_user_ignore_path()) + + +def workspace_patterns(project_root: str) -> List[str]: + """Return the workspace-level pattern list from /.codelensignore.""" + return _read_patterns(_workspace_ignore_path(project_root)) + + +def load_patterns(project_root: str) -> List[str]: + """Merge all three tiers into a single ordered pattern list. + + Order: builtin → user → workspace. In gitignore semantics, the last + matching pattern wins, so this ordering gives workspace patterns + the highest priority (including ``!``-negation overrides). + """ + return ( + builtin_patterns() + + user_patterns() + + workspace_patterns(project_root) + ) + + +# ── Matchers ────────────────────────────────────────────────────── + +class _PathspecMatcher: + """Full gitignore-spec matcher backed by ``pathspec.PathSpec``.""" + + __slots__ = ('_spec',) + + def __init__(self, patterns: List[str]): + # ``gitignore`` factory handles ``!`` negation, ``**`` recursion, + # ``/``-anchored patterns, and trailing-slash directory markers. + self._spec = pathspec.PathSpec.from_lines('gitignore', patterns) + + def is_ignored(self, rel_path: str) -> bool: + # pathspec expects forward-slash-separated relative paths. + rel = rel_path.replace('\\', '/') + if not rel or rel == '.': + return False + # pathspec's gitignore mode treats ``dir/`` patterns as matching + # paths INSIDE the directory but not the directory path itself + # (e.g., pattern ``node_modules/`` does NOT match the path + # ``node_modules`` without a trailing slash). To give users the + # expected "is this directory ignored?" behavior when they pass + # a bare directory path, we also try matching with a trailing slash. + if self._spec.match_file(rel): + return True + if not rel.endswith('/'): + return bool(self._spec.match_file(rel + '/')) + return False + + +class _FnmatchMatcher: + """Fallback matcher using ``fnmatch`` when pathspec is unavailable. + + Supports a useful subset of gitignore syntax: + * Leading ``!`` → negation (last match wins). + * ``*`` and ``?`` wildcards. + * Trailing ``/`` → directory-only marker (treated as path prefix). + * Leading ``/`` → anchored to project root. + + Does NOT support ``**`` recursion or bracket character classes the + same way gitignore does, but is sufficient for the common cases. + """ + + __slots__ = ('_rules',) + + def __init__(self, patterns: List[str]): + # Each rule: (is_negation, compiled_regex, anchored, dir_only) + rules: List[Tuple[bool, 're.Pattern', bool, bool]] = [] + for pat in patterns: + is_neg = pat.startswith('!') + if is_neg: + pat = pat[1:] + anchored = pat.startswith('/') + if anchored: + pat = pat[1:] + dir_only = pat.endswith('/') + if dir_only: + pat = pat[:-1] + # Convert fnmatch glob → regex. + # We treat ``**`` like ``*`` for simplicity in fallback mode. + pat_normalized = pat.replace('\\', '/') + regex = fnmatch.translate(pat_normalized) + # fnmatch.translate produces a regex anchored at both ends; + # we want to match the full path (or a prefix when dir_only). + rules.append(( + is_neg, + re.compile(regex), + anchored, + dir_only, + )) + self._rules = rules + + def is_ignored(self, rel_path: str) -> bool: + rel = rel_path.replace('\\', '/') + if not rel or rel == '.': + return False + + result = False + for is_neg, rx, anchored, dir_only in self._rules: + if dir_only: + # Match if rel == pat or rel.startswith(pat + '/') + # We achieve this by matching the pattern OR pattern + '/*' + # Use the regex against the path and any prefix path that + # ends at a separator. + # Simpler: check the rule against every prefix of rel. + matched = self._match_dir_prefix(rx, rel) + else: + matched = bool(rx.match(rel)) + if matched: + result = not is_neg + return result + + @staticmethod + def _match_dir_prefix(rx: 're.Pattern', rel: str) -> bool: + """True if *rel* OR any ancestor directory matches *rx*.""" + # Check the full path first + if rx.match(rel): + return True + # Then check every ancestor directory + parts = rel.split('/') + for i in range(1, len(parts)): + prefix = '/'.join(parts[:i]) + if rx.match(prefix): + return True + return False + + +def _build_matcher(patterns: List[str]): + """Return a matcher instance for the given patterns. + + Uses ``pathspec`` if available; otherwise falls back to the + ``fnmatch``-based matcher. Returns ``None`` if *patterns* is empty + so callers can short-circuit the no-op case. + """ + if not patterns: + return None + if _HAS_PATHSPEC: + return _PathspecMatcher(patterns) + return _FnmatchMatcher(patterns) + + +# ── Cache ───────────────────────────────────────────────────────── +# Per-process cache keyed by (project_root, signature). The signature +# is a string concatenation of (mtime, size) for the 3 source files +# so the cache auto-invalidates when any source changes. + +_CACHE: dict = {} + + +def _signature(project_root: str) -> str: + """Build a cache signature from the mtimes/sizes of all 3 source files.""" + parts = [] + for p in ( + _BUILTIN_PATH, + _user_ignore_path(), + _workspace_ignore_path(project_root), + ): + try: + st = os.stat(p) + parts.append(f"{p}:{st.st_mtime_ns}:{st.st_size}") + except OSError: + parts.append(f"{p}:-") + return "|".join(parts) + + +def _get_matcher(project_root: str): + """Return a cached matcher for *project_root*, rebuilding if sources changed.""" + sig = _signature(project_root) + key = (project_root, sig) + cached = _CACHE.get(key) + if cached is not None: + return cached + # Build new matcher + patterns = load_patterns(project_root) + matcher = _build_matcher(patterns) + # Evict stale entries for the same project_root to bound cache size. + stale_keys = [k for k in _CACHE if k[0] == project_root] + for k in stale_keys: + del _CACHE[k] + _CACHE[key] = (matcher, patterns) + return matcher, patterns + + +# ── Public API ──────────────────────────────────────────────────── + +def is_ignored(path: str, project_root: str) -> bool: + """Check if *path* is ignored by any of the 3 .codelensignore tiers. + + Args: + path: Absolute path OR path relative to *project_root*. + Both forms are normalized internally. + project_root: Absolute path to the project root. + + Returns: + True if *path* matches a positive pattern in any tier AND is not + re-included by a higher-priority ``!``-negation. False otherwise, + including when no .codelensignore files exist at all. + """ + project_root = os.path.abspath(project_root) + + # Compute a workspace-relative path (forward-slash normalized). + if os.path.isabs(path): + try: + rel = os.path.relpath(path, project_root) + except ValueError: + # Windows: different drive letters — fall back to original. + rel = path + else: + rel = path + + # Never ignore the project root itself. + if rel in ('.', '', '.'): + return False + + matcher, _ = _get_matcher(project_root) + if matcher is None: + return False + return matcher.is_ignored(rel) + + +def suggest_ignore_directories( + project_root: str, + top_n: int = 10, +) -> List[dict]: + """Return the top-N largest directories not currently ignored. + + Walks the workspace, sums file sizes per directory (non-recursively), + and returns the largest directories that are NOT ignored by the + 3-tier system. Useful for the ``scan --suggest-ignore`` flag. + + Args: + project_root: Absolute path to the project root. + top_n: Maximum number of directories to return (default 10). + + Returns: + List of dicts sorted descending by ``size_bytes``, each with: + * ``path`` — workspace-relative directory path (``.`` for root). + * ``size_bytes`` — total size of files directly in the directory. + * ``size_human`` — e.g. ``"1.23 MB"``. + * ``file_count`` — number of files counted in the directory. + """ + project_root = os.path.abspath(project_root) + dir_stats: dict = {} + + for root, dirs, filenames in os.walk(project_root): + rel_root = os.path.relpath(root, project_root) + if rel_root == '.': + rel_root_norm = '' + else: + rel_root_norm = rel_root.replace('\\', '/') + + # Skip this dir if it's already ignored (don't recurse). + if rel_root_norm and is_ignored(rel_root_norm, project_root): + dirs.clear() + continue + + # Skip the project root's own .codelens dir (always internal). + if '.codelens' in root and root != project_root: + dirs.clear() + continue + + # Filter subdirs: skip ignored ones so we don't recurse into them. + kept_dirs = [] + for d in dirs: + sub_rel = f"{rel_root_norm}/{d}" if rel_root_norm else d + if is_ignored(sub_rel, project_root): + continue + kept_dirs.append(d) + dirs[:] = kept_dirs + + # Sum file sizes (non-recursive: only files directly in this dir). + total_size = 0 + file_count = 0 + for fn in filenames: + fp = os.path.join(root, fn) + try: + total_size += os.path.getsize(fp) + file_count += 1 + except OSError: + pass + + display_path = rel_root_norm if rel_root_norm else '.' + dir_stats[display_path] = (total_size, file_count) + + # Rank by size, descending. + ranked = sorted( + dir_stats.items(), + key=lambda kv: kv[1][0], + reverse=True, + )[:top_n] + + return [ + { + 'path': path, + 'size_bytes': size, + 'size_human': _human_size(size), + 'file_count': count, + } + for path, (size, count) in ranked + if size > 0 # skip empty dirs + ] + + +def _human_size(n: int) -> str: + """Format a byte count as a human-readable string.""" + for unit in ('B', 'KB', 'MB', 'GB', 'TB'): + if n < 1024 or unit == 'TB': + if unit == 'B': + return f"{n} {unit}" + return f"{n:.2f} {unit}" + n /= 1024.0 + return f"{n:.2f} TB" + + +__all__ = [ + 'is_ignored', + 'load_patterns', + 'builtin_patterns', + 'user_patterns', + 'workspace_patterns', + 'suggest_ignore_directories', + 'HAS_PATHSPEC', +] + +# Re-export for tests/inspection. +HAS_PATHSPEC = _HAS_PATHSPEC diff --git a/scripts/commands/scan.py b/scripts/commands/scan.py index 1ef5f962..5bd98e49 100644 --- a/scripts/commands/scan.py +++ b/scripts/commands/scan.py @@ -12,6 +12,14 @@ load_backend_registry, save_backend_registry, build_frontend_registry ) +# 3-tier .codelensignore support (issue #55). Module imports pathspec +# lazily and gracefully degrades to fnmatch if pathspec is unavailable. +try: + from codelensignore import is_ignored as _codelensignore_is_ignored + from codelensignore import suggest_ignore_directories as _suggest_ignore_dirs +except ImportError: # pragma: no cover — defensive: module lives in scripts/ + _codelensignore_is_ignored = None + _suggest_ignore_dirs = None from framework_detect import detect_frameworks, get_recommended_config from incremental import ( find_changed_files, update_mtimes_cache, remove_from_mtimes_cache, @@ -55,10 +63,18 @@ def add_args(parser): parser.add_argument("--max-files", type=int, default=None, help="Cap total files scanned (default: unlimited). " "Used by auto-setup to prevent timeout on huge repos.") + parser.add_argument("--suggest-ignore", action="store_true", + help="Print the top-10 largest directories (by total file " + "size) that are NOT currently ignored by .codelensignore. " + "Does not perform a scan; useful for tuning ignore rules.") def execute(args, workspace): """Execute the scan command.""" + # --suggest-ignore short-circuits the normal scan flow. + if getattr(args, 'suggest_ignore', False): + return _run_suggest_ignore(workspace) + incremental = getattr(args, 'incremental', False) plugins = getattr(args, 'plugins', None) max_files = getattr(args, 'max_files', None) @@ -70,6 +86,34 @@ def execute(args, workspace): return cmd_scan(workspace, incremental, plugins=plugins, max_files=max_files) +def _run_suggest_ignore(workspace: str) -> Dict[str, Any]: + """Handle ``scan --suggest-ignore`` — print top-10 largest non-ignored dirs. + + Returns a dict suitable for the CLI formatter. Walks the workspace once, + sums per-directory file sizes (non-recursively), and returns the largest + directories not matched by the 3-tier ``.codelensignore`` system. + """ + workspace = os.path.abspath(workspace) + if _suggest_ignore_dirs is None: # pragma: no cover — defensive + return { + "status": "error", + "workspace": workspace, + "error": "codelensignore module unavailable", + } + top = _suggest_ignore_dirs(workspace, top_n=10) + return { + "status": "ok", + "workspace": workspace, + "command": "scan --suggest-ignore", + "suggestion_count": len(top), + "suggestions": top, + "hint": ( + "Add these paths to .codelensignore (workspace or ~/.codelensignore) " + "to skip them in future scans." + ), + } + + def cmd_scan(workspace: str, incremental: bool = False, plugins: Optional[list] = None, max_files: Optional[int] = None) -> Dict[str, Any]: """ @@ -1240,11 +1284,18 @@ def discover_files(workspace: str, config: Dict) -> Dict[str, List[str]]: for root, dirs, filenames in os.walk(workspace): rel_root = os.path.relpath(root, workspace) - + if should_ignore(rel_root, config): dirs.clear() continue + # 3-tier .codelensignore check (issue #55). Augments the existing + # config-based should_ignore() above; builtin patterns cover the + # historical DEFAULT_IGNORE_DIRS set so backward compat is preserved. + if _codelensignore_is_ignored is not None and _codelensignore_is_ignored(rel_root, workspace): + dirs.clear() + continue + if '.codelens' in root: dirs.clear() continue @@ -1256,6 +1307,10 @@ def discover_files(workspace: str, config: Dict) -> Dict[str, List[str]]: if should_ignore(rel_path, config): continue + # 3-tier .codelensignore check for individual files. + if _codelensignore_is_ignored is not None and _codelensignore_is_ignored(rel_path, workspace): + continue + ext = os.path.splitext(filename)[1].lower() # Skip TypeScript declaration files (auto-generated, no runtime code) diff --git a/scripts/data/default-codelensignore b/scripts/data/default-codelensignore new file mode 100644 index 00000000..11885b2a --- /dev/null +++ b/scripts/data/default-codelensignore @@ -0,0 +1,44 @@ +# CodeLens default ignore patterns (issue #55) +# Loaded as the lowest-priority tier of the 3-tier .codelensignore system: +# 1. Workspace: /.codelensignore (highest priority) +# 2. User: ~/.codelensignore +# 3. Builtin: scripts/data/default-codelensignore (this file) +# +# Format: gitignore-spec syntax (see https://git-scm.com/docs/gitignore). +# `!` negations in higher tiers override patterns in lower tiers. +# +# This file intentionally mirrors the historical DEFAULT_IGNORE_DIRS set from +# utils.py so behavior is preserved when no workspace/user overrides exist. + +# ── Directories ──────────────────────────────────────────────── +node_modules/ +.git/ +__pycache__/ +.venv/ +venv/ +dist/ +build/ +.codelens/ +target/ +.next/ +.nuxt/ +.cache/ +vendor/ +env/ +.idea/ +.vscode/ +_archive/ +coverage/ +.pytest_cache/ +.tox/ +bin/ +obj/ +.terraform/ +.cargo/ +.rustup/ +storybook-static/ +.storybook/ + +# ── File globs ───────────────────────────────────────────────── +*.pyc +*.pyo diff --git a/tests/test_codelensignore.py b/tests/test_codelensignore.py new file mode 100644 index 00000000..483cc9c0 --- /dev/null +++ b/tests/test_codelensignore.py @@ -0,0 +1,487 @@ +"""Tests for 3-tier .codelensignore support (issue #55). + +Verifies: +1. Three-tier loading: workspace > user > builtin (priority order). +2. ``!``-negation works across tiers (workspace can re-include user/builtin ignores). +3. Backward compat with ``DEFAULT_IGNORE_DIRS`` — builtin patterns cover the + historical hardcoded set, including segment-aware matching that avoids + false positives like ``test-target`` matching ``target/``. +4. Integration with ``discover_files`` (scan command). +5. ``--suggest-ignore`` flag returns top-N largest non-ignored directories. +6. ``pathspec`` optional dependency: graceful degradation to fnmatch fallback. +""" + +import os +import sys +import shutil +import tempfile +import json + +import pytest + +SCRIPT_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts" +) +sys.path.insert(0, SCRIPT_DIR) + +import codelensignore as ci +from utils import DEFAULT_IGNORE_DIRS + + +# ─── Helpers ────────────────────────────────────────────────────── + + +@pytest.fixture(autouse=True) +def _clear_cache(): + """Clear the module-level matcher cache before every test.""" + ci._CACHE.clear() + yield + ci._CACHE.clear() + + +@pytest.fixture +def user_ignore_file(tmp_path, monkeypatch): + """Create an isolated user-level ~/.codelensignore in a temp HOME. + + Returns the path to the user ignore file. Tests can write patterns + to it directly. + """ + fake_home = tmp_path / "fake_home" + fake_home.mkdir() + monkeypatch.setenv("HOME", str(fake_home)) + # Also patch expanduser for systems where HOME is overridden + monkeypatch.setattr(os.path, "expanduser", lambda p: ( + str(fake_home) if p == "~" else + str(fake_home) + p[1:] if p.startswith("~/") else + p + )) + ignore_file = fake_home / ".codelensignore" + return str(ignore_file) + + +def _write(path, content): + """Write *content* to *path*, creating parent dirs as needed.""" + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + f.write(content) + + +# ─── 1. Three-tier loading ──────────────────────────────────────── + + +class TestThreeTierLoading: + """Verify builtin, user, and workspace tiers are all loaded.""" + + def test_builtin_patterns_loaded(self): + """Builtin patterns file should exist and contain expected entries.""" + patterns = ci.builtin_patterns() + assert isinstance(patterns, list) + assert len(patterns) > 0 + # Must include the explicitly-required entries from the issue. + for required in ("node_modules/", ".git/", "__pycache__/", + "*.pyc", ".venv/", "venv/", "dist/", + "build/", ".codelens/"): + assert required in patterns, f"Builtin missing required pattern: {required}" + + def test_workspace_patterns_loaded(self, tmp_path): + """Workspace .codelensignore should be loaded when present.""" + ws = tmp_path / "ws" + ws.mkdir() + (ws / ".codelensignore").write_text("custom_dir/\n*.special\n") + + patterns = ci.workspace_patterns(str(ws)) + assert "custom_dir/" in patterns + assert "*.special" in patterns + + def test_user_patterns_loaded(self, user_ignore_file): + """User ~/.codelensignore should be loaded when present.""" + _write(user_ignore_file, "*.user_pattern\n") + + patterns = ci.user_patterns() + assert "*.user_pattern" in patterns + + def test_load_patterns_merges_all_tiers(self, tmp_path, user_ignore_file): + """load_patterns should merge builtin + user + workspace patterns.""" + ws = tmp_path / "ws" + ws.mkdir() + (ws / ".codelensignore").write_text("workspace_pattern/\n") + _write(user_ignore_file, "user_pattern/\n") + + merged = ci.load_patterns(str(ws)) + # All three tiers should be represented + assert "node_modules/" in merged # builtin + assert "user_pattern/" in merged # user + assert "workspace_pattern/" in merged # workspace + + def test_workspace_overrides_user(self, tmp_path, user_ignore_file): + """Workspace patterns can RE-include paths ignored by user tier. + + ``!``-negation in the workspace file should override a positive + pattern in the user file. + """ + ws = tmp_path / "ws" + ws.mkdir() + # User tier: ignore all *.test.py files + _write(user_ignore_file, "*.test.py\n") + # Workspace tier: re-include important.test.py + (ws / ".codelensignore").write_text("!important.test.py\n") + + # regular test file ignored by user pattern + assert ci.is_ignored("app.test.py", str(ws)) is True + # workspace negation re-includes important.test.py + assert ci.is_ignored("important.test.py", str(ws)) is False + + def test_workspace_overrides_builtin(self, tmp_path): + """Workspace ``!``-negation can re-include builtin-ignored paths.""" + ws = tmp_path / "ws" + ws.mkdir() + # Builtin has node_modules/ — workspace re-includes a specific path + (ws / ".codelensignore").write_text("!node_modules/keep_me/\n") + + assert ci.is_ignored("node_modules/skip/file.js", str(ws)) is True + assert ci.is_ignored("node_modules/keep_me/file.js", str(ws)) is False + + def test_user_overrides_builtin(self, user_ignore_file, tmp_path): + """User tier takes priority over builtin.""" + ws = tmp_path / "ws" + ws.mkdir() + # Builtin has *.pyc — user re-includes important.pyc + _write(user_ignore_file, "!important.pyc\n") + + assert ci.is_ignored("app.pyc", str(ws)) is True # builtin + assert ci.is_ignored("important.pyc", str(ws)) is False # user negation + + +# ─── 2. Negation ────────────────────────────────────────────────── + + +class TestNegation: + """Verify ``!``-prefix negation semantics across single and multiple tiers.""" + + def test_negation_within_single_file(self, tmp_path): + """``!``-negation within a single workspace file overrides earlier patterns.""" + ws = tmp_path / "ws" + ws.mkdir() + (ws / ".codelensignore").write_text( + "*.log\n!important.log\n" + ) + assert ci.is_ignored("debug.log", str(ws)) is True + assert ci.is_ignored("important.log", str(ws)) is False + + def test_negation_resurrects_subdir(self, tmp_path): + """A negated subdirectory of an ignored directory is re-included.""" + ws = tmp_path / "ws" + ws.mkdir() + (ws / ".codelensignore").write_text( + "build/\n!build/keep/\n" + ) + assert ci.is_ignored("build/output.exe", str(ws)) is True + assert ci.is_ignored("build/keep/important.txt", str(ws)) is False + + def test_negation_does_not_affect_unrelated_paths(self, tmp_path): + ws = tmp_path / "ws" + ws.mkdir() + (ws / ".codelensignore").write_text( + "*.log\n!important.log\n" + ) + assert ci.is_ignored("src/app.py", str(ws)) is False + assert ci.is_ignored("README.md", str(ws)) is False + + def test_negation_in_workspace_overrides_user_positive(self, tmp_path, user_ignore_file): + ws = tmp_path / "ws" + ws.mkdir() + _write(user_ignore_file, "secrets/\n") + (ws / ".codelensignore").write_text("!secrets/public/\n") + + assert ci.is_ignored("secrets/private.key", str(ws)) is True + assert ci.is_ignored("secrets/public/notice.txt", str(ws)) is False + + +# ─── 3. Backward compat with DEFAULT_IGNORE_DIRS ────────────────── + + +class TestBackwardCompat: + """Builtin patterns must cover the historical DEFAULT_IGNORE_DIRS set, + and segment-aware matching must avoid false positives (issue #55 constraint). + """ + + @pytest.mark.parametrize("dirname", sorted(DEFAULT_IGNORE_DIRS)) + def test_builtin_covers_default_ignore_dirs(self, dirname): + """Every entry in DEFAULT_IGNORE_DIRS should be ignored by builtin.""" + patterns = ci.builtin_patterns() + # Either dir/ or just dir is in the patterns. + assert f"{dirname}/" in patterns or dirname in patterns, ( + f"DEFAULT_IGNORE_DIRS entry {dirname!r} not covered by builtin patterns" + ) + + @pytest.mark.parametrize("dirname", sorted(DEFAULT_IGNORE_DIRS)) + def test_default_dir_is_ignored(self, dirname, tmp_path): + """Sanity: each default-ignored dir name actually gets ignored.""" + # Skip 'bin' here — pathspec's gitignore doesn't recognize bare + # 'bin/' as a top-level dir unless anchored, but builtin patterns + # still match it as a path segment. We verify with a sub-path. + ws = tmp_path / "ws" + ws.mkdir() + assert ci.is_ignored(f"{dirname}/file.txt", str(ws)) is True, ( + f"Expected {dirname}/file.txt to be ignored by builtin patterns" + ) + + def test_segment_aware_no_false_positive_test_target(self, tmp_path): + """``target/`` should NOT match ``test-target/`` (path-segment-aware).""" + ws = tmp_path / "ws" + ws.mkdir() + assert ci.is_ignored("test-target/src/app.py", str(ws)) is False + assert ci.is_ignored("test-target", str(ws)) is False + + def test_segment_aware_no_false_positive_dist_app(self, tmp_path): + """``dist/`` should NOT match ``dist-app/``.""" + ws = tmp_path / "ws" + ws.mkdir() + assert ci.is_ignored("dist-app/components/Button.tsx", str(ws)) is False + + def test_segment_aware_no_false_positive_build_tools(self, tmp_path): + """``build/`` should NOT match ``build-tools/``.""" + ws = tmp_path / "ws" + ws.mkdir() + assert ci.is_ignored("build-tools/config/webpack.js", str(ws)) is False + + def test_actual_target_dir_is_ignored(self, tmp_path): + """An actual ``target/`` directory SHOULD be ignored.""" + ws = tmp_path / "ws" + ws.mkdir() + assert ci.is_ignored("target/debug/binary.o", str(ws)) is True + assert ci.is_ignored("src/target/debug/binary.o", str(ws)) is True + + def test_pyc_files_ignored(self, tmp_path): + """``*.pyc`` builtin pattern should match .pyc files anywhere.""" + ws = tmp_path / "ws" + ws.mkdir() + assert ci.is_ignored("app.pyc", str(ws)) is True + assert ci.is_ignored("src/__pycache__/app.cpython-310.pyc", str(ws)) is True + + def test_codelens_dir_ignored(self, tmp_path): + """``.codelens/`` should be ignored (it's CodeLens's own output dir).""" + ws = tmp_path / "ws" + ws.mkdir() + assert ci.is_ignored(".codelens/codelens.db", str(ws)) is True + assert ci.is_ignored(".codelens", str(ws)) is True + + +# ─── 4. Integration with discover_files ─────────────────────────── + + +class TestDiscoverFilesIntegration: + """Verify that discover_files() respects the 3-tier .codelensignore system.""" + + def _make_workspace(self, tmp_path): + ws = tmp_path / "ws" + ws.mkdir() + # Source files + src = ws / "src" + src.mkdir() + (src / "app.py").write_text("print('hi')\n") + # node_modules — ignored by builtin + nm = ws / "node_modules" / "pkg" + nm.mkdir(parents=True) + (nm / "index.js").write_text("module.exports = 1;\n") + # custom_ignore — to be ignored by workspace .codelensignore + ci_dir = ws / "custom_ignore" + ci_dir.mkdir() + (ci_dir / "data.py").write_text("x = 1\n") + return ws + + def _save_config(self, ws): + from registry import save_config + config = { + "frontend_paths": [], "backend_paths": [], "watch": False, + "ignore": ["node_modules/", "dist/", ".git/", "build/", + "target/", "__pycache__/"], + "frameworks": [], "jsx_mode": False, "vue_mode": False, + "svelte_mode": False, "tailwind_mode": False, + } + save_config(str(ws), config) + return config + + def test_workspace_codelensignore_filters_dir(self, tmp_path): + """discover_files should skip a dir listed in .codelensignore.""" + from commands.scan import discover_files + + ws = self._make_workspace(tmp_path) + (ws / ".codelensignore").write_text("custom_ignore/\n") + config = self._save_config(ws) + + files = discover_files(str(ws), config) + python_files = [os.path.basename(f) for f in files["python"]] + assert "app.py" in python_files + assert "data.py" not in python_files + + def test_builtin_patterns_filter_node_modules(self, tmp_path): + """discover_files should skip node_modules via builtin patterns.""" + from commands.scan import discover_files + + ws = self._make_workspace(tmp_path) + config = self._save_config(ws) + + files = discover_files(str(ws), config) + # node_modules index.js should NOT appear in js_backend + js_files = [os.path.basename(f) for f in files["js_backend"]] + assert "index.js" not in js_files + + def test_negation_reincludes_in_discover(self, tmp_path): + """A ``!``-negation in .codelensignore should let a previously-ignored + file be discovered.""" + from commands.scan import discover_files + + ws = tmp_path / "ws" + ws.mkdir() + # Create a .pyc file — ignored by builtin *.pyc + (ws / "app.pyc").write_text("garbage") + # Workspace: re-include app.pyc (overriding builtin *.pyc) + (ws / ".codelensignore").write_text("!app.pyc\n") + config = self._save_config(ws) + + # Note: discover_files only categorizes known source extensions, + # so .pyc won't show up in any list. Instead, verify via is_ignored. + assert ci.is_ignored("app.pyc", str(ws)) is False + + def test_default_behavior_preserved_without_codelensignore(self, tmp_path): + """Without any workspace .codelensignore, builtin patterns still apply.""" + from commands.scan import discover_files + + ws = self._make_workspace(tmp_path) + config = self._save_config(ws) + + files = discover_files(str(ws), config) + # node_modules should still be filtered out by builtin + js_files = [os.path.basename(f) for f in files["js_backend"]] + assert "index.js" not in js_files + # custom_ignore has no .codelensignore to filter it — its data.py + # SHOULD be discovered (since the test config doesn't include it). + python_files = [os.path.basename(f) for f in files["python"]] + assert "data.py" in python_files + + +# ─── 5. --suggest-ignore flag ───────────────────────────────────── + + +class TestSuggestIgnore: + """Verify ``scan --suggest-ignore`` returns top-N largest non-ignored dirs.""" + + def _make_workspace(self, tmp_path): + ws = tmp_path / "ws" + ws.mkdir() + # Small dir + (ws / "small_dir").mkdir() + (ws / "small_dir" / "file.txt").write_text("x" * 100) + # Large dir + (ws / "large_dir").mkdir() + (ws / "large_dir" / "big.bin").write_text("y" * 10000) + # node_modules — ignored by builtin, should NOT appear + (ws / "node_modules" / "pkg").mkdir(parents=True) + (ws / "node_modules" / "pkg" / "index.js").write_text("z" * 50000) + return ws + + def test_suggest_returns_top_dirs_by_size(self, tmp_path): + ws = self._make_workspace(tmp_path) + result = ci.suggest_ignore_directories(str(ws), top_n=10) + assert result[0]["path"] == "large_dir" + assert result[0]["size_bytes"] == 10000 + assert "size_human" in result[0] + assert "file_count" in result[0] + + def test_suggest_excludes_ignored_dirs(self, tmp_path): + """Directories matched by builtin patterns should NOT appear.""" + ws = self._make_workspace(tmp_path) + result = ci.suggest_ignore_directories(str(ws), top_n=10) + paths = [r["path"] for r in result] + assert "node_modules" not in paths + assert "node_modules/pkg" not in paths + + def test_suggest_excludes_workspace_ignored(self, tmp_path): + """Workspace .codelensignore entries should also be excluded.""" + ws = self._make_workspace(tmp_path) + (ws / ".codelensignore").write_text("large_dir/\n") + result = ci.suggest_ignore_directories(str(ws), top_n=10) + paths = [r["path"] for r in result] + assert "large_dir" not in paths + + def test_suggest_respects_top_n(self, tmp_path): + ws = self._make_workspace(tmp_path) + result = ci.suggest_ignore_directories(str(ws), top_n=1) + assert len(result) <= 1 + assert result[0]["path"] == "large_dir" + + def test_scan_command_execute_suggest_ignore(self, tmp_path): + """The scan command's execute() should short-circuit for --suggest-ignore.""" + from commands.scan import execute + + ws = self._make_workspace(tmp_path) + + class Args: + suggest_ignore = True + incremental = False + plugins = None + max_files = None + + result = execute(Args(), str(ws)) + assert result["status"] == "ok" + assert result["command"] == "scan --suggest-ignore" + assert "suggestions" in result + assert isinstance(result["suggestions"], list) + + +# ─── 6. pathspec fallback ───────────────────────────────────────── + + +class TestPathspecFallback: + """Verify graceful degradation when pathspec is not installed. + + We monkeypatch HAS_PATHSPEC to False and force the fnmatch-based matcher. + """ + + def test_fnmatch_fallback_basic_ignore(self, tmp_path, monkeypatch): + """fnmatch matcher should respect positive patterns.""" + monkeypatch.setattr(ci, "_HAS_PATHSPEC", False) + ci._CACHE.clear() + try: + ws = tmp_path / "ws" + ws.mkdir() + (ws / ".codelensignore").write_text("custom_dir/\n*.log\n") + + assert ci.is_ignored("custom_dir/file.txt", str(ws)) is True + assert ci.is_ignored("debug.log", str(ws)) is True + assert ci.is_ignored("src/app.py", str(ws)) is False + finally: + # Restore real pathspec state + monkeypatch.undo() + ci._CACHE.clear() + + def test_fnmatch_fallback_negation(self, tmp_path, monkeypatch): + """fnmatch matcher should respect ``!``-negation (last match wins).""" + monkeypatch.setattr(ci, "_HAS_PATHSPEC", False) + ci._CACHE.clear() + try: + ws = tmp_path / "ws" + ws.mkdir() + (ws / ".codelensignore").write_text("*.log\n!important.log\n") + + assert ci.is_ignored("debug.log", str(ws)) is True + assert ci.is_ignored("important.log", str(ws)) is False + finally: + monkeypatch.undo() + ci._CACHE.clear() + + def test_fnmatch_fallback_builtin_patterns(self, tmp_path, monkeypatch): + """fnmatch matcher should respect builtin patterns.""" + monkeypatch.setattr(ci, "_HAS_PATHSPEC", False) + ci._CACHE.clear() + try: + ws = tmp_path / "ws" + ws.mkdir() + # Builtin patterns: node_modules/, *.pyc, etc. + assert ci.is_ignored("node_modules/pkg/index.js", str(ws)) is True + assert ci.is_ignored("app.pyc", str(ws)) is True + assert ci.is_ignored("src/app.py", str(ws)) is False + finally: + monkeypatch.undo() + ci._CACHE.clear()