From 96cdd98e62edc55ed58b4a51d2fdf50b7603af82 Mon Sep 17 00:00:00 2001 From: worldmozara Date: Sat, 18 Jul 2026 01:32:06 +0800 Subject: [PATCH] tools/file: tree skip_dirs noise defaults and path_denylist walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default noise basenames (node_modules, __pycache__, …); skip_dirs=[] disables; apply_path_denylist hides denied children mid-tree. --- src/plyngent/tools/file/tree.py | 83 ++++++++++++++++++++++++++++----- tests/test_tools/test_tree.py | 55 ++++++++++++++++++++++ 2 files changed, 126 insertions(+), 12 deletions(-) diff --git a/src/plyngent/tools/file/tree.py b/src/plyngent/tools/file/tree.py index fd5ec17..f449826 100644 --- a/src/plyngent/tools/file/tree.py +++ b/src/plyngent/tools/file/tree.py @@ -4,9 +4,10 @@ from dataclasses import dataclass from typing import TYPE_CHECKING from plyngent.agent import tool -from plyngent.tools.workspace import WorkspaceError, resolve_path +from plyngent.tools.workspace import WorkspaceError, get_path_denylist, resolve_path if TYPE_CHECKING: + from collections.abc import Sequence from pathlib import Path DEFAULT_MAX_DEPTH = 4 @@ -26,21 +27,58 @@ VCS_DIR_NAMES: frozenset[str] = frozenset( } ) +# Extra noise dirs skipped by default (in addition to VCS / optional hidden). +# Pass skip_dirs=[] to disable this list (VCS still always skipped). +DEFAULT_NOISE_DIR_NAMES: frozenset[str] = frozenset( + { + "node_modules", + "__pycache__", + ".venv", + "venv", + "dist", + "build", + "target", + ".tox", + ".mypy_cache", + ".ruff_cache", + ".pytest_cache", + "coverage", + ".next", + ".nuxt", + ".turbo", + ".cache", + "eggs", + ".eggs", + "htmlcov", + } +) + @dataclass(frozen=True, slots=True) class _TreeLimits: max_depth: int max_entries: int skip_hidden_dirs: bool + skip_basenames: frozenset[str] + apply_path_denylist: bool -def _skip_directory(name: str, *, skip_hidden_dirs: bool) -> bool: - if name in VCS_DIR_NAMES: +def _skip_directory(name: str, *, limits: _TreeLimits) -> bool: + if name in VCS_DIR_NAMES or name in limits.skip_basenames: return True - return bool(skip_hidden_dirs and name.startswith(".")) + return bool(limits.skip_hidden_dirs and name.startswith(".")) -def _list_children(directory: Path, *, skip_hidden_dirs: bool) -> list[Path] | str: +def _path_denied(path: Path) -> bool: + """True when resolved path matches a path_denylist substring.""" + denylist = get_path_denylist() + if not denylist: + return False + resolved_str = str(path).replace("\\", "/") + return any(pattern and pattern.replace("\\", "/") in resolved_str for pattern in denylist) + + +def _list_children(directory: Path, *, limits: _TreeLimits) -> list[Path] | str: try: children = list(directory.iterdir()) except OSError as exc: @@ -51,7 +89,9 @@ def _list_children(directory: Path, *, skip_hidden_dirs: bool) -> list[Path] | s is_dir = child.is_dir() except OSError: continue - if is_dir and _skip_directory(child.name, skip_hidden_dirs=skip_hidden_dirs): + if is_dir and _skip_directory(child.name, limits=limits): + continue + if limits.apply_path_denylist and _path_denied(child): continue visible.append(child) # Directories first, then files; alphabetical within each group. @@ -70,7 +110,7 @@ def _render_tree( if depth >= limits.max_depth: return - children = _list_children(directory, skip_hidden_dirs=limits.skip_hidden_dirs) + children = _list_children(directory, limits=limits) if isinstance(children, str): lines.append(f"{prefix}{children}") return @@ -104,6 +144,13 @@ def _render_tree( lines.append(f"{prefix}└── … ({more} more entries not shown)") +def _resolve_skip_basenames(skip_dirs: Sequence[str] | None) -> frozenset[str]: + """None → default noise set; explicit list (including empty) replaces defaults.""" + if skip_dirs is None: + return DEFAULT_NOISE_DIR_NAMES + return frozenset(name for name in skip_dirs if name) + + @tool def tree( path: str = ".", @@ -111,13 +158,22 @@ def tree( max_depth: int = DEFAULT_MAX_DEPTH, max_entries: int = DEFAULT_MAX_ENTRIES, skip_hidden_dirs: bool = True, + skip_dirs: list[str] | None = None, + apply_path_denylist: bool = True, ) -> str: """Show a directory tree under the workspace. Always skips VCS metadata directories (``.git``, ``.hg``, ``.svn``, …). + By default also skips common noise dirs (``node_modules``, ``__pycache__``, + ``.venv``, ``dist``, …). Pass ``skip_dirs=[]`` to disable the noise list + (VCS still skipped). Pass an explicit list to replace the default noise set. + By default skips other dot-directories (not hidden files). Use ``skip_hidden_dirs=false`` to include them. + ``apply_path_denylist`` (default true) hides entries whose full path matches + the agent ``path_denylist`` policy. + ``max_depth`` limits how deep directories are expanded (1 = origin + children). ``max_entries`` caps how many entries are listed per directory. """ @@ -135,15 +191,18 @@ def tree( root_label = path.rstrip("/\\") or "." lines = [f"{root_label}/"] + limits = _TreeLimits( + max_depth=max_depth, + max_entries=max_entries, + skip_hidden_dirs=skip_hidden_dirs, + skip_basenames=_resolve_skip_basenames(skip_dirs), + apply_path_denylist=apply_path_denylist, + ) _render_tree( origin, prefix="", depth=0, - limits=_TreeLimits( - max_depth=max_depth, - max_entries=max_entries, - skip_hidden_dirs=skip_hidden_dirs, - ), + limits=limits, lines=lines, ) return "\n".join(lines) diff --git a/tests/test_tools/test_tree.py b/tests/test_tools/test_tree.py index 9cc4e1b..2319fc0 100644 --- a/tests/test_tools/test_tree.py +++ b/tests/test_tools/test_tree.py @@ -83,3 +83,58 @@ def test_tree_invalid_limits(workspace: object) -> None: del workspace assert "max_depth" in call_sync(tree, ".", max_depth=0) assert "max_entries" in call_sync(tree, ".", max_entries=0) + + +def test_tree_default_noise_dirs(workspace: object) -> None: + assert isinstance(workspace, Path) + (workspace / "src").mkdir() + _ = (workspace / "src" / "app.py").write_text("x", encoding="utf-8") + (workspace / "node_modules").mkdir() + _ = (workspace / "node_modules" / "pkg.js").write_text("x", encoding="utf-8") + (workspace / "__pycache__").mkdir() + _ = (workspace / "__pycache__" / "x.pyc").write_text("x", encoding="utf-8") + out = call_sync(tree, ".") + assert "src/" in out + assert "app.py" in out + assert "node_modules" not in out + assert "__pycache__" not in out + + +def test_tree_skip_dirs_empty_shows_noise(workspace: object) -> None: + assert isinstance(workspace, Path) + (workspace / "node_modules").mkdir() + _ = (workspace / "node_modules" / "pkg.js").write_text("x", encoding="utf-8") + out = call_sync(tree, ".", skip_dirs=[]) + assert "node_modules/" in out + assert "pkg.js" in out + + +def test_tree_skip_dirs_custom(workspace: object) -> None: + assert isinstance(workspace, Path) + (workspace / "keep_me").mkdir() + _ = (workspace / "keep_me" / "a.txt").write_text("x", encoding="utf-8") + (workspace / "drop_me").mkdir() + _ = (workspace / "drop_me" / "b.txt").write_text("x", encoding="utf-8") + # Custom list replaces default noise set (node_modules not in list → would show if present). + out = call_sync(tree, ".", skip_dirs=["drop_me"]) + assert "keep_me/" in out + assert "drop_me" not in out + + +def test_tree_path_denylist_walk(workspace: object) -> None: + from plyngent.tools.workspace import set_path_denylist + + assert isinstance(workspace, Path) + (workspace / "ok").mkdir() + _ = (workspace / "ok" / "a.txt").write_text("x", encoding="utf-8") + (workspace / "secrets").mkdir() + _ = (workspace / "secrets" / "key.txt").write_text("x", encoding="utf-8") + set_path_denylist(["/secrets"]) + try: + out = call_sync(tree, ".") + assert "ok/" in out + assert "secrets" not in out + out2 = call_sync(tree, ".", apply_path_denylist=False, skip_dirs=[]) + assert "secrets/" in out2 + finally: + set_path_denylist(None)