syncscore-companion 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- syncscore_companion-0.1.0/.gitignore +5 -0
- syncscore_companion-0.1.0/PKG-INFO +49 -0
- syncscore_companion-0.1.0/README.md +41 -0
- syncscore_companion-0.1.0/pyproject.toml +24 -0
- syncscore_companion-0.1.0/src/syncscore_companion/__init__.py +1 -0
- syncscore_companion-0.1.0/src/syncscore_companion/__main__.py +4 -0
- syncscore_companion-0.1.0/src/syncscore_companion/cli.py +47 -0
- syncscore_companion-0.1.0/src/syncscore_companion/fs_tools.py +105 -0
- syncscore_companion-0.1.0/src/syncscore_companion/server.py +49 -0
- syncscore_companion-0.1.0/tests/test_cli.py +47 -0
- syncscore_companion-0.1.0/tests/test_fs_tools.py +100 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: syncscore-companion
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local companion MCP server for SyncScore audits — bridges filesystem access to the SyncScore remote MCP server
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: fastmcp>=3.4.0
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# syncscore-companion
|
|
10
|
+
|
|
11
|
+
Local companion MCP server for [SyncScore](https://sync-score.vercel.app) audits.
|
|
12
|
+
|
|
13
|
+
Bridges local filesystem access with SyncScore's remote audit tools in a single
|
|
14
|
+
MCP server, so a customer's AI client (Claude Desktop, Cursor, etc.) only needs
|
|
15
|
+
to configure one `mcpServers` entry to run a full audit — no separate
|
|
16
|
+
filesystem MCP server required.
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
Add to your MCP client config (e.g. `claude_desktop_config.json`):
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"mcpServers": {
|
|
25
|
+
"syncscore": {
|
|
26
|
+
"command": "uvx",
|
|
27
|
+
"args": ["syncscore-companion", "--project-path", "/absolute/path/to/your/project"],
|
|
28
|
+
"env": { "SYNCSCORE_API_KEY": "sk_live_..." }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## What it does
|
|
35
|
+
|
|
36
|
+
- Exposes read-only, scoped filesystem tools (`read_file`, `list_directory`,
|
|
37
|
+
`search_files`) rooted at `--project-path`. Excludes `.git`, `node_modules`,
|
|
38
|
+
`.venv`, dotfiles/secrets, and files over 512KB.
|
|
39
|
+
- Transparently proxies SyncScore's remote audit tools
|
|
40
|
+
(`start_audit`, `report_*`, `finalize_report`) to
|
|
41
|
+
`https://syncscore-api.onrender.com/mcp`, injecting your API key as the
|
|
42
|
+
`Authorization` header automatically.
|
|
43
|
+
|
|
44
|
+
## Local development
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
uv pip install -e ".[dev]"
|
|
48
|
+
pytest tests/
|
|
49
|
+
```
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# syncscore-companion
|
|
2
|
+
|
|
3
|
+
Local companion MCP server for [SyncScore](https://sync-score.vercel.app) audits.
|
|
4
|
+
|
|
5
|
+
Bridges local filesystem access with SyncScore's remote audit tools in a single
|
|
6
|
+
MCP server, so a customer's AI client (Claude Desktop, Cursor, etc.) only needs
|
|
7
|
+
to configure one `mcpServers` entry to run a full audit — no separate
|
|
8
|
+
filesystem MCP server required.
|
|
9
|
+
|
|
10
|
+
## Usage
|
|
11
|
+
|
|
12
|
+
Add to your MCP client config (e.g. `claude_desktop_config.json`):
|
|
13
|
+
|
|
14
|
+
```json
|
|
15
|
+
{
|
|
16
|
+
"mcpServers": {
|
|
17
|
+
"syncscore": {
|
|
18
|
+
"command": "uvx",
|
|
19
|
+
"args": ["syncscore-companion", "--project-path", "/absolute/path/to/your/project"],
|
|
20
|
+
"env": { "SYNCSCORE_API_KEY": "sk_live_..." }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## What it does
|
|
27
|
+
|
|
28
|
+
- Exposes read-only, scoped filesystem tools (`read_file`, `list_directory`,
|
|
29
|
+
`search_files`) rooted at `--project-path`. Excludes `.git`, `node_modules`,
|
|
30
|
+
`.venv`, dotfiles/secrets, and files over 512KB.
|
|
31
|
+
- Transparently proxies SyncScore's remote audit tools
|
|
32
|
+
(`start_audit`, `report_*`, `finalize_report`) to
|
|
33
|
+
`https://syncscore-api.onrender.com/mcp`, injecting your API key as the
|
|
34
|
+
`Authorization` header automatically.
|
|
35
|
+
|
|
36
|
+
## Local development
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
uv pip install -e ".[dev]"
|
|
40
|
+
pytest tests/
|
|
41
|
+
```
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "syncscore-companion"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Local companion MCP server for SyncScore audits — bridges filesystem access to the SyncScore remote MCP server"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"fastmcp>=3.4.0",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[project.scripts]
|
|
12
|
+
syncscore-companion = "syncscore_companion.cli:main"
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["hatchling"]
|
|
16
|
+
build-backend = "hatchling.build"
|
|
17
|
+
|
|
18
|
+
[tool.hatch.build.targets.wheel]
|
|
19
|
+
packages = ["src/syncscore_companion"]
|
|
20
|
+
|
|
21
|
+
[dependency-groups]
|
|
22
|
+
dev = [
|
|
23
|
+
"pytest>=8.0.0",
|
|
24
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from .server import DEFAULT_REMOTE_URL, build_server
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main() -> None:
|
|
11
|
+
parser = argparse.ArgumentParser(prog="syncscore-companion")
|
|
12
|
+
parser.add_argument(
|
|
13
|
+
"--project-path",
|
|
14
|
+
required=True,
|
|
15
|
+
help="Path to the local project to audit",
|
|
16
|
+
)
|
|
17
|
+
parser.add_argument(
|
|
18
|
+
"--api-key",
|
|
19
|
+
default=None,
|
|
20
|
+
help="SyncScore API key (sk_live_...). Falls back to SYNCSCORE_API_KEY env var.",
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"--remote-url",
|
|
24
|
+
default=os.environ.get("SYNCSCORE_REMOTE_URL", DEFAULT_REMOTE_URL),
|
|
25
|
+
help=argparse.SUPPRESS,
|
|
26
|
+
)
|
|
27
|
+
args = parser.parse_args()
|
|
28
|
+
|
|
29
|
+
api_key = args.api_key or os.environ.get("SYNCSCORE_API_KEY", "")
|
|
30
|
+
if not api_key.startswith("sk_live_"):
|
|
31
|
+
sys.stderr.write(
|
|
32
|
+
"Error: a valid SyncScore API key (sk_live_...) is required via "
|
|
33
|
+
"--api-key or the SYNCSCORE_API_KEY environment variable\n"
|
|
34
|
+
)
|
|
35
|
+
sys.exit(1)
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
mcp = build_server(args.project_path, api_key, remote_url=args.remote_url)
|
|
39
|
+
except ValueError as e:
|
|
40
|
+
sys.stderr.write(f"Error: {e}\n")
|
|
41
|
+
sys.exit(1)
|
|
42
|
+
|
|
43
|
+
mcp.run(transport="stdio")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
main()
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
EXCLUDED_DIRS = {
|
|
6
|
+
".git",
|
|
7
|
+
"node_modules",
|
|
8
|
+
".venv",
|
|
9
|
+
"venv",
|
|
10
|
+
"__pycache__",
|
|
11
|
+
".next",
|
|
12
|
+
"dist",
|
|
13
|
+
"build",
|
|
14
|
+
".idea",
|
|
15
|
+
".vscode",
|
|
16
|
+
}
|
|
17
|
+
ALLOWED_DOTFILES = {".env.example", ".env.template"}
|
|
18
|
+
BLOCKED_EXACT_NAMES = {".env"}
|
|
19
|
+
MAX_FILE_SIZE_BYTES = 512 * 1024
|
|
20
|
+
MAX_SEARCH_RESULTS = 200
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def resolve_scoped(root: Path, relative_path: str) -> Path:
|
|
24
|
+
"""Resolve relative_path under root, rejecting traversal and symlink escapes.
|
|
25
|
+
|
|
26
|
+
Path.resolve() dereferences symlinks, so checking the resolved path's
|
|
27
|
+
containment covers both "../" traversal and a symlink pointing outside
|
|
28
|
+
root in one check.
|
|
29
|
+
"""
|
|
30
|
+
candidate = (root / relative_path).resolve()
|
|
31
|
+
if candidate != root and not candidate.is_relative_to(root):
|
|
32
|
+
raise ValueError(f"Path '{relative_path}' escapes the project root")
|
|
33
|
+
return candidate
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def is_excluded(path: Path, root: Path) -> bool:
|
|
37
|
+
rel_parts = path.relative_to(root).parts
|
|
38
|
+
if any(part in EXCLUDED_DIRS for part in rel_parts):
|
|
39
|
+
return True
|
|
40
|
+
if path.name in BLOCKED_EXACT_NAMES:
|
|
41
|
+
return True
|
|
42
|
+
if path.name.startswith(".") and path.name not in ALLOWED_DOTFILES:
|
|
43
|
+
return True
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _looks_binary(data: bytes) -> bool:
|
|
48
|
+
return b"\x00" in data[:8192]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def read_file(root: Path, relative_path: str) -> str:
|
|
52
|
+
path = resolve_scoped(root, relative_path)
|
|
53
|
+
if is_excluded(path, root):
|
|
54
|
+
raise ValueError(f"Path '{relative_path}' is excluded from access")
|
|
55
|
+
if not path.is_file():
|
|
56
|
+
raise ValueError(f"'{relative_path}' is not a file")
|
|
57
|
+
|
|
58
|
+
size = path.stat().st_size
|
|
59
|
+
if size > MAX_FILE_SIZE_BYTES:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
f"File '{relative_path}' exceeds the {MAX_FILE_SIZE_BYTES // 1024}KB limit"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
raw = path.read_bytes()
|
|
65
|
+
if _looks_binary(raw):
|
|
66
|
+
raise ValueError(f"File '{relative_path}' appears to be binary — skip it")
|
|
67
|
+
|
|
68
|
+
return raw.decode("utf-8", errors="replace")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def list_directory(root: Path, relative_path: str = ".") -> list[str]:
|
|
72
|
+
path = resolve_scoped(root, relative_path)
|
|
73
|
+
if not path.is_dir():
|
|
74
|
+
raise ValueError(f"'{relative_path}' is not a directory")
|
|
75
|
+
|
|
76
|
+
entries = []
|
|
77
|
+
for entry in sorted(path.iterdir(), key=lambda e: e.name):
|
|
78
|
+
if is_excluded(entry, root):
|
|
79
|
+
continue
|
|
80
|
+
entries.append(entry.name + "/" if entry.is_dir() else entry.name)
|
|
81
|
+
return entries
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def search_files(root: Path, relative_path: str, pattern: str) -> list[str]:
|
|
85
|
+
base = resolve_scoped(root, relative_path)
|
|
86
|
+
if not base.is_dir():
|
|
87
|
+
raise ValueError(f"'{relative_path}' is not a directory")
|
|
88
|
+
|
|
89
|
+
results: list[str] = []
|
|
90
|
+
for match in base.rglob(pattern):
|
|
91
|
+
if len(results) >= MAX_SEARCH_RESULTS:
|
|
92
|
+
break
|
|
93
|
+
# Skip anything under an excluded/symlinked directory in its path chain.
|
|
94
|
+
try:
|
|
95
|
+
rel = match.relative_to(root)
|
|
96
|
+
except ValueError:
|
|
97
|
+
continue
|
|
98
|
+
if any(part in EXCLUDED_DIRS for part in rel.parts):
|
|
99
|
+
continue
|
|
100
|
+
if any((root / Path(*rel.parts[:i])).is_symlink() for i in range(1, len(rel.parts))):
|
|
101
|
+
continue
|
|
102
|
+
if match.is_file() and not is_excluded(match, root):
|
|
103
|
+
results.append(rel.as_posix())
|
|
104
|
+
|
|
105
|
+
return results
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from fastmcp import FastMCP
|
|
6
|
+
from fastmcp.client import Client
|
|
7
|
+
from fastmcp.client.transports.http import StreamableHttpTransport
|
|
8
|
+
from fastmcp.server import create_proxy
|
|
9
|
+
|
|
10
|
+
from . import fs_tools
|
|
11
|
+
|
|
12
|
+
DEFAULT_REMOTE_URL = "https://syncscore-api.onrender.com/mcp"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_server(project_path: str, api_key: str, remote_url: str = DEFAULT_REMOTE_URL) -> FastMCP:
|
|
16
|
+
root = Path(project_path).expanduser().resolve()
|
|
17
|
+
if not root.is_dir():
|
|
18
|
+
raise ValueError(f"--project-path '{project_path}' is not a directory")
|
|
19
|
+
|
|
20
|
+
mcp = FastMCP("SyncScore Companion")
|
|
21
|
+
|
|
22
|
+
@mcp.tool
|
|
23
|
+
def read_file(relative_path: str) -> str:
|
|
24
|
+
"""Read a text file within the scoped project directory (read-only, max 512KB)."""
|
|
25
|
+
return fs_tools.read_file(root, relative_path)
|
|
26
|
+
|
|
27
|
+
@mcp.tool
|
|
28
|
+
def list_directory(relative_path: str = ".") -> list[str]:
|
|
29
|
+
"""List entries in a directory within the scoped project directory.
|
|
30
|
+
|
|
31
|
+
Excludes .git, node_modules, .venv, and dotfiles/secrets. Directory
|
|
32
|
+
entries are suffixed with '/'.
|
|
33
|
+
"""
|
|
34
|
+
return fs_tools.list_directory(root, relative_path)
|
|
35
|
+
|
|
36
|
+
@mcp.tool
|
|
37
|
+
def search_files(pattern: str, relative_path: str = ".") -> list[str]:
|
|
38
|
+
"""Glob-search for files matching a pattern within the scoped project directory."""
|
|
39
|
+
return fs_tools.search_files(root, relative_path, pattern)
|
|
40
|
+
|
|
41
|
+
remote = Client(
|
|
42
|
+
StreamableHttpTransport(
|
|
43
|
+
remote_url,
|
|
44
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
mcp.mount(create_proxy(remote))
|
|
48
|
+
|
|
49
|
+
return mcp
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from syncscore_companion.cli import main
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_missing_project_path_exits(monkeypatch: pytest.MonkeyPatch):
|
|
12
|
+
monkeypatch.setattr(sys, "argv", ["syncscore-companion", "--api-key", "sk_live_x"])
|
|
13
|
+
with pytest.raises(SystemExit):
|
|
14
|
+
main()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_invalid_api_key_prefix_exits(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
|
|
18
|
+
monkeypatch.setattr(
|
|
19
|
+
sys,
|
|
20
|
+
"argv",
|
|
21
|
+
["syncscore-companion", "--project-path", str(tmp_path), "--api-key", "not-a-real-key"],
|
|
22
|
+
)
|
|
23
|
+
with pytest.raises(SystemExit) as exc_info:
|
|
24
|
+
main()
|
|
25
|
+
assert exc_info.value.code == 1
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_api_key_falls_back_to_env_but_still_requires_prefix(
|
|
29
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
30
|
+
):
|
|
31
|
+
monkeypatch.setattr(sys, "argv", ["syncscore-companion", "--project-path", str(tmp_path)])
|
|
32
|
+
monkeypatch.delenv("SYNCSCORE_API_KEY", raising=False)
|
|
33
|
+
with pytest.raises(SystemExit) as exc_info:
|
|
34
|
+
main()
|
|
35
|
+
assert exc_info.value.code == 1
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_nonexistent_project_path_exits(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
|
|
39
|
+
missing = tmp_path / "does-not-exist"
|
|
40
|
+
monkeypatch.setattr(
|
|
41
|
+
sys,
|
|
42
|
+
"argv",
|
|
43
|
+
["syncscore-companion", "--project-path", str(missing), "--api-key", "sk_live_x"],
|
|
44
|
+
)
|
|
45
|
+
with pytest.raises(SystemExit) as exc_info:
|
|
46
|
+
main()
|
|
47
|
+
assert exc_info.value.code == 1
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from syncscore_companion import fs_tools
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture
|
|
12
|
+
def project(tmp_path: Path) -> Path:
|
|
13
|
+
(tmp_path / "README.md").write_text("hello world")
|
|
14
|
+
(tmp_path / ".env").write_text("SECRET=1")
|
|
15
|
+
(tmp_path / ".env.example").write_text("SECRET=")
|
|
16
|
+
(tmp_path / "src").mkdir()
|
|
17
|
+
(tmp_path / "src" / "main.py").write_text("print('hi')")
|
|
18
|
+
(tmp_path / "node_modules").mkdir()
|
|
19
|
+
(tmp_path / "node_modules" / "pkg.js").write_text("module.exports = {}")
|
|
20
|
+
(tmp_path / ".git").mkdir()
|
|
21
|
+
(tmp_path / ".git" / "config").write_text("[core]")
|
|
22
|
+
return tmp_path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_read_file_within_root(project: Path):
|
|
26
|
+
assert fs_tools.read_file(project, "README.md") == "hello world"
|
|
27
|
+
assert fs_tools.read_file(project, "src/main.py") == "print('hi')"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_read_file_allows_env_example(project: Path):
|
|
31
|
+
assert fs_tools.read_file(project, ".env.example") == "SECRET="
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_read_file_blocks_env(project: Path):
|
|
35
|
+
with pytest.raises(ValueError):
|
|
36
|
+
fs_tools.read_file(project, ".env")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_read_file_blocks_traversal(project: Path):
|
|
40
|
+
with pytest.raises(ValueError):
|
|
41
|
+
fs_tools.read_file(project, "../../../etc/passwd")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_read_file_blocks_excluded_dirs(project: Path):
|
|
45
|
+
with pytest.raises(ValueError):
|
|
46
|
+
fs_tools.read_file(project, "node_modules/pkg.js")
|
|
47
|
+
with pytest.raises(ValueError):
|
|
48
|
+
fs_tools.read_file(project, ".git/config")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_read_file_blocks_symlink_escape(project: Path, tmp_path_factory: pytest.TempPathFactory):
|
|
52
|
+
outside = tmp_path_factory.mktemp("outside")
|
|
53
|
+
secret = outside / "secret.txt"
|
|
54
|
+
secret.write_text("top secret")
|
|
55
|
+
|
|
56
|
+
link = project / "escape"
|
|
57
|
+
os.symlink(outside, link)
|
|
58
|
+
|
|
59
|
+
with pytest.raises(ValueError):
|
|
60
|
+
fs_tools.read_file(project, "escape/secret.txt")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_read_file_size_limit(project: Path):
|
|
64
|
+
big = project / "big.txt"
|
|
65
|
+
big.write_text("x" * (fs_tools.MAX_FILE_SIZE_BYTES + 1))
|
|
66
|
+
with pytest.raises(ValueError):
|
|
67
|
+
fs_tools.read_file(project, "big.txt")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_read_file_binary_rejected(project: Path):
|
|
71
|
+
binary = project / "binary.dat"
|
|
72
|
+
binary.write_bytes(b"\x00\x01\x02\x03")
|
|
73
|
+
with pytest.raises(ValueError):
|
|
74
|
+
fs_tools.read_file(project, "binary.dat")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_list_directory_excludes_dot_dirs(project: Path):
|
|
78
|
+
entries = fs_tools.list_directory(project, ".")
|
|
79
|
+
assert "node_modules/" not in entries
|
|
80
|
+
assert ".git/" not in entries
|
|
81
|
+
assert ".env" not in entries
|
|
82
|
+
assert ".env.example" in entries
|
|
83
|
+
assert "README.md" in entries
|
|
84
|
+
assert "src/" in entries
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_list_directory_blocks_traversal(project: Path):
|
|
88
|
+
with pytest.raises(ValueError):
|
|
89
|
+
fs_tools.list_directory(project, "..")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_search_files_finds_matches(project: Path):
|
|
93
|
+
results = fs_tools.search_files(project, ".", "*.py")
|
|
94
|
+
assert "src/main.py" in results
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_search_files_skips_excluded_dirs(project: Path):
|
|
98
|
+
results = fs_tools.search_files(project, ".", "*")
|
|
99
|
+
assert not any(r.startswith("node_modules/") for r in results)
|
|
100
|
+
assert not any(r.startswith(".git/") for r in results)
|