codebread 1.1.1__tar.gz → 1.1.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.1.1 → codebread-1.1.4}/PKG-INFO +10 -4
- {codebread-1.1.1 → codebread-1.1.4}/README.md +9 -3
- {codebread-1.1.1 → codebread-1.1.4}/codebread/connections.py +41 -10
- {codebread-1.1.1 → codebread-1.1.4}/codebread/context.py +34 -8
- {codebread-1.1.1 → codebread-1.1.4}/codebread/models.py +3 -0
- {codebread-1.1.1 → codebread-1.1.4}/codebread/parsers/generic_parser.py +116 -0
- {codebread-1.1.1 → codebread-1.1.4}/codebread/scanner.py +34 -20
- {codebread-1.1.1 → codebread-1.1.4}/codebread/web/app.js +50 -0
- {codebread-1.1.1 → codebread-1.1.4}/codebread/web/index.html +13 -0
- {codebread-1.1.1 → codebread-1.1.4}/codebread/web/style.css +37 -5
- {codebread-1.1.1 → codebread-1.1.4}/codebread.egg-info/PKG-INFO +10 -4
- {codebread-1.1.1 → codebread-1.1.4}/pyproject.toml +1 -1
- {codebread-1.1.1 → codebread-1.1.4}/tests/test_generic_parser.py +62 -0
- {codebread-1.1.1 → codebread-1.1.4}/tests/test_scanner.py +12 -0
- {codebread-1.1.1 → codebread-1.1.4}/LICENSE +0 -0
- {codebread-1.1.1 → codebread-1.1.4}/codebread/__init__.py +0 -0
- {codebread-1.1.1 → codebread-1.1.4}/codebread/analyzer.py +0 -0
- {codebread-1.1.1 → codebread-1.1.4}/codebread/classifier.py +0 -0
- {codebread-1.1.1 → codebread-1.1.4}/codebread/cli.py +0 -0
- {codebread-1.1.1 → codebread-1.1.4}/codebread/deobfuscate.py +0 -0
- {codebread-1.1.1 → codebread-1.1.4}/codebread/diff.py +0 -0
- {codebread-1.1.1 → codebread-1.1.4}/codebread/export.py +0 -0
- {codebread-1.1.1 → codebread-1.1.4}/codebread/languages.py +0 -0
- {codebread-1.1.1 → codebread-1.1.4}/codebread/parsers/__init__.py +0 -0
- {codebread-1.1.1 → codebread-1.1.4}/codebread/parsers/javascript_parser.py +0 -0
- {codebread-1.1.1 → codebread-1.1.4}/codebread/parsers/python_parser.py +0 -0
- {codebread-1.1.1 → codebread-1.1.4}/codebread/server.py +0 -0
- {codebread-1.1.1 → codebread-1.1.4}/codebread.egg-info/SOURCES.txt +0 -0
- {codebread-1.1.1 → codebread-1.1.4}/codebread.egg-info/dependency_links.txt +0 -0
- {codebread-1.1.1 → codebread-1.1.4}/codebread.egg-info/entry_points.txt +0 -0
- {codebread-1.1.1 → codebread-1.1.4}/codebread.egg-info/requires.txt +0 -0
- {codebread-1.1.1 → codebread-1.1.4}/codebread.egg-info/top_level.txt +0 -0
- {codebread-1.1.1 → codebread-1.1.4}/setup.cfg +0 -0
- {codebread-1.1.1 → codebread-1.1.4}/tests/test_analyzer.py +0 -0
- {codebread-1.1.1 → codebread-1.1.4}/tests/test_config_parser.py +0 -0
- {codebread-1.1.1 → codebread-1.1.4}/tests/test_connections.py +0 -0
- {codebread-1.1.1 → codebread-1.1.4}/tests/test_context.py +0 -0
- {codebread-1.1.1 → codebread-1.1.4}/tests/test_deobfuscate.py +0 -0
- {codebread-1.1.1 → codebread-1.1.4}/tests/test_javascript_parser.py +0 -0
- {codebread-1.1.1 → codebread-1.1.4}/tests/test_python_parser.py +0 -0
- {codebread-1.1.1 → codebread-1.1.4}/tests/test_server.py +0 -0
- {codebread-1.1.1 → codebread-1.1.4}/tests/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codebread
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.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
|
|
@@ -29,7 +29,7 @@ Dynamic: license-file
|
|
|
29
29
|
</p>
|
|
30
30
|
|
|
31
31
|
<p align="center">
|
|
32
|
-
<a href="https://pypi.org/project/codebread/"><img alt="PyPI" src="https://img.shields.io/pypi/v/codebread?color=
|
|
32
|
+
<a href="https://pypi.org/project/codebread/"><img alt="PyPI" src="https://img.shields.io/pypi/v/codebread?color=0e7490&label=pypi&cacheSeconds=3600"></a>
|
|
33
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>
|
|
34
34
|
<img alt="Python 3.9+" src="https://img.shields.io/badge/python-3.9%2B-5f5c55">
|
|
35
35
|
<img alt="No dependencies" src="https://img.shields.io/badge/dependencies-none-5f5c55">
|
|
@@ -82,6 +82,12 @@ it, all the way from a frontend `fetch()` to the database table.
|
|
|
82
82
|
|
|
83
83
|
Read any file with an outline and syntax highlighting, without leaving the page.
|
|
84
84
|
|
|
85
|
+
### Your colours
|
|
86
|
+
|
|
87
|
+
The report follows your system's light or dark mode. Click the dot at the
|
|
88
|
+
right of the top bar to pick an accent colour; your choice is saved in your
|
|
89
|
+
browser.
|
|
90
|
+
|
|
85
91
|
<p align="center">
|
|
86
92
|
<img src="https://cdn.jsdelivr.net/gh/honow48-tech/CodeBread@main/assets/code.png" alt="The Code view: file tree, function outline and highlighted source" width="880">
|
|
87
93
|
</p>
|
|
@@ -128,7 +134,7 @@ codebread --path . --html report.html --no-serve
|
|
|
128
134
|
| Python | ✓ | Flask, FastAPI, Django | requests, httpx, aiohttp | SQLAlchemy, Django, peewee |
|
|
129
135
|
| JavaScript / TypeScript | ✓ | Express, Fastify, Koa, NestJS | fetch, axios | Prisma, Mongoose, Sequelize, Knex, TypeORM |
|
|
130
136
|
| Vue / Svelte | ✓ | same as JS | fetch, axios | same as JS |
|
|
131
|
-
| PHP | ✓ | Laravel | – | raw SQL |
|
|
137
|
+
| PHP | ✓ | Laravel (incl. closures) | – | Eloquent, migrations, raw SQL |
|
|
132
138
|
| Java | ✓ | Spring | – | raw SQL |
|
|
133
139
|
| C# | ✓ | ASP.NET | – | raw SQL |
|
|
134
140
|
| Go | ✓ | Gin, net/http | – | raw SQL |
|
|
@@ -154,7 +160,7 @@ structural regex with brace matching and gets parameter names only.
|
|
|
154
160
|
| Java | regex | ✓ | ✓ | ✓ | – | – | ✓ | ✓ | – |
|
|
155
161
|
| C# | regex | ✓ | ✓ | ✓ | – | – | ✓ | ✓ | – |
|
|
156
162
|
| Go | regex | ✓ | structs | ✓ | – | – | ✓ | ✓ | – |
|
|
157
|
-
| PHP | regex | ✓ | ✓ | ✓ | – |
|
|
163
|
+
| PHP | regex | ✓ | ✓ | ✓ | – | Eloquent + migrations | ✓ | ✓ | ✓ |
|
|
158
164
|
| Ruby | regex | ✓ | ✓ | ✓ | – | – | ✓ | ✓ | ✓ |
|
|
159
165
|
| SQL | regex | – | – | – | – | tables + columns | ✓ | – | – |
|
|
160
166
|
| Config (`.env`, json, yaml, toml, ini, xml) | key scan | – | – | – | – | – | – | – | – |
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
</p>
|
|
14
14
|
|
|
15
15
|
<p align="center">
|
|
16
|
-
<a href="https://pypi.org/project/codebread/"><img alt="PyPI" src="https://img.shields.io/pypi/v/codebread?color=
|
|
16
|
+
<a href="https://pypi.org/project/codebread/"><img alt="PyPI" src="https://img.shields.io/pypi/v/codebread?color=0e7490&label=pypi&cacheSeconds=3600"></a>
|
|
17
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>
|
|
18
18
|
<img alt="Python 3.9+" src="https://img.shields.io/badge/python-3.9%2B-5f5c55">
|
|
19
19
|
<img alt="No dependencies" src="https://img.shields.io/badge/dependencies-none-5f5c55">
|
|
@@ -66,6 +66,12 @@ it, all the way from a frontend `fetch()` to the database table.
|
|
|
66
66
|
|
|
67
67
|
Read any file with an outline and syntax highlighting, without leaving the page.
|
|
68
68
|
|
|
69
|
+
### Your colours
|
|
70
|
+
|
|
71
|
+
The report follows your system's light or dark mode. Click the dot at the
|
|
72
|
+
right of the top bar to pick an accent colour; your choice is saved in your
|
|
73
|
+
browser.
|
|
74
|
+
|
|
69
75
|
<p align="center">
|
|
70
76
|
<img src="https://cdn.jsdelivr.net/gh/honow48-tech/CodeBread@main/assets/code.png" alt="The Code view: file tree, function outline and highlighted source" width="880">
|
|
71
77
|
</p>
|
|
@@ -112,7 +118,7 @@ codebread --path . --html report.html --no-serve
|
|
|
112
118
|
| Python | ✓ | Flask, FastAPI, Django | requests, httpx, aiohttp | SQLAlchemy, Django, peewee |
|
|
113
119
|
| JavaScript / TypeScript | ✓ | Express, Fastify, Koa, NestJS | fetch, axios | Prisma, Mongoose, Sequelize, Knex, TypeORM |
|
|
114
120
|
| Vue / Svelte | ✓ | same as JS | fetch, axios | same as JS |
|
|
115
|
-
| PHP | ✓ | Laravel | – | raw SQL |
|
|
121
|
+
| PHP | ✓ | Laravel (incl. closures) | – | Eloquent, migrations, raw SQL |
|
|
116
122
|
| Java | ✓ | Spring | – | raw SQL |
|
|
117
123
|
| C# | ✓ | ASP.NET | – | raw SQL |
|
|
118
124
|
| Go | ✓ | Gin, net/http | – | raw SQL |
|
|
@@ -138,7 +144,7 @@ structural regex with brace matching and gets parameter names only.
|
|
|
138
144
|
| Java | regex | ✓ | ✓ | ✓ | – | – | ✓ | ✓ | – |
|
|
139
145
|
| C# | regex | ✓ | ✓ | ✓ | – | – | ✓ | ✓ | – |
|
|
140
146
|
| Go | regex | ✓ | structs | ✓ | – | – | ✓ | ✓ | – |
|
|
141
|
-
| PHP | regex | ✓ | ✓ | ✓ | – |
|
|
147
|
+
| PHP | regex | ✓ | ✓ | ✓ | – | Eloquent + migrations | ✓ | ✓ | ✓ |
|
|
142
148
|
| Ruby | regex | ✓ | ✓ | ✓ | – | – | ✓ | ✓ | ✓ |
|
|
143
149
|
| SQL | regex | – | – | – | – | tables + columns | ✓ | – | – |
|
|
144
150
|
| Config (`.env`, json, yaml, toml, ini, xml) | key scan | – | – | – | – | – | – | – | – |
|
|
@@ -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"):
|
|
@@ -71,6 +71,7 @@ window.addEventListener("resize", invalidateRectCache);
|
|
|
71
71
|
/* ---------------- boot ---------------- */
|
|
72
72
|
function boot(data) {
|
|
73
73
|
S.data = data;
|
|
74
|
+
bindAccent(); // first, so a saved accent applies before anything animates in
|
|
74
75
|
indexData();
|
|
75
76
|
renderHeader();
|
|
76
77
|
renderLanguages();
|
|
@@ -2413,6 +2414,55 @@ function esc(s) {
|
|
|
2413
2414
|
}
|
|
2414
2415
|
|
|
2415
2416
|
|
|
2417
|
+
/* ---------------- accent color ----------------
|
|
2418
|
+
The viewer's pick sets --accent-pick; CSS derives everything else (--soft,
|
|
2419
|
+
focus rings, highlights). Dark mode gets a lightened mix so it stays legible.
|
|
2420
|
+
Saved per browser: it's a personal preference, not part of the report. */
|
|
2421
|
+
const ACCENTS = [["Cyan (logo)", null], ["Blue", "#2563eb"], ["Crust", "#b0541c"],
|
|
2422
|
+
["Green", "#3f7d3a"], ["Violet", "#7c4dbd"], ["Rose", "#c2416b"], ["Graphite", "#3f3f46"]];
|
|
2423
|
+
const ACCENT_DEFAULT = "#0e7490"; // keep in sync with --accent in style.css
|
|
2424
|
+
const ACCENT_KEY = "codebread_accent";
|
|
2425
|
+
|
|
2426
|
+
function applyAccent(hex, save = true) {
|
|
2427
|
+
const st = document.documentElement.style;
|
|
2428
|
+
if (hex) {
|
|
2429
|
+
st.setProperty("--accent-pick", hex);
|
|
2430
|
+
st.setProperty("--accent-pick-dark", `color-mix(in srgb, ${hex} 62%, white)`);
|
|
2431
|
+
} else {
|
|
2432
|
+
st.removeProperty("--accent-pick");
|
|
2433
|
+
st.removeProperty("--accent-pick-dark");
|
|
2434
|
+
}
|
|
2435
|
+
if (save) {
|
|
2436
|
+
try { hex ? localStorage.setItem(ACCENT_KEY, hex) : localStorage.removeItem(ACCENT_KEY); } catch (e) { /* no storage */ }
|
|
2437
|
+
}
|
|
2438
|
+
document.querySelectorAll(".swatch").forEach(b => b.classList.toggle("on", (b.dataset.hex || null) === hex));
|
|
2439
|
+
$("accent-input").value = hex || ACCENT_DEFAULT;
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
function bindAccent() {
|
|
2443
|
+
$("accent-swatches").innerHTML = ACCENTS.map(([name, hex]) =>
|
|
2444
|
+
`<button class="swatch" ${hex ? `data-hex="${hex}"` : ""} title="${name}" aria-label="${name}" ` +
|
|
2445
|
+
`style="background:${hex || ACCENT_DEFAULT}"></button>`).join("");
|
|
2446
|
+
let saved = null;
|
|
2447
|
+
try { saved = localStorage.getItem(ACCENT_KEY); } catch (e) { /* no storage */ }
|
|
2448
|
+
applyAccent(saved, false);
|
|
2449
|
+
|
|
2450
|
+
const btn = $("accent-btn"), pop = $("accent-pop");
|
|
2451
|
+
const toggle = (open) => {
|
|
2452
|
+
pop.classList.toggle("hidden", !open);
|
|
2453
|
+
btn.setAttribute("aria-expanded", String(open));
|
|
2454
|
+
};
|
|
2455
|
+
btn.addEventListener("click", () => toggle(pop.classList.contains("hidden")));
|
|
2456
|
+
$("accent-swatches").addEventListener("click", (ev) => {
|
|
2457
|
+
const sw = ev.target.closest(".swatch");
|
|
2458
|
+
if (sw) applyAccent(sw.dataset.hex || null);
|
|
2459
|
+
});
|
|
2460
|
+
$("accent-input").addEventListener("input", (ev) => applyAccent(ev.target.value));
|
|
2461
|
+
$("accent-reset").addEventListener("click", () => applyAccent(null));
|
|
2462
|
+
document.addEventListener("click", (ev) => { if (!ev.target.closest("#accent-wrap")) toggle(false); });
|
|
2463
|
+
document.addEventListener("keydown", (ev) => { if (ev.key === "Escape") toggle(false); });
|
|
2464
|
+
}
|
|
2465
|
+
|
|
2416
2466
|
/* ---------------- start ----------------
|
|
2417
2467
|
Must run after every top-level const above is initialized: the static
|
|
2418
2468
|
--html export inlines CODEBREAD_DATA, so boot() runs synchronously and
|
|
@@ -47,6 +47,19 @@
|
|
|
47
47
|
<button data-layer="database">Database</button>
|
|
48
48
|
<button data-layer="config">Config</button>
|
|
49
49
|
</nav>
|
|
50
|
+
|
|
51
|
+
<div id="accent-wrap">
|
|
52
|
+
<button id="accent-btn" title="Accent color" aria-label="Accent color"
|
|
53
|
+
aria-haspopup="dialog" aria-expanded="false"><span></span></button>
|
|
54
|
+
<div id="accent-pop" class="hidden" role="dialog" aria-label="Accent color">
|
|
55
|
+
<div class="kicker">Accent color</div>
|
|
56
|
+
<div id="accent-swatches"></div>
|
|
57
|
+
<div class="accent-row">
|
|
58
|
+
<label class="accent-custom"><input type="color" id="accent-input"> Custom</label>
|
|
59
|
+
<button id="accent-reset" class="chip">Reset</button>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
50
63
|
</header>
|
|
51
64
|
|
|
52
65
|
<div id="main">
|
|
@@ -14,9 +14,11 @@
|
|
|
14
14
|
--ink-3: #9a978e;
|
|
15
15
|
--line: #e3e0d7;
|
|
16
16
|
--line2: #cfcbc0;
|
|
17
|
-
|
|
17
|
+
/* default: the logo's cyan, deepened for text contrast on paper (~5:1).
|
|
18
|
+
Viewers can pick another from the top bar, see applyAccent() */
|
|
19
|
+
--accent: var(--accent-pick, #0e7490);
|
|
18
20
|
--accent-ink: #fff;
|
|
19
|
-
--soft:
|
|
21
|
+
--soft: color-mix(in srgb, var(--accent) 9%, transparent);
|
|
20
22
|
--warn: #98580f;
|
|
21
23
|
--bad: #a3372a;
|
|
22
24
|
--shadow: 0 18px 40px -22px rgba(30, 24, 12, 0.35);
|
|
@@ -51,9 +53,9 @@
|
|
|
51
53
|
--ink-3: #6c6a63;
|
|
52
54
|
--line: #2a2a27;
|
|
53
55
|
--line2: #3a3934;
|
|
54
|
-
--accent: #
|
|
55
|
-
--accent-ink: #
|
|
56
|
-
--soft:
|
|
56
|
+
--accent: var(--accent-pick-dark, #67e8f9); /* the logo's exact cyan */
|
|
57
|
+
--accent-ink: #062a33;
|
|
58
|
+
--soft: color-mix(in srgb, var(--accent) 12%, transparent);
|
|
57
59
|
--warn: #e2a85c;
|
|
58
60
|
--bad: #ef8a7a;
|
|
59
61
|
--shadow: 0 18px 40px -18px rgba(0, 0, 0, 0.7);
|
|
@@ -162,6 +164,36 @@ kbd {
|
|
|
162
164
|
.sr-item .sr-path { margin-left: auto; color: var(--ink-3); font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
163
165
|
.sr-empty { padding: 10px 12px; color: var(--ink-3); font-size: 13px; }
|
|
164
166
|
|
|
167
|
+
/* ---------- accent picker ------------------------------------------------ */
|
|
168
|
+
|
|
169
|
+
#accent-wrap { position: relative; }
|
|
170
|
+
#accent-btn {
|
|
171
|
+
width: 30px; height: 30px; display: grid; place-items: center; cursor: pointer;
|
|
172
|
+
background: none; border: 1px solid var(--line2); border-radius: 50%;
|
|
173
|
+
transition: border-color .2s, transform .15s;
|
|
174
|
+
}
|
|
175
|
+
#accent-btn:hover { border-color: var(--ink); }
|
|
176
|
+
#accent-btn:active { transform: scale(.94); }
|
|
177
|
+
#accent-btn span { width: 16px; height: 16px; border-radius: 50%; background: var(--accent); transition: background .3s; }
|
|
178
|
+
#accent-pop {
|
|
179
|
+
position: absolute; top: calc(100% + 8px); right: 0; z-index: 60; width: 236px;
|
|
180
|
+
padding: 14px; background: var(--panel); border: 1px solid var(--line2); border-radius: 12px;
|
|
181
|
+
box-shadow: var(--shadow); animation: drop .18s var(--ease);
|
|
182
|
+
}
|
|
183
|
+
#accent-swatches { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; margin: 12px 0; }
|
|
184
|
+
.swatch {
|
|
185
|
+
aspect-ratio: 1; border-radius: 50%; cursor: pointer; padding: 0;
|
|
186
|
+
border: 2px solid var(--panel); box-shadow: 0 0 0 1px var(--line2);
|
|
187
|
+
transition: box-shadow .2s, transform .15s;
|
|
188
|
+
}
|
|
189
|
+
.swatch:hover { transform: scale(1.1); }
|
|
190
|
+
.swatch.on { box-shadow: 0 0 0 2px var(--ink); }
|
|
191
|
+
.accent-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
|
192
|
+
.accent-custom { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--ink-2); cursor: pointer; }
|
|
193
|
+
#accent-input { width: 28px; height: 28px; padding: 0; border: 1px solid var(--line2); border-radius: 6px; background: none; cursor: pointer; }
|
|
194
|
+
#accent-input::-webkit-color-swatch-wrapper { padding: 2px; }
|
|
195
|
+
#accent-input::-webkit-color-swatch { border: 0; border-radius: 4px; }
|
|
196
|
+
|
|
165
197
|
/* ---------- layout ----------------------------------------------------- */
|
|
166
198
|
|
|
167
199
|
#main { display: flex; flex: 1; min-height: 0; position: relative; }
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codebread
|
|
3
|
-
Version: 1.1.
|
|
3
|
+
Version: 1.1.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
|
|
@@ -29,7 +29,7 @@ Dynamic: license-file
|
|
|
29
29
|
</p>
|
|
30
30
|
|
|
31
31
|
<p align="center">
|
|
32
|
-
<a href="https://pypi.org/project/codebread/"><img alt="PyPI" src="https://img.shields.io/pypi/v/codebread?color=
|
|
32
|
+
<a href="https://pypi.org/project/codebread/"><img alt="PyPI" src="https://img.shields.io/pypi/v/codebread?color=0e7490&label=pypi&cacheSeconds=3600"></a>
|
|
33
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>
|
|
34
34
|
<img alt="Python 3.9+" src="https://img.shields.io/badge/python-3.9%2B-5f5c55">
|
|
35
35
|
<img alt="No dependencies" src="https://img.shields.io/badge/dependencies-none-5f5c55">
|
|
@@ -82,6 +82,12 @@ it, all the way from a frontend `fetch()` to the database table.
|
|
|
82
82
|
|
|
83
83
|
Read any file with an outline and syntax highlighting, without leaving the page.
|
|
84
84
|
|
|
85
|
+
### Your colours
|
|
86
|
+
|
|
87
|
+
The report follows your system's light or dark mode. Click the dot at the
|
|
88
|
+
right of the top bar to pick an accent colour; your choice is saved in your
|
|
89
|
+
browser.
|
|
90
|
+
|
|
85
91
|
<p align="center">
|
|
86
92
|
<img src="https://cdn.jsdelivr.net/gh/honow48-tech/CodeBread@main/assets/code.png" alt="The Code view: file tree, function outline and highlighted source" width="880">
|
|
87
93
|
</p>
|
|
@@ -128,7 +134,7 @@ codebread --path . --html report.html --no-serve
|
|
|
128
134
|
| Python | ✓ | Flask, FastAPI, Django | requests, httpx, aiohttp | SQLAlchemy, Django, peewee |
|
|
129
135
|
| JavaScript / TypeScript | ✓ | Express, Fastify, Koa, NestJS | fetch, axios | Prisma, Mongoose, Sequelize, Knex, TypeORM |
|
|
130
136
|
| Vue / Svelte | ✓ | same as JS | fetch, axios | same as JS |
|
|
131
|
-
| PHP | ✓ | Laravel | – | raw SQL |
|
|
137
|
+
| PHP | ✓ | Laravel (incl. closures) | – | Eloquent, migrations, raw SQL |
|
|
132
138
|
| Java | ✓ | Spring | – | raw SQL |
|
|
133
139
|
| C# | ✓ | ASP.NET | – | raw SQL |
|
|
134
140
|
| Go | ✓ | Gin, net/http | – | raw SQL |
|
|
@@ -154,7 +160,7 @@ structural regex with brace matching and gets parameter names only.
|
|
|
154
160
|
| Java | regex | ✓ | ✓ | ✓ | – | – | ✓ | ✓ | – |
|
|
155
161
|
| C# | regex | ✓ | ✓ | ✓ | – | – | ✓ | ✓ | – |
|
|
156
162
|
| Go | regex | ✓ | structs | ✓ | – | – | ✓ | ✓ | – |
|
|
157
|
-
| PHP | regex | ✓ | ✓ | ✓ | – |
|
|
163
|
+
| PHP | regex | ✓ | ✓ | ✓ | – | Eloquent + migrations | ✓ | ✓ | ✓ |
|
|
158
164
|
| Ruby | regex | ✓ | ✓ | ✓ | – | – | ✓ | ✓ | ✓ |
|
|
159
165
|
| SQL | regex | – | – | – | – | tables + columns | ✓ | – | – |
|
|
160
166
|
| Config (`.env`, json, yaml, toml, ini, xml) | key scan | – | – | – | – | – | – | – | – |
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "codebread"
|
|
7
|
-
version = "1.1.
|
|
7
|
+
version = "1.1.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"
|
|
@@ -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
|