codebread 1.0.0__py3-none-any.whl
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/__init__.py +3 -0
- codebread/analyzer.py +84 -0
- codebread/classifier.py +102 -0
- codebread/cli.py +106 -0
- codebread/connections.py +407 -0
- codebread/diff.py +123 -0
- codebread/export.py +45 -0
- codebread/languages.py +110 -0
- codebread/models.py +119 -0
- codebread/parsers/__init__.py +53 -0
- codebread/parsers/generic_parser.py +288 -0
- codebread/parsers/javascript_parser.py +371 -0
- codebread/parsers/python_parser.py +291 -0
- codebread/scanner.py +206 -0
- codebread/server.py +86 -0
- codebread/web/app.js +1716 -0
- codebread/web/index.html +188 -0
- codebread/web/style.css +601 -0
- codebread-1.0.0.dist-info/METADATA +254 -0
- codebread-1.0.0.dist-info/RECORD +24 -0
- codebread-1.0.0.dist-info/WHEEL +5 -0
- codebread-1.0.0.dist-info/entry_points.txt +2 -0
- codebread-1.0.0.dist-info/licenses/LICENSE +21 -0
- codebread-1.0.0.dist-info/top_level.txt +1 -0
codebread/__init__.py
ADDED
codebread/analyzer.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Orchestrates: scan -> parse -> classify -> connect -> graph dict."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import datetime
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Callable, Dict, Optional
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .classifier import classify
|
|
11
|
+
from .connections import build_graph
|
|
12
|
+
from .parsers import parse_file
|
|
13
|
+
from .scanner import read_text, scan
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def analyze(root: str,
|
|
17
|
+
progress: Optional[Callable[[str], None]] = None) -> Dict:
|
|
18
|
+
"""Run the full pipeline on `root` and return the graph dict."""
|
|
19
|
+
say = progress or (lambda msg: None)
|
|
20
|
+
root = os.path.abspath(root)
|
|
21
|
+
|
|
22
|
+
say(f"Scanning {root} ...")
|
|
23
|
+
result = scan(root)
|
|
24
|
+
files_meta = result["files"]
|
|
25
|
+
warnings = result["warnings"]
|
|
26
|
+
say(f" found {len(files_meta)} scannable files")
|
|
27
|
+
|
|
28
|
+
parsed = []
|
|
29
|
+
unsupported = {}
|
|
30
|
+
for i, meta in enumerate(files_meta, 1):
|
|
31
|
+
text, err = read_text(meta["abspath"])
|
|
32
|
+
info = parse_file(meta["path"], text, meta["language"])
|
|
33
|
+
if err:
|
|
34
|
+
info.warnings.append(err)
|
|
35
|
+
info.layer = classify(info, text)
|
|
36
|
+
if info.parsed or info.language in ("html", "css"):
|
|
37
|
+
info.source = text if len(text) <= 300_000 else \
|
|
38
|
+
text[:300_000] + "\n… (truncated)"
|
|
39
|
+
parsed.append(info)
|
|
40
|
+
for w in info.warnings:
|
|
41
|
+
if w.startswith("Unsupported:"):
|
|
42
|
+
unsupported.setdefault(meta["language"], 0)
|
|
43
|
+
unsupported[meta["language"]] += 1
|
|
44
|
+
warnings.append({"path": meta["path"], "message": w})
|
|
45
|
+
if i % 50 == 0:
|
|
46
|
+
say(f" parsed {i}/{len(files_meta)} files")
|
|
47
|
+
|
|
48
|
+
for lang, count in sorted(unsupported.items()):
|
|
49
|
+
say(f" note: {count} file(s) in {lang} - no parser available, "
|
|
50
|
+
f"shown as unsupported in the UI")
|
|
51
|
+
|
|
52
|
+
say("Mapping connections ...")
|
|
53
|
+
_annotate_tree(result["tree"], {f.path: f for f in parsed})
|
|
54
|
+
graph = build_graph(parsed, result["tree"], warnings)
|
|
55
|
+
graph["meta"] = {
|
|
56
|
+
"root": root,
|
|
57
|
+
"name": os.path.basename(root) or root,
|
|
58
|
+
"scannedAt": datetime.datetime.now().isoformat(timespec="seconds"),
|
|
59
|
+
"version": __version__,
|
|
60
|
+
}
|
|
61
|
+
s = graph["stats"]
|
|
62
|
+
say(f"Done: {s['files']} files, {s['functions']} functions, "
|
|
63
|
+
f"{s['tables']} tables, {s['connections']} connections, "
|
|
64
|
+
f"{s['warnings']} warnings")
|
|
65
|
+
return graph
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _annotate_tree(node: Dict, by_path: Dict) -> None:
|
|
69
|
+
"""Copy layer/warning info onto tree nodes for the sidebar."""
|
|
70
|
+
if node.get("type") == "file":
|
|
71
|
+
info = by_path.get(node.get("path"))
|
|
72
|
+
if info is not None:
|
|
73
|
+
node["layer"] = info.layer
|
|
74
|
+
node["nFunctions"] = len(info.functions)
|
|
75
|
+
if info.warnings and "warning" not in node:
|
|
76
|
+
node["warning"] = "parse"
|
|
77
|
+
return
|
|
78
|
+
layers = set()
|
|
79
|
+
for child in node.get("children", []):
|
|
80
|
+
_annotate_tree(child, by_path)
|
|
81
|
+
if child.get("layer") and child["layer"] not in ("unknown", None):
|
|
82
|
+
layers.add(child["layer"])
|
|
83
|
+
if len(layers) == 1:
|
|
84
|
+
node["layer"] = layers.pop()
|
codebread/classifier.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Score-based Frontend / Backend / Database / Config classifier.
|
|
2
|
+
|
|
3
|
+
Combines framework import signatures, folder naming conventions, file
|
|
4
|
+
extensions/patterns, and extracted facts (routes, DB models). Ambiguous
|
|
5
|
+
files stay "unknown" (shown as Unclassified) rather than guessing wrong.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from typing import Dict
|
|
12
|
+
|
|
13
|
+
from .models import FileInfo
|
|
14
|
+
|
|
15
|
+
FRONTEND_IMPORTS = re.compile(
|
|
16
|
+
r"^(react|react-dom|next|vue|@vue|nuxt|@angular|svelte|solid-js|preact|"
|
|
17
|
+
r"jquery|axios|@tanstack|redux|zustand|styled-components|@mui|antd|"
|
|
18
|
+
r"tailwindcss|three|d3)($|/)", re.IGNORECASE)
|
|
19
|
+
BACKEND_IMPORTS = re.compile(
|
|
20
|
+
r"^(express|koa|fastify|hapi|@nestjs|flask|django|fastapi|starlette|"
|
|
21
|
+
r"bottle|tornado|sanic|aiohttp|gin|echo|fiber|laravel|symfony|rails|"
|
|
22
|
+
r"sinatra|spring|springframework|gorilla|net/http|http\.server|"
|
|
23
|
+
r"microsoft\.aspnetcore)($|/|\.)", re.IGNORECASE)
|
|
24
|
+
DB_IMPORTS = re.compile(
|
|
25
|
+
r"^(sqlalchemy|django\.db|peewee|tortoise|pymongo|motor|redis|psycopg2?|"
|
|
26
|
+
r"mysql|sqlite3|mongoose|prisma|@prisma|sequelize|typeorm|knex|pg|"
|
|
27
|
+
r"mongodb|gorm|database/sql|entityframework|dapper|activerecord)"
|
|
28
|
+
r"($|/|\.)", re.IGNORECASE)
|
|
29
|
+
|
|
30
|
+
FRONTEND_DIRS = {"client", "frontend", "front", "components", "pages", "views",
|
|
31
|
+
"ui", "www", "public", "static", "assets", "layouts", "hooks",
|
|
32
|
+
"styles", "templates", "screens", "widgets"}
|
|
33
|
+
BACKEND_DIRS = {"server", "backend", "back", "api", "routes", "controllers",
|
|
34
|
+
"middleware", "services", "handlers", "endpoints", "resolvers",
|
|
35
|
+
"views_api", "rest", "graphql", "app"}
|
|
36
|
+
DB_DIRS = {"models", "model", "db", "database", "migrations", "schema",
|
|
37
|
+
"schemas", "entities", "repositories", "orm", "prisma", "sql"}
|
|
38
|
+
|
|
39
|
+
FRONTEND_EXTS = {".jsx", ".tsx", ".vue", ".svelte", ".html", ".htm",
|
|
40
|
+
".css", ".scss", ".sass", ".less"}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def classify(info: FileInfo, raw_text: str = "") -> str:
|
|
44
|
+
if info.language == "config":
|
|
45
|
+
return "config"
|
|
46
|
+
if info.language in ("markdown", "text"):
|
|
47
|
+
return "unknown"
|
|
48
|
+
if info.language == "sql":
|
|
49
|
+
return "database"
|
|
50
|
+
|
|
51
|
+
score: Dict[str, float] = {"frontend": 0.0, "backend": 0.0,
|
|
52
|
+
"database": 0.0}
|
|
53
|
+
|
|
54
|
+
ext = os.path.splitext(info.path)[1].lower()
|
|
55
|
+
if ext in FRONTEND_EXTS:
|
|
56
|
+
score["frontend"] += 2.5
|
|
57
|
+
if info.language in ("vue", "svelte"):
|
|
58
|
+
score["frontend"] += 2.0
|
|
59
|
+
|
|
60
|
+
parts = [p.lower() for p in info.path.split("/")[:-1]]
|
|
61
|
+
for p in parts:
|
|
62
|
+
if p in FRONTEND_DIRS:
|
|
63
|
+
score["frontend"] += 1.5
|
|
64
|
+
if p in BACKEND_DIRS:
|
|
65
|
+
score["backend"] += 1.5
|
|
66
|
+
if p in DB_DIRS:
|
|
67
|
+
score["database"] += 2.0
|
|
68
|
+
|
|
69
|
+
for imp in info.imports:
|
|
70
|
+
if FRONTEND_IMPORTS.match(imp):
|
|
71
|
+
score["frontend"] += 2.0
|
|
72
|
+
if BACKEND_IMPORTS.match(imp):
|
|
73
|
+
score["backend"] += 2.5
|
|
74
|
+
if DB_IMPORTS.match(imp):
|
|
75
|
+
score["database"] += 2.0
|
|
76
|
+
|
|
77
|
+
# extracted facts are strong signals
|
|
78
|
+
n_routes = len(info.routes) + sum(len(f.routes) for f in info.functions)
|
|
79
|
+
if n_routes:
|
|
80
|
+
score["backend"] += 2.0 + min(n_routes, 5) * 0.4
|
|
81
|
+
if info.tables:
|
|
82
|
+
score["database"] += 2.0 + min(len(info.tables), 5) * 0.6
|
|
83
|
+
n_api = len(info.api_calls) + sum(len(f.api_calls) for f in info.functions)
|
|
84
|
+
if n_api and score["backend"] < 1.0:
|
|
85
|
+
score["frontend"] += 1.0 # things that *call* APIs lean frontend
|
|
86
|
+
n_db = sum(len(f.db_refs) for f in info.functions)
|
|
87
|
+
if n_db:
|
|
88
|
+
score["database"] += min(n_db, 4) * 0.5
|
|
89
|
+
score["backend"] += 0.5
|
|
90
|
+
|
|
91
|
+
if raw_text and info.language in ("javascript", "typescript"):
|
|
92
|
+
if re.search(r"\bdocument\.|window\.|useState\(|useEffect\(",
|
|
93
|
+
raw_text):
|
|
94
|
+
score["frontend"] += 1.5
|
|
95
|
+
if re.search(r"process\.env|require\(['\"]fs['\"]\)|__dirname",
|
|
96
|
+
raw_text):
|
|
97
|
+
score["backend"] += 0.8
|
|
98
|
+
|
|
99
|
+
best = max(score, key=lambda k: score[k])
|
|
100
|
+
if score[best] < 1.5:
|
|
101
|
+
return "unknown"
|
|
102
|
+
return best
|
codebread/cli.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""CodeBread command line interface."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
|
|
10
|
+
BANNER = r"""
|
|
11
|
+
___ _ ___ _
|
|
12
|
+
/ __|___ __| |___| _ )_ _ ___ __ _ __| |
|
|
13
|
+
| (__/ _ \/ _` / -_) _ \ '_/ -_) _` / _` |
|
|
14
|
+
\___\___/\__,_\___|___/_| \___\__,_\__,_| v{v}
|
|
15
|
+
slice open a codebase and see how it's wired
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main(argv=None) -> int:
|
|
20
|
+
parser = argparse.ArgumentParser(
|
|
21
|
+
prog="codebread",
|
|
22
|
+
description="Interactive codebase analyzer & visualizer. Scans a "
|
|
23
|
+
"project, extracts every function/class, maps frontend "
|
|
24
|
+
"-> backend -> database connections, and opens an "
|
|
25
|
+
"interactive graph in your browser.")
|
|
26
|
+
parser.add_argument("--path", "-p",
|
|
27
|
+
help="root folder to scan (prompted if omitted)")
|
|
28
|
+
parser.add_argument("--load", metavar="GRAPH.json",
|
|
29
|
+
help="serve a previously exported graph JSON "
|
|
30
|
+
"instead of re-scanning")
|
|
31
|
+
parser.add_argument("--diff", nargs=2, metavar=("OLD.json", "NEW.json"),
|
|
32
|
+
help="compare two saved graph JSON exports and "
|
|
33
|
+
"print what changed, then exit")
|
|
34
|
+
parser.add_argument("--port", type=int, default=8137,
|
|
35
|
+
help="local server port (default: 8137)")
|
|
36
|
+
parser.add_argument("--json", metavar="OUT.json",
|
|
37
|
+
help="also export the full graph as JSON")
|
|
38
|
+
parser.add_argument("--html", metavar="OUT.html",
|
|
39
|
+
help="also export a self-contained static HTML file")
|
|
40
|
+
parser.add_argument("--no-open", action="store_true",
|
|
41
|
+
help="don't auto-open the browser")
|
|
42
|
+
parser.add_argument("--no-serve", action="store_true",
|
|
43
|
+
help="scan + export only, don't start the server")
|
|
44
|
+
parser.add_argument("--version", action="version",
|
|
45
|
+
version=f"codebread {__version__}")
|
|
46
|
+
args = parser.parse_args(argv)
|
|
47
|
+
|
|
48
|
+
print(BANNER.format(v=__version__))
|
|
49
|
+
|
|
50
|
+
from .export import export_html, export_json, load_json
|
|
51
|
+
|
|
52
|
+
if args.diff:
|
|
53
|
+
old_path, new_path = args.diff
|
|
54
|
+
for p in (old_path, new_path):
|
|
55
|
+
if not os.path.isfile(p):
|
|
56
|
+
print(f"error: {p} not found", file=sys.stderr)
|
|
57
|
+
return 2
|
|
58
|
+
from .diff import compute_diff, format_diff_report
|
|
59
|
+
report = format_diff_report(compute_diff(load_json(old_path), load_json(new_path)))
|
|
60
|
+
print(report)
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
if args.load:
|
|
64
|
+
if not os.path.isfile(args.load):
|
|
65
|
+
print(f"error: {args.load} not found", file=sys.stderr)
|
|
66
|
+
return 2
|
|
67
|
+
graph = load_json(args.load)
|
|
68
|
+
print(f"[codebread] Loaded saved graph: {args.load}")
|
|
69
|
+
else:
|
|
70
|
+
root = args.path
|
|
71
|
+
if not root:
|
|
72
|
+
try:
|
|
73
|
+
root = input("Path to the project to analyze: ").strip().strip('"')
|
|
74
|
+
except (EOFError, KeyboardInterrupt):
|
|
75
|
+
print()
|
|
76
|
+
return 1
|
|
77
|
+
if not root:
|
|
78
|
+
print("error: no path given", file=sys.stderr)
|
|
79
|
+
return 2
|
|
80
|
+
root = os.path.expanduser(root)
|
|
81
|
+
if not os.path.isdir(root):
|
|
82
|
+
print(f"error: not a folder: {root}", file=sys.stderr)
|
|
83
|
+
return 2
|
|
84
|
+
|
|
85
|
+
from .analyzer import analyze
|
|
86
|
+
graph = analyze(root, progress=lambda m: print(f"[codebread] {m}"))
|
|
87
|
+
|
|
88
|
+
if graph["stats"]["warnings"]:
|
|
89
|
+
print(f"[codebread] {graph['stats']['warnings']} warning(s) - "
|
|
90
|
+
f"they are shown as badges in the UI, nothing was hidden.")
|
|
91
|
+
|
|
92
|
+
if args.json:
|
|
93
|
+
p = export_json(graph, args.json)
|
|
94
|
+
print(f"[codebread] Graph JSON written to {p}")
|
|
95
|
+
if args.html:
|
|
96
|
+
p = export_html(graph, args.html)
|
|
97
|
+
print(f"[codebread] Static HTML written to {p}")
|
|
98
|
+
|
|
99
|
+
if not args.no_serve:
|
|
100
|
+
from .server import serve
|
|
101
|
+
serve(graph, port=args.port, open_browser=not args.no_open)
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
sys.exit(main())
|