loosecanvas / tests /test_file_walker.py
Joshua Sundance Bailey
loosecanvas: local AI thought-mapping canvas with a trust-tagged knowledge graph
6d1438c
Raw
History Blame Contribute Delete
6.59 kB
"""M04 — File Tree Walker + Module Name Resolver. Red/green tests from the spec.
Success criteria under test:
- Walks a repo dir and returns one record per ``.py`` file with a resolved dotted
module name (nested package ``pkg`` / ``pkg.sub`` / ``pkg.nested.deep``).
- A namespace dir (no ``__init__.py``) is treated as top-level (its ``.py`` is not
prefixed by the dir name).
- A ``.py`` file that fails to decode (binary / null bytes) is skipped with a warning;
a non-``.py`` binary file is ignored because only ``.py`` files are walked.
- Hidden dirs, ``__pycache__``, ``.venv`` and ``venv`` are skipped.
- SHA-256 is computed per file; one ``SourceFile`` record exists per valid ``.py`` file.
- ``repo_root`` resolving outside ``ALLOWED_BASE_DIR`` is rejected (path-traversal guard).
The fixture tree is created under the repo root via ``tempfile.TemporaryDirectory(dir=...)``
so that it is guaranteed to live inside ``ALLOWED_BASE_DIR`` (which defaults to the parent
of the workspace folder, resolved once at import time and not patchable).
"""
from __future__ import annotations
import hashlib
import logging
from collections.abc import Iterator
from pathlib import Path
import pytest
from loosecanvas.config import ALLOWED_BASE_DIR
from loosecanvas.contracts import SourceSnapshot
from loosecanvas.extractors.file_walker import walk_repo
# Repo root is one parent up from tests/ — guaranteed inside ALLOWED_BASE_DIR.
_REPO_ROOT = Path(__file__).resolve().parents[1]
@pytest.fixture
def repo_tree(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Path]:
"""Build a small fixture tree *inside the repo root* so it stays in ALLOWED_BASE_DIR."""
import tempfile
with tempfile.TemporaryDirectory(dir=_REPO_ROOT) as raw:
root = Path(raw)
# Sanity: the chosen location must satisfy the guard we are about to exercise.
assert root.resolve().is_relative_to(ALLOWED_BASE_DIR)
# A real nested package.
(root / "pkg").mkdir()
(root / "pkg" / "__init__.py").write_text("", encoding="utf-8")
# write_bytes to pin exact content (avoid Windows CRLF translation).
(root / "pkg" / "sub.py").write_bytes(b"x = 1\n")
(root / "pkg" / "nested").mkdir()
(root / "pkg" / "nested" / "__init__.py").write_text("", encoding="utf-8")
(root / "pkg" / "nested" / "deep.py").write_text("y = 2\n", encoding="utf-8")
# A namespace dir (no __init__.py) — its module is top-level.
(root / "nsdir").mkdir()
(root / "nsdir" / "mod.py").write_text("z = 3\n", encoding="utf-8")
# An undecodable .py file — must be skipped with a warning.
(root / "broken.py").write_bytes(b"\x00\x01\x02\xff\xfe")
# A non-.py binary file — ignored because only .py files are walked.
(root / "data.bin").write_bytes(b"\x00\x01\x02")
# Skipped directories.
(root / ".hidden").mkdir()
(root / ".hidden" / "secret.py").write_text("secret = 1\n", encoding="utf-8")
(root / "__pycache__").mkdir()
(root / "__pycache__" / "cached.py").write_text("c = 1\n", encoding="utf-8")
(root / ".venv").mkdir()
(root / ".venv" / "lib.py").write_text("v = 1\n", encoding="utf-8")
(root / "venv").mkdir()
(root / "venv" / "lib2.py").write_text("v = 2\n", encoding="utf-8")
yield root
def test_returns_snapshot_and_module_map(repo_tree: Path) -> None:
snapshot, module_map = walk_repo(repo_tree)
assert isinstance(snapshot, SourceSnapshot)
assert isinstance(module_map, dict)
def test_one_record_per_valid_py_file(repo_tree: Path) -> None:
snapshot, _ = walk_repo(repo_tree)
# Valid .py files: pkg/__init__.py, pkg/sub.py, pkg/nested/__init__.py,
# pkg/nested/deep.py, nsdir/mod.py. (broken.py is skipped; skipped dirs excluded.)
assert set(snapshot.files) == {
"pkg/__init__.py",
"pkg/sub.py",
"pkg/nested/__init__.py",
"pkg/nested/deep.py",
"nsdir/mod.py",
}
def test_nested_package_dotted_names(repo_tree: Path) -> None:
_, module_map = walk_repo(repo_tree)
assert module_map["pkg/__init__.py"] == "pkg"
assert module_map["pkg/sub.py"] == "pkg.sub"
assert module_map["pkg/nested/__init__.py"] == "pkg.nested"
assert module_map["pkg/nested/deep.py"] == "pkg.nested.deep"
def test_namespace_dir_is_top_level(repo_tree: Path) -> None:
_, module_map = walk_repo(repo_tree)
# nsdir has no __init__.py, so mod.py is top-level, not "nsdir.mod".
assert module_map["nsdir/mod.py"] == "mod"
def test_undecodable_py_is_skipped(repo_tree: Path) -> None:
snapshot, module_map = walk_repo(repo_tree)
assert "broken.py" not in snapshot.files
assert "broken.py" not in module_map
def test_non_py_binary_ignored(repo_tree: Path) -> None:
snapshot, _ = walk_repo(repo_tree)
assert "data.bin" not in snapshot.files
def test_skipped_directories(repo_tree: Path) -> None:
snapshot, _ = walk_repo(repo_tree)
paths = set(snapshot.files)
assert not any(p.startswith(".hidden/") for p in paths)
assert not any("__pycache__" in p for p in paths)
assert not any(p.startswith(".venv/") for p in paths)
assert not any(p.startswith("venv/") for p in paths)
def test_sha256_and_content(repo_tree: Path) -> None:
snapshot, _ = walk_repo(repo_tree)
record = snapshot.files["pkg/sub.py"]
raw = (repo_tree / "pkg" / "sub.py").read_bytes()
assert record.sha256 == hashlib.sha256(raw).hexdigest()
assert record.content == "x = 1\n"
assert record.line_count == 1
assert record.source_id == "pkg/sub.py"
assert record.path == "pkg/sub.py"
def test_snapshot_metadata(repo_tree: Path) -> None:
snapshot, _ = walk_repo(repo_tree)
assert snapshot.root_path == str(repo_tree.resolve())
assert snapshot.snapshot_id
def test_path_outside_allowed_base_raises(
caplog: pytest.LogCaptureFixture,
) -> None:
outside = ALLOWED_BASE_DIR.parent
with (
caplog.at_level(logging.WARNING, logger="loosecanvas.extractors.file_walker"),
pytest.raises(ValueError, match="path not allowed") as excinfo,
):
walk_repo(outside)
# The client-visible message must be generic — no resolved path leaked.
assert "ALLOWED_BASE_DIR" not in str(excinfo.value)
assert str(outside.resolve()) not in str(excinfo.value)
# The resolved path must appear in the server-side warning log, not in the exception.
assert any(str(outside.resolve()) in rec.getMessage() for rec in caplog.records)