codebread 1.1.0__tar.gz → 1.1.3__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.1.0 → codebread-1.1.3}/PKG-INFO +7 -2
- {codebread-1.1.0 → codebread-1.1.3}/README.md +5 -0
- {codebread-1.1.0 → codebread-1.1.3}/codebread/connections.py +41 -10
- {codebread-1.1.0 → codebread-1.1.3}/codebread/context.py +34 -8
- {codebread-1.1.0 → codebread-1.1.3}/codebread/models.py +3 -0
- {codebread-1.1.0 → codebread-1.1.3}/codebread/parsers/generic_parser.py +116 -0
- {codebread-1.1.0 → codebread-1.1.3}/codebread/scanner.py +34 -20
- {codebread-1.1.0 → codebread-1.1.3}/codebread.egg-info/PKG-INFO +7 -2
- {codebread-1.1.0 → codebread-1.1.3}/pyproject.toml +3 -2
- {codebread-1.1.0 → codebread-1.1.3}/tests/test_generic_parser.py +62 -0
- {codebread-1.1.0 → codebread-1.1.3}/tests/test_scanner.py +12 -0
- {codebread-1.1.0 → codebread-1.1.3}/LICENSE +0 -0
- {codebread-1.1.0 → codebread-1.1.3}/codebread/__init__.py +0 -0
- {codebread-1.1.0 → codebread-1.1.3}/codebread/analyzer.py +0 -0
- {codebread-1.1.0 → codebread-1.1.3}/codebread/classifier.py +0 -0
- {codebread-1.1.0 → codebread-1.1.3}/codebread/cli.py +0 -0
- {codebread-1.1.0 → codebread-1.1.3}/codebread/deobfuscate.py +0 -0
- {codebread-1.1.0 → codebread-1.1.3}/codebread/diff.py +0 -0
- {codebread-1.1.0 → codebread-1.1.3}/codebread/export.py +0 -0
- {codebread-1.1.0 → codebread-1.1.3}/codebread/languages.py +0 -0
- {codebread-1.1.0 → codebread-1.1.3}/codebread/parsers/__init__.py +0 -0
- {codebread-1.1.0 → codebread-1.1.3}/codebread/parsers/javascript_parser.py +0 -0
- {codebread-1.1.0 → codebread-1.1.3}/codebread/parsers/python_parser.py +0 -0
- {codebread-1.1.0 → codebread-1.1.3}/codebread/server.py +0 -0
- {codebread-1.1.0 → codebread-1.1.3}/codebread/web/app.js +0 -0
- {codebread-1.1.0 → codebread-1.1.3}/codebread/web/index.html +0 -0
- {codebread-1.1.0 → codebread-1.1.3}/codebread/web/style.css +0 -0
- {codebread-1.1.0 → codebread-1.1.3}/codebread.egg-info/SOURCES.txt +0 -0
- {codebread-1.1.0 → codebread-1.1.3}/codebread.egg-info/dependency_links.txt +0 -0
- {codebread-1.1.0 → codebread-1.1.3}/codebread.egg-info/entry_points.txt +0 -0
- {codebread-1.1.0 → codebread-1.1.3}/codebread.egg-info/requires.txt +0 -0
- {codebread-1.1.0 → codebread-1.1.3}/codebread.egg-info/top_level.txt +0 -0
- {codebread-1.1.0 → codebread-1.1.3}/setup.cfg +0 -0
- {codebread-1.1.0 → codebread-1.1.3}/tests/test_analyzer.py +0 -0
- {codebread-1.1.0 → codebread-1.1.3}/tests/test_config_parser.py +0 -0
- {codebread-1.1.0 → codebread-1.1.3}/tests/test_connections.py +0 -0
- {codebread-1.1.0 → codebread-1.1.3}/tests/test_context.py +0 -0
- {codebread-1.1.0 → codebread-1.1.3}/tests/test_deobfuscate.py +0 -0
- {codebread-1.1.0 → codebread-1.1.3}/tests/test_javascript_parser.py +0 -0
- {codebread-1.1.0 → codebread-1.1.3}/tests/test_python_parser.py +0 -0
- {codebread-1.1.0 → codebread-1.1.3}/tests/test_server.py +0 -0
- {codebread-1.1.0 → codebread-1.1.3}/tests/test_version.py +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codebread
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.3
|
|
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
|
|
7
|
-
Keywords: codebase,analyzer,visualizer,call-graph,architecture
|
|
7
|
+
Keywords: codebase,analyzer,visualizer,call-graph,architecture,dependency-graph,code-visualization,static-analysis,code-map
|
|
8
8
|
Requires-Python: >=3.9
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
10
|
License-File: LICENSE
|
|
@@ -23,6 +23,11 @@ Dynamic: license-file
|
|
|
23
23
|
Files, functions, API routes and database tables, mapped in your browser.
|
|
24
24
|
</p>
|
|
25
25
|
|
|
26
|
+
<p align="center">
|
|
27
|
+
An interactive codebase visualizer: call graph, dependency map and API-to-database tracing<br>
|
|
28
|
+
for Python, JavaScript, TypeScript, PHP, Java, Go, C# and Ruby.
|
|
29
|
+
</p>
|
|
30
|
+
|
|
26
31
|
<p align="center">
|
|
27
32
|
<a href="https://pypi.org/project/codebread/"><img alt="PyPI" src="https://img.shields.io/pypi/v/codebread?color=b0541c&label=pypi"></a>
|
|
28
33
|
<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>
|
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
Files, functions, API routes and database tables, mapped in your browser.
|
|
8
8
|
</p>
|
|
9
9
|
|
|
10
|
+
<p align="center">
|
|
11
|
+
An interactive codebase visualizer: call graph, dependency map and API-to-database tracing<br>
|
|
12
|
+
for Python, JavaScript, TypeScript, PHP, Java, Go, C# and Ruby.
|
|
13
|
+
</p>
|
|
14
|
+
|
|
10
15
|
<p align="center">
|
|
11
16
|
<a href="https://pypi.org/project/codebread/"><img alt="PyPI" src="https://img.shields.io/pypi/v/codebread?color=b0541c&label=pypi"></a>
|
|
12
17
|
<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,6 +26,16 @@ COMMON_NAMES = { # never resolve these across files by name alone
|
|
|
26
26
|
ENTRY_NAMES = {
|
|
27
27
|
"main", "__init__", "__main__", "__new__", "__call__", "setup", "setUp",
|
|
28
28
|
"tearDown", "run", "handler", "index", "render", "constructor",
|
|
29
|
+
# PHP magic methods
|
|
30
|
+
"__construct", "__destruct", "__invoke", "__get", "__set", "__toString",
|
|
31
|
+
# Laravel: migrations, providers, jobs, requests, factories, policies
|
|
32
|
+
"up", "down", "boot", "booted", "register", "handle", "schedule", "rules",
|
|
33
|
+
"authorize", "definition", "casts", "toArray", "via", "toMail", "broadcastOn",
|
|
34
|
+
"store", "show", "create", "edit", "update", "destroy",
|
|
35
|
+
# Filament / Livewire hooks
|
|
36
|
+
"form", "table", "configure", "panel", "mount", "getPages", "getRelations",
|
|
37
|
+
"getHeaderActions", "getStats", "getData", "getType", "getEloquentQuery",
|
|
38
|
+
"getGloballySearchableAttributes", "canAccessPanel",
|
|
29
39
|
}
|
|
30
40
|
|
|
31
41
|
|
|
@@ -80,6 +90,10 @@ def _resolve_file_ref(base_file: str, target: str, file_set) -> Optional[str]:
|
|
|
80
90
|
posixpath.normpath(target.lstrip("/")),
|
|
81
91
|
target.lstrip("./"),
|
|
82
92
|
]
|
|
93
|
+
# PSR-4 namespaces: `use App\Support\MjIcon` lives in app/Support/MjIcon.php
|
|
94
|
+
head, _, rest = target.partition("/")
|
|
95
|
+
if rest and head[:1].isupper():
|
|
96
|
+
candidates.append(head.lower() + "/" + rest)
|
|
83
97
|
# python dotted modules: models / app.utils -> models.py / app/utils.py
|
|
84
98
|
if "/" not in target and "." not in posixpath.basename(target):
|
|
85
99
|
candidates.append(target.replace(".", "/"))
|
|
@@ -233,8 +247,9 @@ def build_graph(files: List[FileInfo], tree: Dict,
|
|
|
233
247
|
if t is not None: # enrich a placeholder with real definition
|
|
234
248
|
for n in nodes:
|
|
235
249
|
if n["id"] == nid:
|
|
236
|
-
if t.fields:
|
|
237
|
-
n["fields"] =
|
|
250
|
+
if t.fields: # merge: migrations add columns over time
|
|
251
|
+
n["fields"] = n.get("fields", []) + [
|
|
252
|
+
c for c in t.fields if c not in n.get("fields", [])]
|
|
238
253
|
if t.model:
|
|
239
254
|
n["model"] = t.model
|
|
240
255
|
if t.source:
|
|
@@ -252,11 +267,17 @@ def build_graph(files: List[FileInfo], tree: Dict,
|
|
|
252
267
|
})
|
|
253
268
|
return nid
|
|
254
269
|
|
|
270
|
+
# real definitions first, so a model's guessed name can snap to one
|
|
271
|
+
defined = {t.name.lower() for f in files for t in f.tables if not t.aliases}
|
|
255
272
|
for f in files:
|
|
256
|
-
for t in f.tables:
|
|
273
|
+
for t in sorted(f.tables, key=lambda t: bool(t.aliases)):
|
|
274
|
+
if t.aliases:
|
|
275
|
+
t.name = next((a for a in t.aliases if a.lower() in defined), t.name)
|
|
257
276
|
nid = table_node(t.name, t)
|
|
258
277
|
if t.model:
|
|
259
278
|
model_to_table[t.model.lower()] = nid
|
|
279
|
+
if t.aliases: # Eloquent model file -> its table
|
|
280
|
+
add_edge(f.path, nid, "db", f"{t.model} model → {t.name}")
|
|
260
281
|
|
|
261
282
|
# ---- call edges --------------------------------------------------
|
|
262
283
|
for f in files:
|
|
@@ -288,12 +309,14 @@ def build_graph(files: List[FileInfo], tree: Dict,
|
|
|
288
309
|
|
|
289
310
|
# ---- file-level connections ---------------------------------------
|
|
290
311
|
file_set = {f.path for f in files}
|
|
312
|
+
called_from_page: set = set() # own functions a page calls (onclick="f()")
|
|
291
313
|
for f in files:
|
|
292
314
|
fname = f.path.split("/")[-1]
|
|
293
315
|
local = {fn.name: fn_nodes[(f.path, fn.name)] for fn in f.functions}
|
|
294
316
|
# top-level calls: page/script code calling into functions
|
|
295
317
|
for callee in f.calls:
|
|
296
318
|
if callee in local:
|
|
319
|
+
called_from_page.add(local[callee])
|
|
297
320
|
continue # a file already implicitly contains its own fns
|
|
298
321
|
candidates = name_index.get(callee, [])
|
|
299
322
|
if not candidates or callee in COMMON_NAMES:
|
|
@@ -332,13 +355,13 @@ def build_graph(files: List[FileInfo], tree: Dict,
|
|
|
332
355
|
target = handler_id or f.path
|
|
333
356
|
route_index.append((r.method, _norm_path(r.path), target,
|
|
334
357
|
f"{r.method} {r.path}"))
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
358
|
+
# mark the handler with its route; closure routes (Laravel
|
|
359
|
+
# `Route::get('/', function () {...})`) land on the file itself
|
|
360
|
+
for n in nodes:
|
|
361
|
+
if n["id"] == target:
|
|
362
|
+
n.setdefault("routes", []).append(
|
|
363
|
+
{"method": r.method, "path": r.path})
|
|
364
|
+
break
|
|
342
365
|
|
|
343
366
|
# ---- api edges (frontend call -> backend route) -------------------
|
|
344
367
|
def match_route(method: str, url: str) -> Optional[Tuple[str, str]]:
|
|
@@ -374,6 +397,8 @@ def build_graph(files: List[FileInfo], tree: Dict,
|
|
|
374
397
|
tid = model_to_table.get(ref.table.lower()) \
|
|
375
398
|
or table_ids.get(ref.table.lower())
|
|
376
399
|
if tid is None:
|
|
400
|
+
if ref.via == "orm-guess":
|
|
401
|
+
continue # `Foo::where()` where Foo isn't a known model
|
|
377
402
|
# unknown table: create it only for confident refs
|
|
378
403
|
if ref.via == "sql" or ref.table.lower() in table_ids:
|
|
379
404
|
tid = table_node(ref.table)
|
|
@@ -389,6 +414,12 @@ def build_graph(files: List[FileInfo], tree: Dict,
|
|
|
389
414
|
|
|
390
415
|
# ---- orphans + circular dependencies --------------------------------
|
|
391
416
|
cycles = _mark_orphans_and_cycles(nodes, edges)
|
|
417
|
+
# Eloquent model methods (relations, accessors) are reached through magic
|
|
418
|
+
# properties like $booking->user, which static analysis can't see
|
|
419
|
+
model_files = {f.path for f in files for t in f.tables if t.model and t.aliases}
|
|
420
|
+
for n in nodes:
|
|
421
|
+
if n.get("orphan") and (n.get("file") in model_files or n["id"] in called_from_page):
|
|
422
|
+
del n["orphan"]
|
|
392
423
|
|
|
393
424
|
# ---- stats ---------------------------------------------------------
|
|
394
425
|
n_fns = sum(1 for n in nodes if n["kind"] in ("function", "method"))
|
|
@@ -8,6 +8,7 @@ it once, then opens only the files that matter for the task.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import os
|
|
11
|
+
import re
|
|
11
12
|
from collections import Counter, defaultdict
|
|
12
13
|
from typing import Dict, Iterable
|
|
13
14
|
|
|
@@ -24,6 +25,20 @@ def _short(items: Iterable[str], cap: int = LIST_CAP) -> str:
|
|
|
24
25
|
return ", ".join(items[:cap]) + more
|
|
25
26
|
|
|
26
27
|
|
|
28
|
+
def _human(name: str) -> str:
|
|
29
|
+
return " ".join(re.sub(r"(?<=[a-z0-9])(?=[A-Z])|_+", " ", name).split()).lower()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _desc(fn: Dict) -> str:
|
|
33
|
+
"""Drop auto-descriptions that only repeat the name ("Get Header Actions."),
|
|
34
|
+
keep what they add after it ("handles POST /users; queries users")."""
|
|
35
|
+
d = (fn.get("description") or "").strip()
|
|
36
|
+
head, _, tail = d.partition(" — ")
|
|
37
|
+
if head.rstrip(".").lower() == _human(fn["label"]):
|
|
38
|
+
return tail.rstrip(".")
|
|
39
|
+
return d
|
|
40
|
+
|
|
41
|
+
|
|
27
42
|
def to_markdown(graph: Dict) -> str:
|
|
28
43
|
nodes = {n["id"]: n for n in graph.get("nodes", [])}
|
|
29
44
|
edges = [e for e in graph.get("edges", [])
|
|
@@ -76,11 +91,16 @@ def to_markdown(graph: Dict) -> str:
|
|
|
76
91
|
""]
|
|
77
92
|
|
|
78
93
|
# ---- entry points
|
|
79
|
-
routes =
|
|
80
|
-
|
|
94
|
+
routes = set()
|
|
95
|
+
for n in nodes.values():
|
|
96
|
+
for r in n.get("routes") or []:
|
|
97
|
+
if n["kind"] == "file": # inline handler, e.g. a Laravel route closure
|
|
98
|
+
routes.add((r["method"], r["path"], f"`{n['file']}` (inline)"))
|
|
99
|
+
elif n["kind"] in ("function", "method"):
|
|
100
|
+
routes.add((r["method"], r["path"], f"`{n['file']}:{n['label']}()`"))
|
|
81
101
|
if routes:
|
|
82
102
|
L += ["## HTTP routes", "", "| Method | Path | Handler |", "|---|---|---|"]
|
|
83
|
-
L += [f"| {m} | `{p}` |
|
|
103
|
+
L += [f"| {m} | `{p}` | {h} |" for m, p, h in sorted(routes, key=lambda x: (x[1], x[0], x[2]))]
|
|
84
104
|
L.append("")
|
|
85
105
|
|
|
86
106
|
# ---- data model
|
|
@@ -106,6 +126,7 @@ def to_markdown(graph: Dict) -> str:
|
|
|
106
126
|
by_layer[f.get("layer") or "unknown"].append(f)
|
|
107
127
|
for layer in sorted(by_layer, key=layer_rank):
|
|
108
128
|
L += [f"### {LAYER_NAME.get(layer, layer)}", ""]
|
|
129
|
+
quiet = [] # files with nothing to say beyond their name
|
|
109
130
|
for f in by_layer[layer]:
|
|
110
131
|
path = f["file"]
|
|
111
132
|
members = [f["id"]] + [fn["id"] for fn in fns.get(path, [])]
|
|
@@ -113,6 +134,9 @@ def to_markdown(graph: Dict) -> str:
|
|
|
113
134
|
if owner(e["target"]) != f["id"]]
|
|
114
135
|
heard = [ref(owner(e["source"])) for m in members for e in in_e[m]
|
|
115
136
|
if owner(e["source"]) != f["id"]]
|
|
137
|
+
if not (talks or heard or fns.get(path) or f.get("warnings")):
|
|
138
|
+
quiet.append(f"`{path}`")
|
|
139
|
+
continue
|
|
116
140
|
L.append(f"#### `{path}` ({f.get('language', '?')}, {f.get('loc', 0)} lines)")
|
|
117
141
|
if talks:
|
|
118
142
|
L.append("Uses: " + _short(talks))
|
|
@@ -125,28 +149,30 @@ def to_markdown(graph: Dict) -> str:
|
|
|
125
149
|
params = ", ".join(fn.get("params") or [])
|
|
126
150
|
cls = f"{fn['parentClass']}." if fn.get("parentClass") else ""
|
|
127
151
|
line = f"- `{cls}{fn['label']}({params})` L{fn.get('line', '?')}"
|
|
128
|
-
if fn
|
|
129
|
-
line += f": {fn
|
|
152
|
+
if _desc(fn):
|
|
153
|
+
line += f": {_desc(fn)}"
|
|
130
154
|
calls = [ref(e["target"], path) for e in out_e[fn["id"]] if e["kind"] != "db"]
|
|
131
155
|
tbls = [nodes[e["target"]]["label"] for e in out_e[fn["id"]] if e["kind"] == "db"]
|
|
132
156
|
callers = [ref(e["source"], path) for e in in_e[fn["id"]]]
|
|
133
157
|
extra = []
|
|
134
158
|
if calls:
|
|
135
159
|
extra.append("calls " + _short(calls))
|
|
136
|
-
if tbls and not fn
|
|
160
|
+
if tbls and not all(t in _desc(fn) for t in tbls): # skip if the description names them
|
|
137
161
|
extra.append("tables " + _short(tbls))
|
|
138
162
|
if callers:
|
|
139
163
|
extra.append("called by " + _short(callers))
|
|
140
164
|
if fn.get("cycle"):
|
|
141
165
|
extra.append("in a call cycle")
|
|
142
|
-
|
|
143
|
-
|
|
166
|
+
# no "no callers found": framework hooks (up(), boot(), form())
|
|
167
|
+
# are called by the framework, so it would mislead more than help
|
|
144
168
|
if extra:
|
|
145
169
|
line += " | " + "; ".join(extra)
|
|
146
170
|
L.append(line)
|
|
147
171
|
if len(fl) > FNS_PER_FILE:
|
|
148
172
|
L.append(f"- … {len(fl) - FNS_PER_FILE} more functions")
|
|
149
173
|
L.append("")
|
|
174
|
+
if quiet:
|
|
175
|
+
L += ["Other files: " + _short(quiet, 40), ""]
|
|
150
176
|
|
|
151
177
|
# ---- things to be careful about
|
|
152
178
|
notes = []
|
|
@@ -58,6 +58,9 @@ class TableInfo:
|
|
|
58
58
|
fields: List[str] = field(default_factory=list)
|
|
59
59
|
line: int = 0
|
|
60
60
|
source: str = "" # file that defines it
|
|
61
|
+
# candidate names when `name` is only a guess (e.g. Laravel's pluralised
|
|
62
|
+
# Eloquent table names); resolved against real tables in build_graph
|
|
63
|
+
aliases: List[str] = field(default_factory=list)
|
|
61
64
|
|
|
62
65
|
|
|
63
66
|
@dataclass
|
|
@@ -15,6 +15,7 @@ KEYWORDS = {
|
|
|
15
15
|
"try", "throw", "case", "using", "lock", "foreach", "select", "func",
|
|
16
16
|
"defer", "go", "range", "elsif", "unless", "until", "when", "match",
|
|
17
17
|
"sizeof", "typeof", "yield", "puts", "print", "println", "printf",
|
|
18
|
+
"function", "fn", "use", # PHP closures: `function () use ($x)`, `fn () =>`
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
SQL_RE = re.compile(
|
|
@@ -23,6 +24,100 @@ SQL_RE = re.compile(
|
|
|
23
24
|
re.IGNORECASE)
|
|
24
25
|
CALL_RE = re.compile(r"(?<![\w$.:])([A-Za-z_][\w]*)\s*\(")
|
|
25
26
|
|
|
27
|
+
# ---- PHP / Laravel ------------------------------------------------------
|
|
28
|
+
# CALL_RE skips `X::f(` and would read `$obj->label(` as a call to any
|
|
29
|
+
# function named label. In PHP only these member calls are trustworthy:
|
|
30
|
+
PHP_MEMBER_CALL_RE = re.compile(
|
|
31
|
+
r"(?:\$this->|\b(?:self|static|parent)::|\b[A-Z]\w*::)(\w+)\s*\(")
|
|
32
|
+
PHP_USE_RE = re.compile(r"^\s*use\s+([A-Za-z_][\w\\]*\\\w+)\s*(?:as\s+\w+\s*)?;",
|
|
33
|
+
re.MULTILINE)
|
|
34
|
+
PHP_VIEW_RE = re.compile(r"\bview\s*\(\s*['\"]([\w.\-/]+)['\"]")
|
|
35
|
+
# Eloquent: Booking::where(...), Booking::create(...), DB::table('bookings')
|
|
36
|
+
PHP_ORM_RE = re.compile(
|
|
37
|
+
r"\b([A-Z]\w*)::(query|where\w*|find\w*|first\w*|create|insert\w*|"
|
|
38
|
+
r"update\w*|upsert|destroy|all|with|latest|oldest|orderBy|count|sum|"
|
|
39
|
+
r"pluck|paginate|select)\s*\(")
|
|
40
|
+
PHP_DB_TABLE_RE = re.compile(r"\bDB::table\(\s*['\"](\w+)['\"]")
|
|
41
|
+
ORM_OP = {"create": "insert", "insert": "insert", "upsert": "insert",
|
|
42
|
+
"update": "update", "destroy": "delete"}
|
|
43
|
+
NOT_MODELS = {"DB", "Route", "Schema", "Carbon", "Str", "Arr", "Auth", "Cache",
|
|
44
|
+
"Log", "Http", "Storage", "Validator", "Hash", "Session", "Config",
|
|
45
|
+
"Gate", "Mail", "Event", "Queue", "App", "Request", "Response"}
|
|
46
|
+
SCHEMA_RE = re.compile(r"Schema::(create|table)\s*\(\s*['\"](\w+)['\"]")
|
|
47
|
+
BLUEPRINT_COL_RE = re.compile(r"\$\w+->(\w+)\s*\(\s*(?:['\"](\w+)['\"])?")
|
|
48
|
+
BLUEPRINT_IMPLIED = {"id": ["id"], "timestamps": ["created_at", "updated_at"],
|
|
49
|
+
"timestampsTz": ["created_at", "updated_at"],
|
|
50
|
+
"softDeletes": ["deleted_at"], "rememberToken": ["remember_token"]}
|
|
51
|
+
BLUEPRINT_SKIP = {"index", "unique", "primary", "foreign", "dropColumn",
|
|
52
|
+
"dropForeign", "dropIndex", "renameColumn", "fullText",
|
|
53
|
+
"spatialIndex", "comment", "engine", "charset", "collation"}
|
|
54
|
+
ELOQUENT_RE = re.compile(
|
|
55
|
+
r"^\s*(?:final\s+)?class\s+(\w+)\s+extends\s+(?:Model|Authenticatable|Pivot)\b",
|
|
56
|
+
re.MULTILINE)
|
|
57
|
+
PHP_TABLE_PROP_RE = re.compile(r"\$table\s*=\s*['\"](\w+)['\"]")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _snake(name: str) -> str:
|
|
61
|
+
return re.sub(r"(?<=[a-z0-9])([A-Z])", r"_\1", name).lower()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _plural_candidates(snake: str) -> List[str]:
|
|
65
|
+
"""Laravel's table guess for a model, best first (Booking -> bookings)."""
|
|
66
|
+
if snake.endswith("y") and snake[-2:-1] not in "aeiou":
|
|
67
|
+
first = snake[:-1] + "ies"
|
|
68
|
+
elif snake.endswith(("s", "x", "z", "ch", "sh")):
|
|
69
|
+
first = snake + "es"
|
|
70
|
+
else:
|
|
71
|
+
first = snake + "s"
|
|
72
|
+
return [first, snake] # bare name covers uncountables like `equipment`
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _php_member_calls(body: str) -> List[str]:
|
|
76
|
+
return [m.group(1) for m in PHP_MEMBER_CALL_RE.finditer(body)]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _php_db_refs(body: str, line: int) -> List[DbRef]:
|
|
80
|
+
refs = [DbRef(table=m.group(1), op="query", via="sql", line=line)
|
|
81
|
+
for m in PHP_DB_TABLE_RE.finditer(body)]
|
|
82
|
+
for m in PHP_ORM_RE.finditer(body):
|
|
83
|
+
if m.group(1) in NOT_MODELS:
|
|
84
|
+
continue
|
|
85
|
+
op = next((v for k, v in ORM_OP.items() if m.group(2).startswith(k)), "select")
|
|
86
|
+
refs.append(DbRef(table=m.group(1), op=op, via="orm-guess", line=line))
|
|
87
|
+
return refs
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def parse_laravel(info: FileInfo, text: str) -> None:
|
|
91
|
+
"""File-level Laravel facts: `use` imports, view() links, migrations,
|
|
92
|
+
Eloquent models. Cheap regex passes; nothing matches in non-Laravel PHP."""
|
|
93
|
+
info.imports.extend(m.group(1).replace("\\", "/") for m in PHP_USE_RE.finditer(text))
|
|
94
|
+
for m in PHP_VIEW_RE.finditer(text):
|
|
95
|
+
info.links.append("resources/views/" + m.group(1).replace(".", "/") + ".blade.php")
|
|
96
|
+
|
|
97
|
+
for m in SCHEMA_RE.finditer(text):
|
|
98
|
+
end = _brace_end_line(text, m.end())
|
|
99
|
+
block = "\n".join(text.split("\n")[_line_of(text, m.start()) - 1:end or None])
|
|
100
|
+
cols: List[str] = []
|
|
101
|
+
for c in BLUEPRINT_COL_RE.finditer(block):
|
|
102
|
+
method, col = c.group(1), c.group(2)
|
|
103
|
+
if method in BLUEPRINT_SKIP:
|
|
104
|
+
continue
|
|
105
|
+
for name in BLUEPRINT_IMPLIED.get(method, [col] if col else []):
|
|
106
|
+
if name not in cols:
|
|
107
|
+
cols.append(name)
|
|
108
|
+
# Schema::table() only alters: it adds columns but doesn't define the table
|
|
109
|
+
info.tables.append(TableInfo(name=m.group(2), fields=cols[:40],
|
|
110
|
+
line=_line_of(text, m.start()),
|
|
111
|
+
source=info.path if m.group(1) == "create" else ""))
|
|
112
|
+
|
|
113
|
+
for m in ELOQUENT_RE.finditer(text):
|
|
114
|
+
cls = m.group(1)
|
|
115
|
+
explicit = PHP_TABLE_PROP_RE.search(text)
|
|
116
|
+
cands = [explicit.group(1)] if explicit else _plural_candidates(_snake(cls))
|
|
117
|
+
# no source: the migration defines the table; the model links to it by edge
|
|
118
|
+
info.tables.append(TableInfo(name=cands[0], model=cls, aliases=cands,
|
|
119
|
+
line=_line_of(text, m.start())))
|
|
120
|
+
|
|
26
121
|
LANG_PATTERNS: Dict[str, Dict[str, Pattern]] = {
|
|
27
122
|
"go": {
|
|
28
123
|
"function": re.compile(
|
|
@@ -173,8 +268,16 @@ def parse_generic(info: FileInfo, text: str, language: str) -> None:
|
|
|
173
268
|
cname = cm.group(1)
|
|
174
269
|
if cname in KEYWORDS or cname == name or cname in seen:
|
|
175
270
|
continue
|
|
271
|
+
if language == "php" and body[max(0, cm.start() - 2):cm.start()] == "->":
|
|
272
|
+
continue # $obj->method(): unknown receiver, see PHP_MEMBER_CALL_RE
|
|
176
273
|
seen.add(cname)
|
|
177
274
|
fn.calls.append(cname)
|
|
275
|
+
if language == "php":
|
|
276
|
+
for cname in _php_member_calls(body):
|
|
277
|
+
if cname not in seen and cname != name and cname not in KEYWORDS:
|
|
278
|
+
seen.add(cname)
|
|
279
|
+
fn.calls.append(cname)
|
|
280
|
+
fn.db_refs.extend(_php_db_refs(body, start_line))
|
|
178
281
|
fn.calls = fn.calls[:40]
|
|
179
282
|
fn.description = describe(fn, body[:400])
|
|
180
283
|
info.functions.append(fn)
|
|
@@ -196,6 +299,10 @@ def parse_generic(info: FileInfo, text: str, language: str) -> None:
|
|
|
196
299
|
|
|
197
300
|
if language in ("php", "ruby"):
|
|
198
301
|
extract_toplevel(info, text)
|
|
302
|
+
if language == "php":
|
|
303
|
+
parse_laravel(info, text)
|
|
304
|
+
info.imports = sorted(set(info.imports))[:60]
|
|
305
|
+
info.links = list(dict.fromkeys(info.links))[:30]
|
|
199
306
|
|
|
200
307
|
|
|
201
308
|
PAGE_LINK_RE = re.compile(
|
|
@@ -218,14 +325,23 @@ def extract_toplevel(info: FileInfo, text: str) -> None:
|
|
|
218
325
|
def outside(line: int) -> bool:
|
|
219
326
|
return not any(s <= line <= e for s, e in fn_ranges)
|
|
220
327
|
|
|
328
|
+
is_php = info.language == "php"
|
|
221
329
|
seen = set()
|
|
222
330
|
for m in CALL_RE.finditer(text):
|
|
223
331
|
name = m.group(1)
|
|
224
332
|
if name in TOPLEVEL_NOISE or name in seen:
|
|
225
333
|
continue
|
|
334
|
+
if is_php and text[max(0, m.start() - 2):m.start()] == "->":
|
|
335
|
+
continue
|
|
226
336
|
if outside(_line_of(text, m.start())):
|
|
227
337
|
seen.add(name)
|
|
228
338
|
info.calls.append(name)
|
|
339
|
+
if is_php: # routes/web.php closures call App\X::y() at top level
|
|
340
|
+
for m in PHP_MEMBER_CALL_RE.finditer(text):
|
|
341
|
+
name = m.group(1)
|
|
342
|
+
if name not in seen and name not in TOPLEVEL_NOISE and outside(_line_of(text, m.start())):
|
|
343
|
+
seen.add(name)
|
|
344
|
+
info.calls.append(name)
|
|
229
345
|
info.calls = info.calls[:40]
|
|
230
346
|
|
|
231
347
|
seen_l = set()
|
|
@@ -85,32 +85,45 @@ class _SimpleGitignore:
|
|
|
85
85
|
|
|
86
86
|
|
|
87
87
|
class GitignoreStack:
|
|
88
|
-
"""
|
|
88
|
+
"""Every .gitignore in the tree, each scoped to its own folder — like git.
|
|
89
|
+
|
|
90
|
+
Frameworks lean on nested ones (Laravel's storage/framework/views/.gitignore
|
|
91
|
+
is just `*` + `!.gitignore`), so reading only the root file lets compiled
|
|
92
|
+
caches flood the scan. Uses pathspec when available.
|
|
93
|
+
"""
|
|
89
94
|
|
|
90
95
|
def __init__(self, root: str):
|
|
91
96
|
self.root = root
|
|
92
|
-
self.
|
|
93
|
-
self.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
97
|
+
self._rules: List[Tuple[str, object]] = [] # (folder rel to root, matcher)
|
|
98
|
+
self.load_dir(root, "")
|
|
99
|
+
|
|
100
|
+
def load_dir(self, abs_dir: str, rel_dir: str) -> None:
|
|
101
|
+
gi = os.path.join(abs_dir, ".gitignore")
|
|
102
|
+
if not os.path.isfile(gi):
|
|
103
|
+
return
|
|
104
|
+
try:
|
|
105
|
+
with open(gi, "r", encoding="utf-8", errors="replace") as f:
|
|
106
|
+
lines = f.readlines()
|
|
107
|
+
except OSError:
|
|
108
|
+
return
|
|
109
|
+
try:
|
|
110
|
+
import pathspec # type: ignore
|
|
111
|
+
matcher: object = pathspec.GitIgnoreSpec.from_lines(lines)
|
|
112
|
+
except ImportError:
|
|
113
|
+
matcher = _SimpleGitignore(lines)
|
|
114
|
+
self._rules.append((rel_dir.replace(os.sep, "/").strip("/"), matcher))
|
|
106
115
|
|
|
107
116
|
def ignored(self, rel_path: str, is_dir: bool) -> bool:
|
|
108
117
|
rel = rel_path.replace(os.sep, "/")
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
118
|
+
for base, m in self._rules:
|
|
119
|
+
if base and not rel.startswith(base + "/"):
|
|
120
|
+
continue
|
|
121
|
+
sub = rel[len(base) + 1:] if base else rel
|
|
122
|
+
if isinstance(m, _SimpleGitignore):
|
|
123
|
+
if m.matches(sub, is_dir):
|
|
124
|
+
return True
|
|
125
|
+
elif m.match_file(sub + "/" if is_dir else sub):
|
|
126
|
+
return True
|
|
114
127
|
return False
|
|
115
128
|
|
|
116
129
|
|
|
@@ -157,6 +170,7 @@ def scan(root: str, max_file_kb: int = 1024) -> Dict:
|
|
|
157
170
|
continue
|
|
158
171
|
if gitignore.ignored(rel, True):
|
|
159
172
|
continue
|
|
173
|
+
gitignore.load_dir(entry.path, rel)
|
|
160
174
|
child = walk_dir(entry.path, rel)
|
|
161
175
|
# keep dirs that contain anything (incl. warning stubs)
|
|
162
176
|
if child["children"] or child.get("warning"):
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codebread
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.3
|
|
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
|
|
7
|
-
Keywords: codebase,analyzer,visualizer,call-graph,architecture
|
|
7
|
+
Keywords: codebase,analyzer,visualizer,call-graph,architecture,dependency-graph,code-visualization,static-analysis,code-map
|
|
8
8
|
Requires-Python: >=3.9
|
|
9
9
|
Description-Content-Type: text/markdown
|
|
10
10
|
License-File: LICENSE
|
|
@@ -23,6 +23,11 @@ Dynamic: license-file
|
|
|
23
23
|
Files, functions, API routes and database tables, mapped in your browser.
|
|
24
24
|
</p>
|
|
25
25
|
|
|
26
|
+
<p align="center">
|
|
27
|
+
An interactive codebase visualizer: call graph, dependency map and API-to-database tracing<br>
|
|
28
|
+
for Python, JavaScript, TypeScript, PHP, Java, Go, C# and Ruby.
|
|
29
|
+
</p>
|
|
30
|
+
|
|
26
31
|
<p align="center">
|
|
27
32
|
<a href="https://pypi.org/project/codebread/"><img alt="PyPI" src="https://img.shields.io/pypi/v/codebread?color=b0541c&label=pypi"></a>
|
|
28
33
|
<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>
|
|
@@ -4,13 +4,14 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "codebread"
|
|
7
|
-
version = "1.1.
|
|
7
|
+
version = "1.1.3"
|
|
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"
|
|
11
11
|
license = { text = "MIT" }
|
|
12
12
|
authors = [{ name = "Danial Irsyad" }]
|
|
13
|
-
keywords = ["codebase", "analyzer", "visualizer", "call-graph", "architecture"
|
|
13
|
+
keywords = ["codebase", "analyzer", "visualizer", "call-graph", "architecture",
|
|
14
|
+
"dependency-graph", "code-visualization", "static-analysis", "code-map"]
|
|
14
15
|
dependencies = []
|
|
15
16
|
|
|
16
17
|
[project.optional-dependencies]
|
|
@@ -102,3 +102,65 @@ def test_redact_secrets_preserves_line_count():
|
|
|
102
102
|
text = "A=1\nB=2\nPASSWORD=x\nC=3\n"
|
|
103
103
|
out = redact_secrets(text)
|
|
104
104
|
assert len(out.splitlines()) == len(text.splitlines())
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_laravel_migrations_models_routes_and_calls():
|
|
108
|
+
from codebread.connections import build_graph
|
|
109
|
+
|
|
110
|
+
def php(path, src):
|
|
111
|
+
info = FileInfo(path=path, language="php")
|
|
112
|
+
parse_generic(info, src, "php")
|
|
113
|
+
return info
|
|
114
|
+
|
|
115
|
+
files = [
|
|
116
|
+
php("database/migrations/1_create_bookings.php", """<?php
|
|
117
|
+
return new class extends Migration { public function up(): void {
|
|
118
|
+
Schema::create('bookings', function (Blueprint $table) {
|
|
119
|
+
$table->id(); $table->string('reference'); $table->timestamps();
|
|
120
|
+
});
|
|
121
|
+
Schema::create('equipment', function (Blueprint $table) { $table->id(); });
|
|
122
|
+
} };"""),
|
|
123
|
+
php("database/migrations/2_add_status.php", """<?php
|
|
124
|
+
return new class extends Migration { public function up(): void {
|
|
125
|
+
Schema::table('bookings', function (Blueprint $table) { $table->string('status'); });
|
|
126
|
+
} };"""),
|
|
127
|
+
php("app/Models/Booking.php", "<?php\nclass Booking extends Model {\n"
|
|
128
|
+
" public function user() { return $this->belongsTo(User::class); }\n}\n"),
|
|
129
|
+
php("app/Models/Equipment.php", "<?php\nclass Equipment extends Model {}\n"),
|
|
130
|
+
php("app/Support/Icons.php", "<?php\nclass Icons { public static function label($k) { return $k; } }\n"),
|
|
131
|
+
php("app/Widgets/Stats.php", r"""<?php
|
|
132
|
+
use App\Support\Icons;
|
|
133
|
+
class Stats { public function table($t) {
|
|
134
|
+
return $t->label('Total')->value(Booking::where('status', 'x')->count());
|
|
135
|
+
} }"""),
|
|
136
|
+
php("routes/web.php", """<?php
|
|
137
|
+
Route::get('/', function () { return view('welcome'); });
|
|
138
|
+
Route::get('/icons', function () { return Icons::label('x'); });"""),
|
|
139
|
+
FileInfo(path="resources/views/welcome.blade.php", language="php"),
|
|
140
|
+
]
|
|
141
|
+
g = build_graph(files, {"type": "dir", "children": []}, [])
|
|
142
|
+
nodes = {n["id"]: n for n in g["nodes"]}
|
|
143
|
+
edges = {(e["source"], e["target"], e["kind"]) for e in g["edges"]}
|
|
144
|
+
|
|
145
|
+
# migrations define tables; an alter adds columns without stealing the source
|
|
146
|
+
assert nodes["table::bookings"]["fields"] == ["id", "reference", "created_at",
|
|
147
|
+
"updated_at", "status"]
|
|
148
|
+
assert nodes["table::bookings"]["file"] == "database/migrations/1_create_bookings.php"
|
|
149
|
+
# models snap to the real table: Booking -> bookings, Equipment -> equipment
|
|
150
|
+
assert nodes["table::bookings"]["model"] == "Booking"
|
|
151
|
+
assert nodes["table::equipment"]["model"] == "Equipment"
|
|
152
|
+
assert "table::equipments" not in nodes
|
|
153
|
+
# closure routes land on the routes file; view() links to the Blade file
|
|
154
|
+
assert {r["path"] for r in nodes["routes/web.php"]["routes"]} == {"/", "/icons"}
|
|
155
|
+
assert ("routes/web.php", "resources/views/welcome.blade.php", "link") in edges
|
|
156
|
+
# static calls count, $obj->label() does not; Eloquent calls reach the table
|
|
157
|
+
label = "app/Support/Icons.php::label@2"
|
|
158
|
+
assert ("routes/web.php", label, "call") in edges
|
|
159
|
+
assert not any(s.startswith("app/Widgets/Stats.php") and t == label for s, t, _ in edges)
|
|
160
|
+
assert ("app/Widgets/Stats.php::table@3", "table::bookings", "db") in edges
|
|
161
|
+
# `use App\...` resolves to the file (PSR-4)
|
|
162
|
+
assert ("app/Widgets/Stats.php", "app/Support/Icons.php", "include") in edges
|
|
163
|
+
# framework-called code isn't "unused": migration hooks, Eloquent relations
|
|
164
|
+
by_label = {(n.get("file"), n["label"]): n for n in g["nodes"]}
|
|
165
|
+
assert not by_label[("database/migrations/1_create_bookings.php", "up")].get("orphan")
|
|
166
|
+
assert not by_label[("app/Models/Booking.php", "user")].get("orphan")
|
|
@@ -82,3 +82,15 @@ def test_looks_binary_false_for_text(tmp_path):
|
|
|
82
82
|
p = tmp_path / "note.unknownext"
|
|
83
83
|
p.write_text("just plain text", encoding="utf-8")
|
|
84
84
|
assert looks_binary(str(p)) is False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_scan_respects_nested_gitignore(tmp_path):
|
|
88
|
+
# Laravel ships storage/framework/views/.gitignore = "*\n!.gitignore":
|
|
89
|
+
# compiled Blade caches must not leak into the scan
|
|
90
|
+
root = str(tmp_path)
|
|
91
|
+
_write(root, "app/keep.php", "<?php function f() {}\n")
|
|
92
|
+
_write(root, "storage/framework/views/.gitignore", "*\n!.gitignore\n")
|
|
93
|
+
_write(root, "storage/framework/views/0a1b2c.php", "<?php echo 1;\n")
|
|
94
|
+
paths = {f["path"] for f in scan(root)["files"]}
|
|
95
|
+
assert "app/keep.php" in paths
|
|
96
|
+
assert "storage/framework/views/0a1b2c.php" not in paths
|
|
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
|
|
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
|