codebread 1.0.3__tar.gz → 1.0.4__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.
- {codebread-1.0.3/codebread.egg-info → codebread-1.0.4}/PKG-INFO +30 -5
- codebread-1.0.3/PKG-INFO → codebread-1.0.4/README.md +27 -18
- codebread-1.0.4/codebread/__init__.py +36 -0
- {codebread-1.0.3 → codebread-1.0.4}/codebread/server.py +47 -21
- codebread-1.0.3/README.md → codebread-1.0.4/codebread.egg-info/PKG-INFO +43 -4
- {codebread-1.0.3 → codebread-1.0.4}/codebread.egg-info/SOURCES.txt +10 -1
- {codebread-1.0.3 → codebread-1.0.4}/codebread.egg-info/requires.txt +3 -0
- {codebread-1.0.3 → codebread-1.0.4}/pyproject.toml +5 -1
- codebread-1.0.4/tests/test_analyzer.py +71 -0
- codebread-1.0.4/tests/test_config_parser.py +36 -0
- codebread-1.0.4/tests/test_connections.py +96 -0
- codebread-1.0.4/tests/test_generic_parser.py +104 -0
- codebread-1.0.4/tests/test_javascript_parser.py +82 -0
- codebread-1.0.4/tests/test_python_parser.py +59 -0
- codebread-1.0.4/tests/test_scanner.py +84 -0
- codebread-1.0.4/tests/test_server.py +83 -0
- codebread-1.0.4/tests/test_version.py +14 -0
- codebread-1.0.3/codebread/__init__.py +0 -3
- {codebread-1.0.3 → codebread-1.0.4}/LICENSE +0 -0
- {codebread-1.0.3 → codebread-1.0.4}/codebread/analyzer.py +0 -0
- {codebread-1.0.3 → codebread-1.0.4}/codebread/classifier.py +0 -0
- {codebread-1.0.3 → codebread-1.0.4}/codebread/cli.py +0 -0
- {codebread-1.0.3 → codebread-1.0.4}/codebread/connections.py +0 -0
- {codebread-1.0.3 → codebread-1.0.4}/codebread/diff.py +0 -0
- {codebread-1.0.3 → codebread-1.0.4}/codebread/export.py +0 -0
- {codebread-1.0.3 → codebread-1.0.4}/codebread/languages.py +0 -0
- {codebread-1.0.3 → codebread-1.0.4}/codebread/models.py +0 -0
- {codebread-1.0.3 → codebread-1.0.4}/codebread/parsers/__init__.py +0 -0
- {codebread-1.0.3 → codebread-1.0.4}/codebread/parsers/generic_parser.py +0 -0
- {codebread-1.0.3 → codebread-1.0.4}/codebread/parsers/javascript_parser.py +0 -0
- {codebread-1.0.3 → codebread-1.0.4}/codebread/parsers/python_parser.py +0 -0
- {codebread-1.0.3 → codebread-1.0.4}/codebread/scanner.py +0 -0
- {codebread-1.0.3 → codebread-1.0.4}/codebread/web/app.js +0 -0
- {codebread-1.0.3 → codebread-1.0.4}/codebread/web/index.html +0 -0
- {codebread-1.0.3 → codebread-1.0.4}/codebread/web/style.css +0 -0
- {codebread-1.0.3 → codebread-1.0.4}/codebread.egg-info/dependency_links.txt +0 -0
- {codebread-1.0.3 → codebread-1.0.4}/codebread.egg-info/entry_points.txt +0 -0
- {codebread-1.0.3 → codebread-1.0.4}/codebread.egg-info/top_level.txt +0 -0
- {codebread-1.0.3 → codebread-1.0.4}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codebread
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.4
|
|
4
4
|
Summary: Interactive codebase analyzer & visualizer — slice open a project and see how it's wired together.
|
|
5
5
|
Author: Danial Irsyad
|
|
6
6
|
License: MIT
|
|
@@ -10,6 +10,8 @@ Description-Content-Type: text/markdown
|
|
|
10
10
|
License-File: LICENSE
|
|
11
11
|
Provides-Extra: full
|
|
12
12
|
Requires-Dist: pathspec>=0.11; extra == "full"
|
|
13
|
+
Provides-Extra: test
|
|
14
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
13
15
|
Dynamic: license-file
|
|
14
16
|
|
|
15
17
|
<p align="center">
|
|
@@ -22,7 +24,8 @@ Dynamic: license-file
|
|
|
22
24
|
</p>
|
|
23
25
|
|
|
24
26
|
<p align="center">
|
|
25
|
-
<img alt="
|
|
27
|
+
<a href="https://pypi.org/project/codebread/"><img alt="PyPI" src="https://img.shields.io/pypi/v/codebread?color=2dd4bf&label=version"></a>
|
|
28
|
+
<a href="https://github.com/honow48-tech/CodeBread/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/honow48-tech/CodeBread/actions/workflows/ci.yml/badge.svg"></a>
|
|
26
29
|
<img alt="python" src="https://img.shields.io/badge/python-3.9%2B-0284c7">
|
|
27
30
|
<img alt="deps" src="https://img.shields.io/badge/required%20dependencies-none-2dd4bf">
|
|
28
31
|
<img alt="license" src="https://img.shields.io/badge/license-MIT-8b5cf6">
|
|
@@ -238,12 +241,30 @@ codebread/
|
|
|
238
241
|
server.py ← stdlib local web server
|
|
239
242
|
export.py ← JSON + single-file HTML export
|
|
240
243
|
web/ ← the UI (vanilla JS + SVG, zero dependencies)
|
|
244
|
+
tests/ ← pytest suite + per-language fixture corpus
|
|
241
245
|
assets/ ← logo (.svg source + .png for the README —
|
|
242
246
|
GitHub blocks same-repo SVG <img> embeds)
|
|
243
247
|
```
|
|
244
248
|
|
|
245
249
|
---
|
|
246
250
|
|
|
251
|
+
## Testing
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
pip install -e ".[full,test]"
|
|
255
|
+
pytest -q
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
The suite covers every parser (a small fixture file per language under
|
|
259
|
+
`tests/fixtures/`), the connection/graph builder (call resolution,
|
|
260
|
+
API↔route matching, DB edges, orphan + cycle detection), the scanner
|
|
261
|
+
(`.gitignore` handling, language detection), the local server (including a
|
|
262
|
+
regression test for the path-traversal fix), and an end-to-end analyze
|
|
263
|
+
pass — including a regression test that secrets in scanned `.env` files
|
|
264
|
+
never leak into an exported graph. CI (`.github/workflows/ci.yml`) runs it
|
|
265
|
+
on every push/PR across the minimum and latest supported Python versions,
|
|
266
|
+
and the PyPI release workflow won't publish if it fails.
|
|
267
|
+
|
|
247
268
|
## Roadmap
|
|
248
269
|
|
|
249
270
|
Already done as of v1.0:
|
|
@@ -251,13 +272,13 @@ Already done as of v1.0:
|
|
|
251
272
|
- [x] Orphaned-function and circular-dependency detection
|
|
252
273
|
- [x] Diff mode between two scans
|
|
253
274
|
- [x] Focus mode, orbit layout, right-click actions, minimap, breadcrumb
|
|
275
|
+
- [x] Automated test suite + CI
|
|
254
276
|
|
|
255
277
|
Not yet, but planned:
|
|
256
278
|
|
|
257
279
|
- [ ] "Explain this function" — a plain-language AI summary button
|
|
258
280
|
- [ ] More precise multi-language parsing (currently structural regex for
|
|
259
281
|
everything but Python)
|
|
260
|
-
- [ ] An automated test suite
|
|
261
282
|
|
|
262
283
|
Have an idea? Open an issue.
|
|
263
284
|
|
|
@@ -271,8 +292,12 @@ but contributions are genuinely welcome:
|
|
|
271
292
|
or UI polish. Keep the zero-dependency philosophy: the core tool should
|
|
272
293
|
keep running on nothing but the Python standard library, and the UI
|
|
273
294
|
should keep running on vanilla JS with no build step.
|
|
274
|
-
-
|
|
275
|
-
|
|
295
|
+
- Run `pytest -q` before opening a PR (see [Testing](#testing) above); add
|
|
296
|
+
a fixture + test case under `tests/` for new parser behavior where you
|
|
297
|
+
can.
|
|
298
|
+
|
|
299
|
+
Security issue instead? See [SECURITY.md](SECURITY.md) rather than a
|
|
300
|
+
public issue.
|
|
276
301
|
|
|
277
302
|
## License
|
|
278
303
|
|
|
@@ -1,17 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: codebread
|
|
3
|
-
Version: 1.0.3
|
|
4
|
-
Summary: Interactive codebase analyzer & visualizer — slice open a project and see how it's wired together.
|
|
5
|
-
Author: Danial Irsyad
|
|
6
|
-
License: MIT
|
|
7
|
-
Keywords: codebase,analyzer,visualizer,call-graph,architecture
|
|
8
|
-
Requires-Python: >=3.9
|
|
9
|
-
Description-Content-Type: text/markdown
|
|
10
|
-
License-File: LICENSE
|
|
11
|
-
Provides-Extra: full
|
|
12
|
-
Requires-Dist: pathspec>=0.11; extra == "full"
|
|
13
|
-
Dynamic: license-file
|
|
14
|
-
|
|
15
1
|
<p align="center">
|
|
16
2
|
<img src="https://cdn.jsdelivr.net/gh/honow48-tech/CodeBread@main/assets/logo-wordmark.png" alt="CodeBread" width="360">
|
|
17
3
|
</p>
|
|
@@ -22,7 +8,8 @@ Dynamic: license-file
|
|
|
22
8
|
</p>
|
|
23
9
|
|
|
24
10
|
<p align="center">
|
|
25
|
-
<img alt="
|
|
11
|
+
<a href="https://pypi.org/project/codebread/"><img alt="PyPI" src="https://img.shields.io/pypi/v/codebread?color=2dd4bf&label=version"></a>
|
|
12
|
+
<a href="https://github.com/honow48-tech/CodeBread/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/honow48-tech/CodeBread/actions/workflows/ci.yml/badge.svg"></a>
|
|
26
13
|
<img alt="python" src="https://img.shields.io/badge/python-3.9%2B-0284c7">
|
|
27
14
|
<img alt="deps" src="https://img.shields.io/badge/required%20dependencies-none-2dd4bf">
|
|
28
15
|
<img alt="license" src="https://img.shields.io/badge/license-MIT-8b5cf6">
|
|
@@ -238,12 +225,30 @@ codebread/
|
|
|
238
225
|
server.py ← stdlib local web server
|
|
239
226
|
export.py ← JSON + single-file HTML export
|
|
240
227
|
web/ ← the UI (vanilla JS + SVG, zero dependencies)
|
|
228
|
+
tests/ ← pytest suite + per-language fixture corpus
|
|
241
229
|
assets/ ← logo (.svg source + .png for the README —
|
|
242
230
|
GitHub blocks same-repo SVG <img> embeds)
|
|
243
231
|
```
|
|
244
232
|
|
|
245
233
|
---
|
|
246
234
|
|
|
235
|
+
## Testing
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
pip install -e ".[full,test]"
|
|
239
|
+
pytest -q
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
The suite covers every parser (a small fixture file per language under
|
|
243
|
+
`tests/fixtures/`), the connection/graph builder (call resolution,
|
|
244
|
+
API↔route matching, DB edges, orphan + cycle detection), the scanner
|
|
245
|
+
(`.gitignore` handling, language detection), the local server (including a
|
|
246
|
+
regression test for the path-traversal fix), and an end-to-end analyze
|
|
247
|
+
pass — including a regression test that secrets in scanned `.env` files
|
|
248
|
+
never leak into an exported graph. CI (`.github/workflows/ci.yml`) runs it
|
|
249
|
+
on every push/PR across the minimum and latest supported Python versions,
|
|
250
|
+
and the PyPI release workflow won't publish if it fails.
|
|
251
|
+
|
|
247
252
|
## Roadmap
|
|
248
253
|
|
|
249
254
|
Already done as of v1.0:
|
|
@@ -251,13 +256,13 @@ Already done as of v1.0:
|
|
|
251
256
|
- [x] Orphaned-function and circular-dependency detection
|
|
252
257
|
- [x] Diff mode between two scans
|
|
253
258
|
- [x] Focus mode, orbit layout, right-click actions, minimap, breadcrumb
|
|
259
|
+
- [x] Automated test suite + CI
|
|
254
260
|
|
|
255
261
|
Not yet, but planned:
|
|
256
262
|
|
|
257
263
|
- [ ] "Explain this function" — a plain-language AI summary button
|
|
258
264
|
- [ ] More precise multi-language parsing (currently structural regex for
|
|
259
265
|
everything but Python)
|
|
260
|
-
- [ ] An automated test suite
|
|
261
266
|
|
|
262
267
|
Have an idea? Open an issue.
|
|
263
268
|
|
|
@@ -271,8 +276,12 @@ but contributions are genuinely welcome:
|
|
|
271
276
|
or UI polish. Keep the zero-dependency philosophy: the core tool should
|
|
272
277
|
keep running on nothing but the Python standard library, and the UI
|
|
273
278
|
should keep running on vanilla JS with no build step.
|
|
274
|
-
-
|
|
275
|
-
|
|
279
|
+
- Run `pytest -q` before opening a PR (see [Testing](#testing) above); add
|
|
280
|
+
a fixture + test case under `tests/` for new parser behavior where you
|
|
281
|
+
can.
|
|
282
|
+
|
|
283
|
+
Security issue instead? See [SECURITY.md](SECURITY.md) rather than a
|
|
284
|
+
public issue.
|
|
276
285
|
|
|
277
286
|
## License
|
|
278
287
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""CodeBread — slice open a codebase and see its internal structure."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _read_version() -> str:
|
|
8
|
+
"""Single source of truth: pyproject.toml's [project] version.
|
|
9
|
+
|
|
10
|
+
Installed packages get it from the wheel's own metadata (always in
|
|
11
|
+
sync with what was actually published). Running from a source
|
|
12
|
+
checkout without installing falls back to reading pyproject.toml
|
|
13
|
+
directly — no parser dependency needed for one `version = "..."`
|
|
14
|
+
line, keeping the zero-dependency promise intact.
|
|
15
|
+
"""
|
|
16
|
+
try:
|
|
17
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
18
|
+
try:
|
|
19
|
+
return version("codebread")
|
|
20
|
+
except PackageNotFoundError:
|
|
21
|
+
pass
|
|
22
|
+
except ImportError:
|
|
23
|
+
pass
|
|
24
|
+
try:
|
|
25
|
+
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
26
|
+
with open(os.path.join(root, "pyproject.toml"), encoding="utf-8") as f:
|
|
27
|
+
for line in f:
|
|
28
|
+
line = line.strip()
|
|
29
|
+
if line.startswith("version"):
|
|
30
|
+
return line.split("=", 1)[1].strip().strip("\"'")
|
|
31
|
+
except OSError:
|
|
32
|
+
pass
|
|
33
|
+
return "0.0.0+unknown"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
__version__ = _read_version()
|
|
@@ -7,7 +7,7 @@ import socket
|
|
|
7
7
|
import threading
|
|
8
8
|
import webbrowser
|
|
9
9
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
10
|
-
from typing import Dict
|
|
10
|
+
from typing import Dict, Optional
|
|
11
11
|
|
|
12
12
|
WEB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "web")
|
|
13
13
|
WEB_ROOT = os.path.realpath(WEB_DIR)
|
|
@@ -21,7 +21,30 @@ MIME = {".html": "text/html; charset=utf-8",
|
|
|
21
21
|
".ico": "image/x-icon"}
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
def
|
|
24
|
+
def safe_web_path(rel: str) -> Optional[str]:
|
|
25
|
+
"""Resolve a request path against WEB_DIR and return it only if the
|
|
26
|
+
*resolved* result is still inside WEB_DIR, else None.
|
|
27
|
+
|
|
28
|
+
String-prefix checks on `rel` alone aren't enough: on Windows,
|
|
29
|
+
os.path.join(WEB_DIR, "C:/some/file") silently discards WEB_DIR because
|
|
30
|
+
the second argument is drive-absolute, which would otherwise let a
|
|
31
|
+
request read any file on disk. Kept as a standalone function so the
|
|
32
|
+
traversal guard has a direct regression test, not just cli/browser use.
|
|
33
|
+
"""
|
|
34
|
+
full = os.path.realpath(os.path.join(WEB_DIR, rel))
|
|
35
|
+
try:
|
|
36
|
+
inside = os.path.commonpath([full, WEB_ROOT]) == WEB_ROOT
|
|
37
|
+
except ValueError:
|
|
38
|
+
inside = False # e.g. different drives on Windows
|
|
39
|
+
return full if inside else None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _free_port(preferred: int) -> Optional[int]:
|
|
43
|
+
"""Find a bindable port near `preferred`, or None if none was free.
|
|
44
|
+
`preferred == 0` means "let the OS pick" and always succeeds — must
|
|
45
|
+
return that as a distinct value from "no port found", since 0 is a
|
|
46
|
+
falsy int and `if not port:` would otherwise treat a successful
|
|
47
|
+
OS-assigned bind as failure."""
|
|
25
48
|
for port in [preferred] + list(range(preferred + 1, preferred + 30)):
|
|
26
49
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
27
50
|
try:
|
|
@@ -29,12 +52,10 @@ def _free_port(preferred: int) -> int:
|
|
|
29
52
|
return port
|
|
30
53
|
except OSError:
|
|
31
54
|
continue
|
|
32
|
-
return
|
|
55
|
+
return None
|
|
33
56
|
|
|
34
57
|
|
|
35
|
-
def
|
|
36
|
-
data_bytes = json.dumps(graph, ensure_ascii=False).encode("utf-8")
|
|
37
|
-
|
|
58
|
+
def _make_handler(data_bytes: bytes):
|
|
38
59
|
class Handler(BaseHTTPRequestHandler):
|
|
39
60
|
def do_GET(self):
|
|
40
61
|
path = self.path.split("?")[0]
|
|
@@ -46,17 +67,8 @@ def serve(graph: Dict, port: int = 8137, open_browser: bool = True) -> None:
|
|
|
46
67
|
self._file(path.lstrip("/"))
|
|
47
68
|
|
|
48
69
|
def _file(self, rel: str):
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
# Windows, os.path.join(WEB_DIR, "C:/some/file") silently
|
|
52
|
-
# discards WEB_DIR because the second argument is drive-absolute,
|
|
53
|
-
# which would otherwise let a request read any file on disk.
|
|
54
|
-
full = os.path.realpath(os.path.join(WEB_DIR, rel))
|
|
55
|
-
try:
|
|
56
|
-
inside = os.path.commonpath([full, WEB_ROOT]) == WEB_ROOT
|
|
57
|
-
except ValueError:
|
|
58
|
-
inside = False # e.g. different drives on Windows
|
|
59
|
-
if not inside:
|
|
70
|
+
full = safe_web_path(rel)
|
|
71
|
+
if full is None:
|
|
60
72
|
self.send_error(403)
|
|
61
73
|
return
|
|
62
74
|
if not os.path.isfile(full):
|
|
@@ -78,12 +90,26 @@ def serve(graph: Dict, port: int = 8137, open_browser: bool = True) -> None:
|
|
|
78
90
|
def log_message(self, fmt, *args): # keep the console clean
|
|
79
91
|
pass
|
|
80
92
|
|
|
81
|
-
|
|
82
|
-
|
|
93
|
+
return Handler
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def build_server(graph: Dict, port: int = 8137) -> Optional[ThreadingHTTPServer]:
|
|
97
|
+
"""Bind a ready-to-serve ThreadingHTTPServer, or None if no port was
|
|
98
|
+
free. Split out from `serve()` so tests (and other embedders) can start
|
|
99
|
+
and cleanly `.shutdown()` a server instead of blocking forever."""
|
|
100
|
+
data_bytes = json.dumps(graph, ensure_ascii=False).encode("utf-8")
|
|
101
|
+
bound_port = _free_port(port)
|
|
102
|
+
if bound_port is None:
|
|
103
|
+
return None
|
|
104
|
+
return ThreadingHTTPServer(("127.0.0.1", bound_port), _make_handler(data_bytes))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def serve(graph: Dict, port: int = 8137, open_browser: bool = True) -> None:
|
|
108
|
+
server = build_server(graph, port)
|
|
109
|
+
if server is None:
|
|
83
110
|
print("[codebread] No free port found near 8137 — aborting serve.")
|
|
84
111
|
return
|
|
85
|
-
|
|
86
|
-
url = f"http://127.0.0.1:{port}/"
|
|
112
|
+
url = f"http://127.0.0.1:{server.server_port}/"
|
|
87
113
|
print(f"[codebread] Serving at {url} (Ctrl+C to stop)")
|
|
88
114
|
if open_browser:
|
|
89
115
|
threading.Timer(0.4, lambda: webbrowser.open(url)).start()
|
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: codebread
|
|
3
|
+
Version: 1.0.4
|
|
4
|
+
Summary: Interactive codebase analyzer & visualizer — slice open a project and see how it's wired together.
|
|
5
|
+
Author: Danial Irsyad
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: codebase,analyzer,visualizer,call-graph,architecture
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Provides-Extra: full
|
|
12
|
+
Requires-Dist: pathspec>=0.11; extra == "full"
|
|
13
|
+
Provides-Extra: test
|
|
14
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
1
17
|
<p align="center">
|
|
2
18
|
<img src="https://cdn.jsdelivr.net/gh/honow48-tech/CodeBread@main/assets/logo-wordmark.png" alt="CodeBread" width="360">
|
|
3
19
|
</p>
|
|
@@ -8,7 +24,8 @@
|
|
|
8
24
|
</p>
|
|
9
25
|
|
|
10
26
|
<p align="center">
|
|
11
|
-
<img alt="
|
|
27
|
+
<a href="https://pypi.org/project/codebread/"><img alt="PyPI" src="https://img.shields.io/pypi/v/codebread?color=2dd4bf&label=version"></a>
|
|
28
|
+
<a href="https://github.com/honow48-tech/CodeBread/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/honow48-tech/CodeBread/actions/workflows/ci.yml/badge.svg"></a>
|
|
12
29
|
<img alt="python" src="https://img.shields.io/badge/python-3.9%2B-0284c7">
|
|
13
30
|
<img alt="deps" src="https://img.shields.io/badge/required%20dependencies-none-2dd4bf">
|
|
14
31
|
<img alt="license" src="https://img.shields.io/badge/license-MIT-8b5cf6">
|
|
@@ -224,12 +241,30 @@ codebread/
|
|
|
224
241
|
server.py ← stdlib local web server
|
|
225
242
|
export.py ← JSON + single-file HTML export
|
|
226
243
|
web/ ← the UI (vanilla JS + SVG, zero dependencies)
|
|
244
|
+
tests/ ← pytest suite + per-language fixture corpus
|
|
227
245
|
assets/ ← logo (.svg source + .png for the README —
|
|
228
246
|
GitHub blocks same-repo SVG <img> embeds)
|
|
229
247
|
```
|
|
230
248
|
|
|
231
249
|
---
|
|
232
250
|
|
|
251
|
+
## Testing
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
pip install -e ".[full,test]"
|
|
255
|
+
pytest -q
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
The suite covers every parser (a small fixture file per language under
|
|
259
|
+
`tests/fixtures/`), the connection/graph builder (call resolution,
|
|
260
|
+
API↔route matching, DB edges, orphan + cycle detection), the scanner
|
|
261
|
+
(`.gitignore` handling, language detection), the local server (including a
|
|
262
|
+
regression test for the path-traversal fix), and an end-to-end analyze
|
|
263
|
+
pass — including a regression test that secrets in scanned `.env` files
|
|
264
|
+
never leak into an exported graph. CI (`.github/workflows/ci.yml`) runs it
|
|
265
|
+
on every push/PR across the minimum and latest supported Python versions,
|
|
266
|
+
and the PyPI release workflow won't publish if it fails.
|
|
267
|
+
|
|
233
268
|
## Roadmap
|
|
234
269
|
|
|
235
270
|
Already done as of v1.0:
|
|
@@ -237,13 +272,13 @@ Already done as of v1.0:
|
|
|
237
272
|
- [x] Orphaned-function and circular-dependency detection
|
|
238
273
|
- [x] Diff mode between two scans
|
|
239
274
|
- [x] Focus mode, orbit layout, right-click actions, minimap, breadcrumb
|
|
275
|
+
- [x] Automated test suite + CI
|
|
240
276
|
|
|
241
277
|
Not yet, but planned:
|
|
242
278
|
|
|
243
279
|
- [ ] "Explain this function" — a plain-language AI summary button
|
|
244
280
|
- [ ] More precise multi-language parsing (currently structural regex for
|
|
245
281
|
everything but Python)
|
|
246
|
-
- [ ] An automated test suite
|
|
247
282
|
|
|
248
283
|
Have an idea? Open an issue.
|
|
249
284
|
|
|
@@ -257,8 +292,12 @@ but contributions are genuinely welcome:
|
|
|
257
292
|
or UI polish. Keep the zero-dependency philosophy: the core tool should
|
|
258
293
|
keep running on nothing but the Python standard library, and the UI
|
|
259
294
|
should keep running on vanilla JS with no build step.
|
|
260
|
-
-
|
|
261
|
-
|
|
295
|
+
- Run `pytest -q` before opening a PR (see [Testing](#testing) above); add
|
|
296
|
+
a fixture + test case under `tests/` for new parser behavior where you
|
|
297
|
+
can.
|
|
298
|
+
|
|
299
|
+
Security issue instead? See [SECURITY.md](SECURITY.md) rather than a
|
|
300
|
+
public issue.
|
|
262
301
|
|
|
263
302
|
## License
|
|
264
303
|
|
|
@@ -24,4 +24,13 @@ codebread/parsers/javascript_parser.py
|
|
|
24
24
|
codebread/parsers/python_parser.py
|
|
25
25
|
codebread/web/app.js
|
|
26
26
|
codebread/web/index.html
|
|
27
|
-
codebread/web/style.css
|
|
27
|
+
codebread/web/style.css
|
|
28
|
+
tests/test_analyzer.py
|
|
29
|
+
tests/test_config_parser.py
|
|
30
|
+
tests/test_connections.py
|
|
31
|
+
tests/test_generic_parser.py
|
|
32
|
+
tests/test_javascript_parser.py
|
|
33
|
+
tests/test_python_parser.py
|
|
34
|
+
tests/test_scanner.py
|
|
35
|
+
tests/test_server.py
|
|
36
|
+
tests/test_version.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "codebread"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.4"
|
|
8
8
|
description = "Interactive codebase analyzer & visualizer — slice open a project and see how it's wired together."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -15,6 +15,7 @@ dependencies = []
|
|
|
15
15
|
|
|
16
16
|
[project.optional-dependencies]
|
|
17
17
|
full = ["pathspec>=0.11"]
|
|
18
|
+
test = ["pytest>=7"]
|
|
18
19
|
|
|
19
20
|
[project.scripts]
|
|
20
21
|
codebread = "codebread.cli:main"
|
|
@@ -24,3 +25,6 @@ include = ["codebread*"]
|
|
|
24
25
|
|
|
25
26
|
[tool.setuptools.package-data]
|
|
26
27
|
codebread = ["web/*"]
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from codebread.analyzer import analyze
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _write(root, rel_path, content):
|
|
8
|
+
full = os.path.join(root, rel_path)
|
|
9
|
+
os.makedirs(os.path.dirname(full), exist_ok=True)
|
|
10
|
+
with open(full, "w", encoding="utf-8") as f:
|
|
11
|
+
f.write(content)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_analyze_end_to_end_small_project(tmp_path):
|
|
15
|
+
root = str(tmp_path)
|
|
16
|
+
_write(root, "app.py", (
|
|
17
|
+
"from flask import Flask\n"
|
|
18
|
+
"app = Flask(__name__)\n\n"
|
|
19
|
+
"@app.route('/users')\n"
|
|
20
|
+
"def list_users():\n"
|
|
21
|
+
" return get_users()\n\n"
|
|
22
|
+
"def get_users():\n"
|
|
23
|
+
" return []\n"
|
|
24
|
+
))
|
|
25
|
+
_write(root, "frontend/main.js", (
|
|
26
|
+
"async function loadUsers() {\n"
|
|
27
|
+
" const res = await fetch('/users');\n"
|
|
28
|
+
" return res.json();\n"
|
|
29
|
+
"}\n"
|
|
30
|
+
))
|
|
31
|
+
|
|
32
|
+
graph = analyze(root)
|
|
33
|
+
|
|
34
|
+
assert graph["stats"]["files"] == 2
|
|
35
|
+
assert graph["stats"]["functions"] >= 3
|
|
36
|
+
assert graph["meta"]["name"] == os.path.basename(root)
|
|
37
|
+
# the graph must be JSON-serializable end to end (what --json/server ship)
|
|
38
|
+
json.dumps(graph)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_analyze_never_leaks_env_secrets_into_exported_graph(tmp_path):
|
|
42
|
+
root = str(tmp_path)
|
|
43
|
+
_write(root, ".env", (
|
|
44
|
+
"DATABASE_URL=postgres://admin:hunter2@db.example.com:5432/prod\n"
|
|
45
|
+
"API_KEY=sk-abcdef123456\n"
|
|
46
|
+
"DEBUG=true\n"
|
|
47
|
+
))
|
|
48
|
+
_write(root, "app.py", "def hello():\n return 'hi'\n")
|
|
49
|
+
|
|
50
|
+
graph = analyze(root)
|
|
51
|
+
dumped = json.dumps(graph)
|
|
52
|
+
|
|
53
|
+
assert "hunter2" not in dumped
|
|
54
|
+
assert "sk-abcdef123456" not in dumped
|
|
55
|
+
# non-secret content should still be visible (regression against
|
|
56
|
+
# over-redaction hiding everything)
|
|
57
|
+
assert "DEBUG=true" in dumped
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_analyze_handles_syntax_errors_without_crashing(tmp_path):
|
|
61
|
+
root = str(tmp_path)
|
|
62
|
+
_write(root, "broken.py", "def broken(:\n pass\n")
|
|
63
|
+
|
|
64
|
+
graph = analyze(root)
|
|
65
|
+
assert graph["stats"]["warnings"] >= 1
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_analyze_empty_directory(tmp_path):
|
|
69
|
+
graph = analyze(str(tmp_path))
|
|
70
|
+
assert graph["stats"]["files"] == 0
|
|
71
|
+
assert graph["nodes"] == []
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from codebread.models import FileInfo
|
|
2
|
+
from codebread.parsers.generic_parser import parse_config
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _parse(text):
|
|
6
|
+
info = FileInfo(path=".env", language="config")
|
|
7
|
+
parse_config(info, text)
|
|
8
|
+
return info
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_secret_keys_are_masked_in_summary():
|
|
12
|
+
info = _parse("PASSWORD=hunter2\nAPI_KEY=sk-live-abc123\n")
|
|
13
|
+
joined = "\n".join(info.db_config)
|
|
14
|
+
assert "hunter2" not in joined
|
|
15
|
+
assert "sk-live-abc123" not in joined
|
|
16
|
+
assert "masked" in joined
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_db_url_credentials_masked_but_host_kept():
|
|
20
|
+
info = _parse("DATABASE_URL=postgres://admin:hunter2@db.example.com:5432/prod\n")
|
|
21
|
+
joined = "\n".join(info.db_config)
|
|
22
|
+
assert "hunter2" not in joined
|
|
23
|
+
assert "db.example.com" in joined
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_non_db_non_secret_keys_ignored():
|
|
27
|
+
info = _parse("DEBUG=true\nPORT=8080\n")
|
|
28
|
+
assert info.db_config == []
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_comments_and_blank_lines_skipped():
|
|
32
|
+
info = _parse("# PASSWORD=shouldnotmatch\n\nPASSWORD=realsecret\n")
|
|
33
|
+
joined = "\n".join(info.db_config)
|
|
34
|
+
assert "shouldnotmatch" not in joined
|
|
35
|
+
assert "realsecret" not in joined # masked either way
|
|
36
|
+
assert joined.count("PASSWORD") == 1
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from codebread.connections import build_graph
|
|
2
|
+
from codebread.models import ApiCall, DbRef, FileInfo, FunctionInfo, Route, TableInfo
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _fn(name, calls=None, routes=None, api_calls=None, db_refs=None, line=1):
|
|
6
|
+
return FunctionInfo(
|
|
7
|
+
name=name, line=line, end_line=line + 5,
|
|
8
|
+
calls=calls or [], routes=routes or [],
|
|
9
|
+
api_calls=api_calls or [], db_refs=db_refs or [],
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build(files):
|
|
14
|
+
return build_graph(files, tree={}, warnings=[])
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_same_file_call_edge():
|
|
18
|
+
f = FileInfo(path="app.py", language="python", layer="backend",
|
|
19
|
+
functions=[_fn("main", calls=["helper"]), _fn("helper", line=10)])
|
|
20
|
+
g = build([f])
|
|
21
|
+
call_edges = [e for e in g["edges"] if e["kind"] == "call"]
|
|
22
|
+
assert any(e["source"].endswith("::main@1") and e["target"].endswith("::helper@10")
|
|
23
|
+
for e in call_edges)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_cross_file_call_resolves_unique_name():
|
|
27
|
+
a = FileInfo(path="app.py", language="python", layer="backend",
|
|
28
|
+
functions=[_fn("main", calls=["fetch_user"])])
|
|
29
|
+
b = FileInfo(path="utils.py", language="python", layer="backend",
|
|
30
|
+
functions=[_fn("fetch_user", line=1)])
|
|
31
|
+
g = build([a, b])
|
|
32
|
+
call_edges = [e for e in g["edges"] if e["kind"] == "call"]
|
|
33
|
+
assert any(e["target"] == "utils.py::fetch_user@1" for e in call_edges)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_api_call_matches_route_despite_param_syntax_difference():
|
|
37
|
+
frontend = FileInfo(path="frontend.js", language="javascript", layer="frontend",
|
|
38
|
+
api_calls=[ApiCall(method="GET", url="/api/users/:id")])
|
|
39
|
+
backend = FileInfo(path="backend.py", language="python", layer="backend",
|
|
40
|
+
functions=[_fn("get_user",
|
|
41
|
+
routes=[Route(method="GET", path="/api/users/<id>")])])
|
|
42
|
+
g = build([frontend, backend])
|
|
43
|
+
api_edges = [e for e in g["edges"] if e["kind"] == "api"]
|
|
44
|
+
assert len(api_edges) == 1
|
|
45
|
+
assert api_edges[0]["target"] == "backend.py::get_user@1"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_db_edge_links_function_to_table():
|
|
49
|
+
models = FileInfo(path="models.py", language="python", layer="database",
|
|
50
|
+
tables=[TableInfo(name="users", model="User")])
|
|
51
|
+
backend = FileInfo(path="backend.py", language="python", layer="backend",
|
|
52
|
+
functions=[_fn("get_user",
|
|
53
|
+
db_refs=[DbRef(table="User", op="query", via="orm")])])
|
|
54
|
+
g = build([models, backend])
|
|
55
|
+
db_edges = [e for e in g["edges"] if e["kind"] == "db"]
|
|
56
|
+
assert len(db_edges) == 1
|
|
57
|
+
assert db_edges[0]["target"] == "table::users"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_include_edge_resolves_python_import():
|
|
61
|
+
idx = FileInfo(path="index.py", language="python", layer="backend",
|
|
62
|
+
imports=["helperlib"])
|
|
63
|
+
lib = FileInfo(path="helperlib.py", language="python", layer="backend")
|
|
64
|
+
g = build([idx, lib])
|
|
65
|
+
include_edges = [e for e in g["edges"] if e["kind"] == "include"]
|
|
66
|
+
assert any(e["source"] == "index.py" and e["target"] == "helperlib.py"
|
|
67
|
+
for e in include_edges)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_orphan_detection_respects_entry_names():
|
|
71
|
+
f = FileInfo(path="app.py", language="python", layer="backend",
|
|
72
|
+
functions=[_fn("main"), _fn("never_called", line=10)])
|
|
73
|
+
g = build([f])
|
|
74
|
+
by_label_line = {(n["label"], n.get("line")): n for n in g["nodes"]
|
|
75
|
+
if n["kind"] == "function"}
|
|
76
|
+
assert not by_label_line[("main", 1)].get("orphan")
|
|
77
|
+
assert by_label_line[("never_called", 10)].get("orphan") is True
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_cycle_detection_flags_both_functions_and_reports_cycle():
|
|
81
|
+
f = FileInfo(path="cycle.py", language="python", layer="backend",
|
|
82
|
+
functions=[_fn("a", calls=["b"]), _fn("b", calls=["a"], line=10)])
|
|
83
|
+
g = build([f])
|
|
84
|
+
fn_nodes = [n for n in g["nodes"] if n["kind"] == "function"]
|
|
85
|
+
assert all(n.get("cycle") for n in fn_nodes)
|
|
86
|
+
assert g["stats"]["cycles"] == 1
|
|
87
|
+
assert len(g["cycles"]) == 1
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_stats_counts_are_consistent():
|
|
91
|
+
f = FileInfo(path="app.py", language="python", layer="backend",
|
|
92
|
+
functions=[_fn("main", calls=["helper"]), _fn("helper", line=10)])
|
|
93
|
+
g = build([f])
|
|
94
|
+
assert g["stats"]["files"] == 1
|
|
95
|
+
assert g["stats"]["functions"] == 2
|
|
96
|
+
assert g["stats"]["connections"] == len(g["edges"])
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
from conftest import read_fixture
|
|
2
|
+
|
|
3
|
+
from codebread.models import FileInfo
|
|
4
|
+
from codebread.parsers.generic_parser import (parse_generic, parse_sql,
|
|
5
|
+
redact_secrets)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _parse(language, *fixture_parts):
|
|
9
|
+
info = FileInfo(path="/".join(fixture_parts), language=language)
|
|
10
|
+
parse_generic(info, read_fixture(*fixture_parts), language)
|
|
11
|
+
return info
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_java_functions_class_and_route():
|
|
15
|
+
info = _parse("java", "java", "UserController.java")
|
|
16
|
+
names = {f.name for f in info.functions}
|
|
17
|
+
assert {"getUser", "createUser"} <= names
|
|
18
|
+
by_name = {f.name: f for f in info.functions}
|
|
19
|
+
assert by_name["getUser"].routes[0].method == "GET"
|
|
20
|
+
assert by_name["getUser"].routes[0].path == "/users/{id}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_java_raw_sql_detected():
|
|
24
|
+
info = _parse("java", "java", "UserController.java")
|
|
25
|
+
by_name = {f.name: f for f in info.functions}
|
|
26
|
+
assert any(r.table == "users" and r.op == "insert"
|
|
27
|
+
for r in by_name["createUser"].db_refs)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_csharp_functions_and_route():
|
|
31
|
+
info = _parse("csharp", "csharp", "UsersController.cs")
|
|
32
|
+
names = {f.name for f in info.functions}
|
|
33
|
+
assert {"GetUser", "CreateUser"} <= names
|
|
34
|
+
by_name = {f.name: f for f in info.functions}
|
|
35
|
+
assert by_name["GetUser"].routes[0].method == "GET"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_go_functions_and_struct():
|
|
39
|
+
info = _parse("go", "go", "sample.go")
|
|
40
|
+
names = {f.name for f in info.functions}
|
|
41
|
+
assert {"getUser", "createUser", "main"} <= names
|
|
42
|
+
# structs are extracted as classes via a separate pass in analyzer/UI —
|
|
43
|
+
# parse_generic itself doesn't record "class" nodes for Go, only imports
|
|
44
|
+
# and functions, so we just check the struct's type line didn't get
|
|
45
|
+
# mistaken for a function.
|
|
46
|
+
assert "User" not in names
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_go_raw_sql_detected():
|
|
50
|
+
info = _parse("go", "go", "sample.go")
|
|
51
|
+
by_name = {f.name: f for f in info.functions}
|
|
52
|
+
assert any(r.table == "users" and r.op == "insert"
|
|
53
|
+
for r in by_name["createUser"].db_refs)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_php_functions_route_and_page_link():
|
|
57
|
+
info = _parse("php", "php", "users.php")
|
|
58
|
+
names = {f.name for f in info.functions}
|
|
59
|
+
assert {"find", "render_user_page"} <= names
|
|
60
|
+
assert any(r.method == "GET" and r.path == "/users/{id}"
|
|
61
|
+
for r in info.routes)
|
|
62
|
+
assert "dashboard.php" in info.links
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_php_import_extracted():
|
|
66
|
+
info = _parse("php", "php", "users.php")
|
|
67
|
+
assert "db.php" in info.imports
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_ruby_functions_and_route():
|
|
71
|
+
info = _parse("ruby", "ruby", "users_controller.rb")
|
|
72
|
+
names = {f.name for f in info.functions}
|
|
73
|
+
assert {"show", "create", "helper_method"} <= names
|
|
74
|
+
assert any(r.method == "GET" and r.path == "/users/:id"
|
|
75
|
+
for r in info.routes)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_sql_create_table_schema():
|
|
79
|
+
info = FileInfo(path="schema.sql", language="sql")
|
|
80
|
+
parse_sql(info, read_fixture("sql", "schema.sql"))
|
|
81
|
+
by_name = {t.name: t for t in info.tables}
|
|
82
|
+
assert set(by_name) == {"users", "orders"}
|
|
83
|
+
assert set(by_name["users"].fields) >= {"id", "name", "email"}
|
|
84
|
+
# constraint lines must not be mistaken for columns
|
|
85
|
+
assert "FOREIGN" not in by_name["orders"].fields
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_redact_secrets_masks_password_and_url_creds():
|
|
89
|
+
text = (
|
|
90
|
+
"DATABASE_URL=postgres://admin:hunter2@db.example.com:5432/prod\n"
|
|
91
|
+
"API_KEY=sk-abcdef123456\n"
|
|
92
|
+
"DEBUG=true\n"
|
|
93
|
+
)
|
|
94
|
+
out = redact_secrets(text)
|
|
95
|
+
assert "hunter2" not in out
|
|
96
|
+
assert "sk-abcdef123456" not in out
|
|
97
|
+
assert "DEBUG=true" in out
|
|
98
|
+
assert "db.example.com" in out # non-secret parts stay readable
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_redact_secrets_preserves_line_count():
|
|
102
|
+
text = "A=1\nB=2\nPASSWORD=x\nC=3\n"
|
|
103
|
+
out = redact_secrets(text)
|
|
104
|
+
assert len(out.splitlines()) == len(text.splitlines())
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
from conftest import read_fixture
|
|
2
|
+
|
|
3
|
+
from codebread.models import FileInfo
|
|
4
|
+
from codebread.parsers.javascript_parser import parse_javascript
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _parse_express():
|
|
8
|
+
info = FileInfo(path="server.js", language="javascript")
|
|
9
|
+
parse_javascript(info, read_fixture("javascript", "sample_express.js"), "javascript")
|
|
10
|
+
return info
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _parse_ts():
|
|
14
|
+
info = FileInfo(path="app.ts", language="typescript")
|
|
15
|
+
parse_javascript(info, read_fixture("javascript", "sample_types.ts"), "typescript")
|
|
16
|
+
return info
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _parse_vue():
|
|
20
|
+
info = FileInfo(path="Counter.vue", language="vue")
|
|
21
|
+
parse_javascript(info, read_fixture("vue", "sample.vue"), "vue")
|
|
22
|
+
return info
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_extracts_functions_and_class_method():
|
|
26
|
+
info = _parse_express()
|
|
27
|
+
names = {f.name for f in info.functions}
|
|
28
|
+
assert {"fetchExternalProfile", "getUser", "createUser", "getById"} <= names
|
|
29
|
+
by_name = {f.name: f for f in info.functions}
|
|
30
|
+
assert by_name["getById"].parent_class == "UserService"
|
|
31
|
+
assert by_name["getById"].kind == "method"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_extracts_mongoose_model():
|
|
35
|
+
info = _parse_express()
|
|
36
|
+
assert len(info.tables) == 1
|
|
37
|
+
table = info.tables[0]
|
|
38
|
+
assert table.model == "User"
|
|
39
|
+
assert set(table.fields) >= {"name", "email"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_extracts_route_attached_to_named_handler():
|
|
43
|
+
info = _parse_express()
|
|
44
|
+
all_routes = list(info.routes)
|
|
45
|
+
for f in info.functions:
|
|
46
|
+
all_routes.extend(f.routes)
|
|
47
|
+
methods_paths = {(r.method, r.path) for r in all_routes}
|
|
48
|
+
assert ("GET", "/users/:id") in methods_paths
|
|
49
|
+
assert ("POST", "/users") in methods_paths
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_extracts_fetch_api_call():
|
|
53
|
+
info = _parse_express()
|
|
54
|
+
by_name = {f.name: f for f in info.functions}
|
|
55
|
+
calls = by_name["fetchExternalProfile"].api_calls
|
|
56
|
+
assert len(calls) == 1
|
|
57
|
+
assert calls[0].method == "GET"
|
|
58
|
+
assert "api.example.com" in calls[0].url
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_extracts_orm_usage_and_raw_sql():
|
|
62
|
+
info = _parse_express()
|
|
63
|
+
by_name = {f.name: f for f in info.functions}
|
|
64
|
+
assert any(r.table == "User" for r in by_name["getUser"].db_refs)
|
|
65
|
+
assert any(r.table == "users" and r.via == "sql" and r.op == "insert"
|
|
66
|
+
for r in by_name["createUser"].db_refs)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_typescript_type_annotations_dont_break_extraction():
|
|
70
|
+
info = _parse_ts()
|
|
71
|
+
names = {f.name for f in info.functions}
|
|
72
|
+
assert {"getUserName", "double", "greet"} <= names
|
|
73
|
+
by_name = {f.name: f for f in info.functions}
|
|
74
|
+
assert by_name["greet"].parent_class == "Greeter"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_vue_script_setup_functions_extracted_and_template_excluded():
|
|
78
|
+
info = _parse_vue()
|
|
79
|
+
names = {f.name for f in info.functions}
|
|
80
|
+
assert {"increment", "logChange"} <= names
|
|
81
|
+
# template-only content shouldn't leak in as a function name
|
|
82
|
+
assert "button" not in names
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from conftest import read_fixture
|
|
2
|
+
|
|
3
|
+
from codebread.models import FileInfo
|
|
4
|
+
from codebread.parsers.python_parser import parse_python
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _parse():
|
|
8
|
+
info = FileInfo(path="app.py", language="python")
|
|
9
|
+
parse_python(info, read_fixture("python", "sample_flask.py"))
|
|
10
|
+
return info
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_extracts_functions():
|
|
14
|
+
info = _parse()
|
|
15
|
+
names = {f.name for f in info.functions}
|
|
16
|
+
assert {"fetch_remote_profile", "get_user", "create_user"} <= names
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_extracts_orm_model():
|
|
20
|
+
info = _parse()
|
|
21
|
+
assert len(info.tables) == 1
|
|
22
|
+
table = info.tables[0]
|
|
23
|
+
assert table.name == "users"
|
|
24
|
+
assert table.model == "User"
|
|
25
|
+
assert set(table.fields) >= {"id", "name"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_extracts_routes():
|
|
29
|
+
info = _parse()
|
|
30
|
+
by_name = {f.name: f for f in info.functions}
|
|
31
|
+
assert by_name["get_user"].routes[0].method == "GET"
|
|
32
|
+
assert by_name["get_user"].routes[0].path == "/users/<int:user_id>"
|
|
33
|
+
assert by_name["create_user"].routes[0].method == "POST"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_extracts_outgoing_api_call():
|
|
37
|
+
info = _parse()
|
|
38
|
+
by_name = {f.name: f for f in info.functions}
|
|
39
|
+
calls = by_name["fetch_remote_profile"].api_calls
|
|
40
|
+
assert len(calls) == 1
|
|
41
|
+
assert calls[0].method == "GET"
|
|
42
|
+
assert "/profiles/" in calls[0].url
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_extracts_orm_query_and_raw_sql():
|
|
46
|
+
info = _parse()
|
|
47
|
+
by_name = {f.name: f for f in info.functions}
|
|
48
|
+
get_refs = by_name["get_user"].db_refs
|
|
49
|
+
assert any(r.table == "User" and r.via == "orm" for r in get_refs)
|
|
50
|
+
create_refs = by_name["create_user"].db_refs
|
|
51
|
+
assert any(r.table == "users" and r.via == "sql" and r.op == "insert"
|
|
52
|
+
for r in create_refs)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_syntax_error_is_reported_not_raised():
|
|
56
|
+
info = FileInfo(path="broken.py", language="python")
|
|
57
|
+
parse_python(info, "def broken(:\n pass\n")
|
|
58
|
+
assert info.functions == []
|
|
59
|
+
assert any("syntax error" in w.lower() for w in info.warnings)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from codebread.languages import detect_language, looks_binary
|
|
4
|
+
from codebread.scanner import scan
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _write(root, rel_path, content=""):
|
|
8
|
+
full = os.path.join(root, rel_path)
|
|
9
|
+
os.makedirs(os.path.dirname(full), exist_ok=True)
|
|
10
|
+
with open(full, "w", encoding="utf-8") as f:
|
|
11
|
+
f.write(content)
|
|
12
|
+
return full
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_scan_respects_gitignore(tmp_path):
|
|
16
|
+
root = str(tmp_path)
|
|
17
|
+
_write(root, ".gitignore", "ignored.py\nbuild/\n")
|
|
18
|
+
_write(root, "keep.py", "def f(): pass\n")
|
|
19
|
+
_write(root, "ignored.py", "def g(): pass\n")
|
|
20
|
+
_write(root, "build/output.py", "def h(): pass\n")
|
|
21
|
+
|
|
22
|
+
result = scan(root)
|
|
23
|
+
paths = {f["path"] for f in result["files"]}
|
|
24
|
+
assert "keep.py" in paths
|
|
25
|
+
assert "ignored.py" not in paths
|
|
26
|
+
assert "build/output.py" not in paths
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_scan_skips_known_noise_dirs(tmp_path):
|
|
30
|
+
root = str(tmp_path)
|
|
31
|
+
_write(root, "app.py", "def f(): pass\n")
|
|
32
|
+
_write(root, "node_modules/pkg/index.js", "module.exports = {};\n")
|
|
33
|
+
_write(root, ".git/HEAD", "ref: refs/heads/main\n")
|
|
34
|
+
|
|
35
|
+
result = scan(root)
|
|
36
|
+
paths = {f["path"] for f in result["files"]}
|
|
37
|
+
assert "app.py" in paths
|
|
38
|
+
assert not any(p.startswith("node_modules/") for p in paths)
|
|
39
|
+
assert not any(p.startswith(".git/") for p in paths)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_scan_flags_oversized_files_as_warning(tmp_path):
|
|
43
|
+
root = str(tmp_path)
|
|
44
|
+
big = "x = 1\n" * 200_000 # comfortably over the 1MB default cap
|
|
45
|
+
_write(root, "huge.py", big)
|
|
46
|
+
|
|
47
|
+
result = scan(root, max_file_kb=1)
|
|
48
|
+
assert any(w["path"] == "huge.py" for w in result["warnings"])
|
|
49
|
+
assert not any(f["path"] == "huge.py" for f in result["files"])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_detect_language_env_files_are_config():
|
|
53
|
+
assert detect_language(".env") == "config"
|
|
54
|
+
assert detect_language(".env.production") == "config"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_detect_language_settings_py_stays_python():
|
|
58
|
+
# CONFIG_BASENAMES includes settings.py, but it should still be parsed
|
|
59
|
+
# as real Python (full extraction), not routed into the generic
|
|
60
|
+
# config-key-scan bucket.
|
|
61
|
+
assert detect_language("settings.py") == "python"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_detect_language_common_extensions():
|
|
65
|
+
assert detect_language("app.py") == "python"
|
|
66
|
+
assert detect_language("component.tsx") == "typescript"
|
|
67
|
+
assert detect_language("main.go") == "go"
|
|
68
|
+
assert detect_language("style.css") == "css"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_looks_binary_by_extension():
|
|
72
|
+
assert looks_binary("photo.png") is True
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_looks_binary_by_null_byte(tmp_path):
|
|
76
|
+
p = tmp_path / "weird.dat"
|
|
77
|
+
p.write_bytes(b"\x00\x01\x02binary-ish")
|
|
78
|
+
assert looks_binary(str(p)) is True
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_looks_binary_false_for_text(tmp_path):
|
|
82
|
+
p = tmp_path / "note.unknownext"
|
|
83
|
+
p.write_text("just plain text", encoding="utf-8")
|
|
84
|
+
assert looks_binary(str(p)) is False
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import threading
|
|
3
|
+
import urllib.error
|
|
4
|
+
import urllib.request
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from codebread.server import WEB_DIR, build_server, safe_web_path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_safe_web_path_allows_files_inside_web_dir():
|
|
12
|
+
resolved = safe_web_path("app.js")
|
|
13
|
+
assert resolved is not None
|
|
14
|
+
assert os.path.isfile(resolved)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_safe_web_path_blocks_relative_traversal():
|
|
18
|
+
assert safe_web_path("../../../../Windows/win.ini") is None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.mark.skipif(os.name != "nt",
|
|
22
|
+
reason="drive-absolute os.path.join discard is an "
|
|
23
|
+
"ntpath-only quirk; on POSIX there's no drive "
|
|
24
|
+
"letter so the path safely stays under WEB_DIR")
|
|
25
|
+
def test_safe_web_path_blocks_windows_drive_absolute_traversal():
|
|
26
|
+
# The bug this locks in: os.path.join(WEB_DIR, "C:/x") on Windows
|
|
27
|
+
# discards WEB_DIR entirely because the second arg is drive-absolute.
|
|
28
|
+
drive = os.path.splitdrive(WEB_DIR)[0] or "C:"
|
|
29
|
+
assert safe_web_path(f"{drive}/Windows/win.ini") is None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_safe_web_path_blocks_unc_style_path():
|
|
33
|
+
assert safe_web_path("//server/share/file.txt") is None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_build_server_with_port_zero_lets_os_assign_a_port():
|
|
37
|
+
# Regression: port 0 ("let the OS pick") is a falsy int, and an earlier
|
|
38
|
+
# version of _free_port() returned 0 for both "OS-assigned" and "no
|
|
39
|
+
# free port found", so `if not port:` treated a successful bind as
|
|
40
|
+
# failure.
|
|
41
|
+
server = build_server({"nodes": [], "edges": [], "tree": {}, "stats": {}}, port=0)
|
|
42
|
+
try:
|
|
43
|
+
assert server is not None
|
|
44
|
+
assert server.server_port > 0
|
|
45
|
+
finally:
|
|
46
|
+
if server is not None:
|
|
47
|
+
server.server_close()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pytest.fixture
|
|
51
|
+
def live_server():
|
|
52
|
+
server = build_server({"nodes": [], "edges": [], "tree": {}, "stats": {}}, port=0)
|
|
53
|
+
assert server is not None
|
|
54
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
55
|
+
thread.start()
|
|
56
|
+
try:
|
|
57
|
+
yield f"http://127.0.0.1:{server.server_port}"
|
|
58
|
+
finally:
|
|
59
|
+
server.shutdown()
|
|
60
|
+
server.server_close()
|
|
61
|
+
thread.join(timeout=5)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_live_server_serves_legit_asset(live_server):
|
|
65
|
+
with urllib.request.urlopen(f"{live_server}/app.js") as resp:
|
|
66
|
+
assert resp.status == 200
|
|
67
|
+
assert len(resp.read()) > 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_live_server_serves_data_json(live_server):
|
|
71
|
+
with urllib.request.urlopen(f"{live_server}/data.json") as resp:
|
|
72
|
+
assert resp.status == 200
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.mark.skipif(os.name != "nt",
|
|
76
|
+
reason="drive-absolute os.path.join discard is an "
|
|
77
|
+
"ntpath-only quirk; on POSIX the request just "
|
|
78
|
+
"404s as a normal missing file under WEB_DIR")
|
|
79
|
+
def test_live_server_rejects_drive_absolute_traversal(live_server):
|
|
80
|
+
drive = os.path.splitdrive(WEB_DIR)[0] or "C:"
|
|
81
|
+
with pytest.raises(urllib.error.HTTPError) as exc:
|
|
82
|
+
urllib.request.urlopen(f"{live_server}/{drive}/Windows/win.ini")
|
|
83
|
+
assert exc.value.code == 403
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
import codebread
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_version_is_a_plain_semver_like_string():
|
|
7
|
+
assert re.match(r"^\d+\.\d+\.\d+", codebread.__version__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_version_is_not_the_unresolved_fallback():
|
|
11
|
+
# "0.0.0+unknown" only happens if codebread is neither installed nor
|
|
12
|
+
# runnable from a checkout with a readable pyproject.toml — shouldn't
|
|
13
|
+
# happen in this repo's own test environment.
|
|
14
|
+
assert codebread.__version__ != "0.0.0+unknown"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|