codebread 1.0.5__py3-none-any.whl → 1.1.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 +18 -14
- codebread/cli.py +13 -0
- codebread/context.py +170 -0
- codebread/web/app.js +562 -52
- codebread/web/index.html +93 -29
- codebread/web/style.css +675 -411
- codebread-1.1.0.dist-info/METADATA +274 -0
- {codebread-1.0.5.dist-info → codebread-1.1.0.dist-info}/RECORD +12 -11
- {codebread-1.0.5.dist-info → codebread-1.1.0.dist-info}/WHEEL +1 -1
- codebread-1.0.5.dist-info/METADATA +0 -356
- {codebread-1.0.5.dist-info → codebread-1.1.0.dist-info}/entry_points.txt +0 -0
- {codebread-1.0.5.dist-info → codebread-1.1.0.dist-info}/licenses/LICENSE +0 -0
- {codebread-1.0.5.dist-info → codebread-1.1.0.dist-info}/top_level.txt +0 -0
codebread/__init__.py
CHANGED
|
@@ -2,17 +2,30 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import os
|
|
5
|
+
import re
|
|
5
6
|
|
|
6
7
|
|
|
7
8
|
def _read_version() -> str:
|
|
8
9
|
"""Single source of truth: pyproject.toml's [project] version.
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
A source checkout (pyproject.toml sitting next to the package) wins:
|
|
12
|
+
an editable `pip install -e .` freezes its metadata at install time,
|
|
13
|
+
so after a version bump importlib.metadata keeps reporting the old
|
|
14
|
+
number until you reinstall. Wheel installs have no pyproject.toml
|
|
15
|
+
beside them and fall through to their own (always correct) metadata.
|
|
16
|
+
No toml parser needed for one `version = "..."` line — keeps the
|
|
17
|
+
zero-dependency promise.
|
|
15
18
|
"""
|
|
19
|
+
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
20
|
+
try:
|
|
21
|
+
with open(os.path.join(root, "pyproject.toml"), encoding="utf-8") as f:
|
|
22
|
+
text = f.read()
|
|
23
|
+
if re.search(r"""^name\s*=\s*["']codebread["']""", text, re.M):
|
|
24
|
+
m = re.search(r"""^version\s*=\s*["']([^"']+)["']""", text, re.M)
|
|
25
|
+
if m:
|
|
26
|
+
return m.group(1)
|
|
27
|
+
except OSError:
|
|
28
|
+
pass
|
|
16
29
|
try:
|
|
17
30
|
from importlib.metadata import PackageNotFoundError, version
|
|
18
31
|
try:
|
|
@@ -21,15 +34,6 @@ def _read_version() -> str:
|
|
|
21
34
|
pass
|
|
22
35
|
except ImportError:
|
|
23
36
|
pass
|
|
24
|
-
try:
|
|
25
|
-
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
26
|
-
with open(os.path.join(root, "pyproject.toml"), encoding="utf-8") as f:
|
|
27
|
-
for line in f:
|
|
28
|
-
line = line.strip()
|
|
29
|
-
if line.startswith("version"):
|
|
30
|
-
return line.split("=", 1)[1].strip().strip("\"'")
|
|
31
|
-
except OSError:
|
|
32
|
-
pass
|
|
33
37
|
return "0.0.0+unknown"
|
|
34
38
|
|
|
35
39
|
|
codebread/cli.py
CHANGED
|
@@ -37,6 +37,11 @@ def main(argv=None) -> int:
|
|
|
37
37
|
help="also export the full graph as JSON")
|
|
38
38
|
parser.add_argument("--html", metavar="OUT.html",
|
|
39
39
|
help="also export a self-contained static HTML file")
|
|
40
|
+
parser.add_argument("--md", nargs="?", const="", metavar="OUT.md",
|
|
41
|
+
help="also write an AI context brief (Markdown) - a "
|
|
42
|
+
"compact map of the project an assistant can read "
|
|
43
|
+
"instead of every file. Default: CODEBREAD.md in "
|
|
44
|
+
"the scanned folder")
|
|
40
45
|
parser.add_argument("--no-open", action="store_true",
|
|
41
46
|
help="don't auto-open the browser")
|
|
42
47
|
parser.add_argument("--no-serve", action="store_true",
|
|
@@ -89,6 +94,14 @@ def main(argv=None) -> int:
|
|
|
89
94
|
print(f"[codebread] {graph['stats']['warnings']} warning(s) - "
|
|
90
95
|
f"they are shown as badges in the UI, nothing was hidden.")
|
|
91
96
|
|
|
97
|
+
from .context import export_markdown, to_markdown
|
|
98
|
+
graph["contextMd"] = to_markdown(graph) # powers the report's "Copy for AI" / download
|
|
99
|
+
if args.md is not None:
|
|
100
|
+
default_dir = graph.get("meta", {}).get("root") or os.getcwd()
|
|
101
|
+
if not os.path.isdir(default_dir): # --load of a graph scanned elsewhere
|
|
102
|
+
default_dir = os.getcwd()
|
|
103
|
+
p = export_markdown(graph, args.md or os.path.join(default_dir, "CODEBREAD.md"))
|
|
104
|
+
print(f"[codebread] AI context brief written to {p}")
|
|
92
105
|
if args.json:
|
|
93
106
|
p = export_json(graph, args.json)
|
|
94
107
|
print(f"[codebread] Graph JSON written to {p}")
|
codebread/context.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""AI context brief: the whole graph as compact Markdown.
|
|
2
|
+
|
|
3
|
+
An assistant that reads every source file to learn a project burns tokens
|
|
4
|
+
on code it doesn't need. This brief gives it the map instead: what each
|
|
5
|
+
file does, its functions, routes, tables, and who calls whom. It reads
|
|
6
|
+
it once, then opens only the files that matter for the task.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from collections import Counter, defaultdict
|
|
12
|
+
from typing import Dict, Iterable
|
|
13
|
+
|
|
14
|
+
LAYER_ORDER = ["frontend", "backend", "database", "config", "unknown"]
|
|
15
|
+
LAYER_NAME = {"frontend": "Frontend", "backend": "Backend", "database": "Database",
|
|
16
|
+
"config": "Config", "unknown": "Unclassified"}
|
|
17
|
+
FNS_PER_FILE = 40 # ponytail: hard cap keeps huge files from flooding the brief
|
|
18
|
+
LIST_CAP = 8
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _short(items: Iterable[str], cap: int = LIST_CAP) -> str:
|
|
22
|
+
items = list(dict.fromkeys(items)) # dedupe, keep order
|
|
23
|
+
more = f" (+{len(items) - cap} more)" if len(items) > cap else ""
|
|
24
|
+
return ", ".join(items[:cap]) + more
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def to_markdown(graph: Dict) -> str:
|
|
28
|
+
nodes = {n["id"]: n for n in graph.get("nodes", [])}
|
|
29
|
+
edges = [e for e in graph.get("edges", [])
|
|
30
|
+
if e["kind"] != "contains" and e["source"] in nodes and e["target"] in nodes]
|
|
31
|
+
meta, stats = graph.get("meta", {}), graph.get("stats", {})
|
|
32
|
+
files = sorted((n for n in nodes.values() if n["kind"] == "file"), key=lambda n: n["file"])
|
|
33
|
+
tables = sorted((n for n in nodes.values() if n["kind"] == "table"), key=lambda n: n["label"])
|
|
34
|
+
fns = defaultdict(list)
|
|
35
|
+
for n in nodes.values():
|
|
36
|
+
if n["kind"] in ("function", "method") and n.get("file"):
|
|
37
|
+
fns[n["file"]].append(n)
|
|
38
|
+
for fl in fns.values():
|
|
39
|
+
fl.sort(key=lambda n: n.get("line") or 0)
|
|
40
|
+
out_e, in_e = defaultdict(list), defaultdict(list)
|
|
41
|
+
for e in edges:
|
|
42
|
+
out_e[e["source"]].append(e)
|
|
43
|
+
in_e[e["target"]].append(e)
|
|
44
|
+
|
|
45
|
+
def ref(nid: str, here: str = "") -> str:
|
|
46
|
+
n = nodes[nid]
|
|
47
|
+
if n["kind"] in ("function", "method"):
|
|
48
|
+
return f"`{n['label']}()`" if n.get("file") == here else f"`{n.get('file')}:{n['label']}()`"
|
|
49
|
+
if n["kind"] == "table":
|
|
50
|
+
return f"`{n['label']}` (table)"
|
|
51
|
+
return f"`{n.get('file') or n['label']}`"
|
|
52
|
+
|
|
53
|
+
def owner(nid: str) -> str:
|
|
54
|
+
n = nodes[nid]
|
|
55
|
+
return n["id"] if n["kind"] in ("file", "table") else (n.get("file") or n["id"])
|
|
56
|
+
|
|
57
|
+
name = meta.get("name") or "project"
|
|
58
|
+
L = [f"# {name}: codebase context", "",
|
|
59
|
+
f"> Generated by CodeBread {meta.get('version', '')} on {str(meta.get('scannedAt', ''))[:10]}. "
|
|
60
|
+
"This is a map of the whole project: what each file does, its functions, and how "
|
|
61
|
+
"they connect. Read it first and open source files only when you need exact code.", ""]
|
|
62
|
+
|
|
63
|
+
# ---- at a glance
|
|
64
|
+
langs = Counter(f.get("language") or "?" for f in files)
|
|
65
|
+
layers = Counter(f.get("layer") or "unknown" for f in files)
|
|
66
|
+
|
|
67
|
+
def layer_rank(k: str) -> int:
|
|
68
|
+
return LAYER_ORDER.index(k) if k in LAYER_ORDER else len(LAYER_ORDER)
|
|
69
|
+
|
|
70
|
+
L += ["## At a glance", "",
|
|
71
|
+
f"- {stats.get('files', len(files))} files, {stats.get('functions', 0)} functions, "
|
|
72
|
+
f"{stats.get('tables', len(tables))} tables, {stats.get('connections', len(edges))} connections",
|
|
73
|
+
"- Languages: " + ", ".join(f"{k} ({v})" for k, v in langs.most_common()),
|
|
74
|
+
"- Layers: " + ", ".join(f"{LAYER_NAME.get(k, k)} ({v})"
|
|
75
|
+
for k, v in sorted(layers.items(), key=lambda kv: layer_rank(kv[0]))),
|
|
76
|
+
""]
|
|
77
|
+
|
|
78
|
+
# ---- entry points
|
|
79
|
+
routes = sorted(((r["method"], r["path"], fn) for fl in fns.values() for fn in fl
|
|
80
|
+
for r in fn.get("routes") or []), key=lambda x: (x[1], x[0]))
|
|
81
|
+
if routes:
|
|
82
|
+
L += ["## HTTP routes", "", "| Method | Path | Handler |", "|---|---|---|"]
|
|
83
|
+
L += [f"| {m} | `{p}` | `{fn['file']}:{fn['label']}()` |" for m, p, fn in routes]
|
|
84
|
+
L.append("")
|
|
85
|
+
|
|
86
|
+
# ---- data model
|
|
87
|
+
if tables:
|
|
88
|
+
L += ["## Database tables", ""]
|
|
89
|
+
for t in tables:
|
|
90
|
+
bits = []
|
|
91
|
+
if t.get("fields"):
|
|
92
|
+
bits.append("fields: " + _short(t["fields"], 15))
|
|
93
|
+
if t.get("model"):
|
|
94
|
+
bits.append(f"model `{t['model']}`")
|
|
95
|
+
users = [ref(e["source"]) for e in in_e[t["id"]]]
|
|
96
|
+
if users:
|
|
97
|
+
bits.append("used by " + _short(users))
|
|
98
|
+
where = f" in `{t['file']}`" if t.get("file") else ""
|
|
99
|
+
L.append(f"- **{t['label']}**{where}" + (": " + "; ".join(bits) if bits else ""))
|
|
100
|
+
L.append("")
|
|
101
|
+
|
|
102
|
+
# ---- files, grouped by layer
|
|
103
|
+
L += ["## Files", ""]
|
|
104
|
+
by_layer = defaultdict(list)
|
|
105
|
+
for f in files:
|
|
106
|
+
by_layer[f.get("layer") or "unknown"].append(f)
|
|
107
|
+
for layer in sorted(by_layer, key=layer_rank):
|
|
108
|
+
L += [f"### {LAYER_NAME.get(layer, layer)}", ""]
|
|
109
|
+
for f in by_layer[layer]:
|
|
110
|
+
path = f["file"]
|
|
111
|
+
members = [f["id"]] + [fn["id"] for fn in fns.get(path, [])]
|
|
112
|
+
talks = [ref(owner(e["target"])) for m in members for e in out_e[m]
|
|
113
|
+
if owner(e["target"]) != f["id"]]
|
|
114
|
+
heard = [ref(owner(e["source"])) for m in members for e in in_e[m]
|
|
115
|
+
if owner(e["source"]) != f["id"]]
|
|
116
|
+
L.append(f"#### `{path}` ({f.get('language', '?')}, {f.get('loc', 0)} lines)")
|
|
117
|
+
if talks:
|
|
118
|
+
L.append("Uses: " + _short(talks))
|
|
119
|
+
if heard:
|
|
120
|
+
L.append("Used by: " + _short(heard))
|
|
121
|
+
if f.get("warnings"):
|
|
122
|
+
L.append("Warnings: " + "; ".join(f["warnings"][:3]))
|
|
123
|
+
fl = fns.get(path, [])
|
|
124
|
+
for fn in fl[:FNS_PER_FILE]:
|
|
125
|
+
params = ", ".join(fn.get("params") or [])
|
|
126
|
+
cls = f"{fn['parentClass']}." if fn.get("parentClass") else ""
|
|
127
|
+
line = f"- `{cls}{fn['label']}({params})` L{fn.get('line', '?')}"
|
|
128
|
+
if fn.get("description"):
|
|
129
|
+
line += f": {fn['description']}"
|
|
130
|
+
calls = [ref(e["target"], path) for e in out_e[fn["id"]] if e["kind"] != "db"]
|
|
131
|
+
tbls = [nodes[e["target"]]["label"] for e in out_e[fn["id"]] if e["kind"] == "db"]
|
|
132
|
+
callers = [ref(e["source"], path) for e in in_e[fn["id"]]]
|
|
133
|
+
extra = []
|
|
134
|
+
if calls:
|
|
135
|
+
extra.append("calls " + _short(calls))
|
|
136
|
+
if tbls and not fn.get("description"): # descriptions already name their tables
|
|
137
|
+
extra.append("tables " + _short(tbls))
|
|
138
|
+
if callers:
|
|
139
|
+
extra.append("called by " + _short(callers))
|
|
140
|
+
if fn.get("cycle"):
|
|
141
|
+
extra.append("in a call cycle")
|
|
142
|
+
elif fn.get("orphan"):
|
|
143
|
+
extra.append("no callers found")
|
|
144
|
+
if extra:
|
|
145
|
+
line += " | " + "; ".join(extra)
|
|
146
|
+
L.append(line)
|
|
147
|
+
if len(fl) > FNS_PER_FILE:
|
|
148
|
+
L.append(f"- … {len(fl) - FNS_PER_FILE} more functions")
|
|
149
|
+
L.append("")
|
|
150
|
+
|
|
151
|
+
# ---- things to be careful about
|
|
152
|
+
notes = []
|
|
153
|
+
for c in graph.get("cycles", [])[:20]:
|
|
154
|
+
notes.append("Call cycle: " + " → ".join(f"`{x}()`" for x in c["labels"] + c["labels"][:1]))
|
|
155
|
+
for w in graph.get("warnings", [])[:20]:
|
|
156
|
+
notes.append(f"Warning in `{w.get('path', '')}`: {w.get('message', '')}")
|
|
157
|
+
obf = [f["file"] for f in files if f.get("obfuscation")]
|
|
158
|
+
if obf:
|
|
159
|
+
notes.append("Obfuscated code in: " + _short(f"`{p}`" for p in obf))
|
|
160
|
+
if notes:
|
|
161
|
+
L += ["## Watch out", ""] + [f"- {n}" for n in notes] + [""]
|
|
162
|
+
|
|
163
|
+
return "\n".join(L).rstrip() + "\n"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def export_markdown(graph: Dict, out_path: str) -> str:
|
|
167
|
+
out_path = os.path.abspath(out_path)
|
|
168
|
+
with open(out_path, "w", encoding="utf-8") as f:
|
|
169
|
+
f.write(to_markdown(graph))
|
|
170
|
+
return out_path
|