codebread 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codebread/__init__.py +3 -0
- codebread/analyzer.py +84 -0
- codebread/classifier.py +102 -0
- codebread/cli.py +106 -0
- codebread/connections.py +407 -0
- codebread/diff.py +123 -0
- codebread/export.py +45 -0
- codebread/languages.py +110 -0
- codebread/models.py +119 -0
- codebread/parsers/__init__.py +53 -0
- codebread/parsers/generic_parser.py +288 -0
- codebread/parsers/javascript_parser.py +371 -0
- codebread/parsers/python_parser.py +291 -0
- codebread/scanner.py +206 -0
- codebread/server.py +86 -0
- codebread/web/app.js +1716 -0
- codebread/web/index.html +188 -0
- codebread/web/style.css +601 -0
- codebread-1.0.0.dist-info/METADATA +254 -0
- codebread-1.0.0.dist-info/RECORD +24 -0
- codebread-1.0.0.dist-info/WHEEL +5 -0
- codebread-1.0.0.dist-info/entry_points.txt +2 -0
- codebread-1.0.0.dist-info/licenses/LICENSE +21 -0
- codebread-1.0.0.dist-info/top_level.txt +1 -0
codebread/connections.py
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""Connection mapper: turns per-file facts into a single graph.
|
|
2
|
+
|
|
3
|
+
Node kinds: file, function (incl. methods), table.
|
|
4
|
+
Edge kinds:
|
|
5
|
+
call — function A calls function B
|
|
6
|
+
api — frontend fetch/axios/requests call matched to a backend route
|
|
7
|
+
db — function reads/writes a table
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import posixpath
|
|
12
|
+
import re
|
|
13
|
+
from typing import Dict, List, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
from .models import FileInfo, FunctionInfo, TableInfo
|
|
16
|
+
|
|
17
|
+
COMMON_NAMES = { # never resolve these across files by name alone
|
|
18
|
+
"main", "run", "init", "setup", "get", "set", "update", "handle",
|
|
19
|
+
"start", "stop", "index", "render", "log", "test", "create", "close",
|
|
20
|
+
"open", "load", "save", "send", "next", "call", "apply", "toString",
|
|
21
|
+
"connect", "execute", "process", "add", "remove", "push", "pop",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
# never flag these as orphans by name alone — common framework entry points
|
|
25
|
+
# (constructors, lifecycle hooks, test functions) that are called implicitly
|
|
26
|
+
ENTRY_NAMES = {
|
|
27
|
+
"main", "__init__", "__main__", "__new__", "__call__", "setup", "setUp",
|
|
28
|
+
"tearDown", "run", "handler", "index", "render", "constructor",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _norm_path(p: str) -> List[str]:
|
|
33
|
+
"""Normalize an URL/route path into comparable segments.
|
|
34
|
+
':id', '{id}', '<id>', '${...}', '{param}' all become '*'."""
|
|
35
|
+
p = re.sub(r"^[a-z]+://[^/]*", "", p.strip())
|
|
36
|
+
p = p.split("?")[0].split("#")[0]
|
|
37
|
+
segs = []
|
|
38
|
+
for s in p.split("/"):
|
|
39
|
+
s = s.strip()
|
|
40
|
+
if not s:
|
|
41
|
+
continue
|
|
42
|
+
if (s.startswith(":") or s.startswith("{") or s.startswith("<")
|
|
43
|
+
or "${" in s or "{param}" in s or s.startswith("*")):
|
|
44
|
+
segs.append("*")
|
|
45
|
+
else:
|
|
46
|
+
segs.append(s.lower())
|
|
47
|
+
return segs
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _paths_match(a: List[str], b: List[str]) -> bool:
|
|
51
|
+
if len(a) != len(b):
|
|
52
|
+
# allow route prefixes mounted elsewhere: match on trailing segments
|
|
53
|
+
if len(a) > len(b):
|
|
54
|
+
a = a[-len(b):] if b else a
|
|
55
|
+
else:
|
|
56
|
+
b = b[-len(a):] if a else b
|
|
57
|
+
if len(a) != len(b) or not a:
|
|
58
|
+
return False
|
|
59
|
+
return all(x == y or x == "*" or y == "*" for x, y in zip(a, b))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _methods_match(call_m: str, route_m: str) -> bool:
|
|
63
|
+
if call_m == "ANY" or route_m == "ANY":
|
|
64
|
+
return True
|
|
65
|
+
return bool(set(call_m.split("/")) & set(route_m.split("/")))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
_RESOLVE_EXTS = (".js", ".ts", ".jsx", ".tsx", ".mjs", ".vue", ".svelte",
|
|
69
|
+
".py", ".php", ".rb")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _resolve_file_ref(base_file: str, target: str, file_set) -> Optional[str]:
|
|
73
|
+
"""Resolve an import/include/link string to a scanned file path."""
|
|
74
|
+
target = target.replace("\\", "/").strip()
|
|
75
|
+
if not target or "://" in target:
|
|
76
|
+
return None
|
|
77
|
+
base_dir = posixpath.dirname(base_file)
|
|
78
|
+
candidates = [
|
|
79
|
+
posixpath.normpath(posixpath.join(base_dir, target)),
|
|
80
|
+
posixpath.normpath(target.lstrip("/")),
|
|
81
|
+
target.lstrip("./"),
|
|
82
|
+
]
|
|
83
|
+
# python dotted modules: models / app.utils -> models.py / app/utils.py
|
|
84
|
+
if "/" not in target and "." not in posixpath.basename(target):
|
|
85
|
+
candidates.append(target.replace(".", "/"))
|
|
86
|
+
candidates.append(posixpath.join(base_dir, target.replace(".", "/")))
|
|
87
|
+
for cand in candidates:
|
|
88
|
+
if cand.startswith(".."):
|
|
89
|
+
continue
|
|
90
|
+
if cand in file_set:
|
|
91
|
+
return cand
|
|
92
|
+
for ext in _RESOLVE_EXTS:
|
|
93
|
+
if cand + ext in file_set:
|
|
94
|
+
return cand + ext
|
|
95
|
+
idx = posixpath.join(cand, "index" + ext)
|
|
96
|
+
if idx in file_set:
|
|
97
|
+
return idx
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _find_cycles(adj: Dict[str, List[str]]) -> List[List[str]]:
|
|
102
|
+
"""Iterative DFS cycle detection over a directed graph (call edges).
|
|
103
|
+
Returns one representative cycle (as a list of node ids) per strongly
|
|
104
|
+
connected loop found — not an exhaustive enumeration of every cycle,
|
|
105
|
+
but enough to flag circular call chains without recursion-depth risk
|
|
106
|
+
on large codebases."""
|
|
107
|
+
WHITE, GRAY, BLACK = 0, 1, 2
|
|
108
|
+
color: Dict[str, int] = {}
|
|
109
|
+
cycles: List[List[str]] = []
|
|
110
|
+
seen_keys = set()
|
|
111
|
+
|
|
112
|
+
for start in adj:
|
|
113
|
+
if color.get(start, WHITE) != WHITE:
|
|
114
|
+
continue
|
|
115
|
+
stack = [(start, iter(adj.get(start, [])))]
|
|
116
|
+
path = [start]
|
|
117
|
+
color[start] = GRAY
|
|
118
|
+
while stack:
|
|
119
|
+
node, it = stack[-1]
|
|
120
|
+
advanced = False
|
|
121
|
+
for nxt in it:
|
|
122
|
+
c = color.get(nxt, WHITE)
|
|
123
|
+
if c == WHITE:
|
|
124
|
+
color[nxt] = GRAY
|
|
125
|
+
stack.append((nxt, iter(adj.get(nxt, []))))
|
|
126
|
+
path.append(nxt)
|
|
127
|
+
advanced = True
|
|
128
|
+
break
|
|
129
|
+
elif c == GRAY:
|
|
130
|
+
idx = path.index(nxt)
|
|
131
|
+
cyc = path[idx:]
|
|
132
|
+
key = frozenset(cyc)
|
|
133
|
+
if len(cyc) > 1 and key not in seen_keys:
|
|
134
|
+
seen_keys.add(key)
|
|
135
|
+
cycles.append(cyc)
|
|
136
|
+
if not advanced:
|
|
137
|
+
color[stack[-1][0]] = BLACK
|
|
138
|
+
stack.pop()
|
|
139
|
+
path.pop()
|
|
140
|
+
return cycles
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _mark_orphans_and_cycles(nodes: List[Dict], edges: List[Dict]) -> List[Dict]:
|
|
144
|
+
"""Flag functions/methods with no detected callers (orphans) and
|
|
145
|
+
functions involved in a circular call chain. Returns the cycle list
|
|
146
|
+
(each entry: {"nodes": [...], "labels": [...]}) for the UI panel."""
|
|
147
|
+
call_adj: Dict[str, List[str]] = {}
|
|
148
|
+
in_call_count: Dict[str, int] = {}
|
|
149
|
+
for e in edges:
|
|
150
|
+
if e["kind"] == "call":
|
|
151
|
+
call_adj.setdefault(e["source"], []).append(e["target"])
|
|
152
|
+
if e["kind"] in ("call", "api"):
|
|
153
|
+
in_call_count[e["target"]] = in_call_count.get(e["target"], 0) + 1
|
|
154
|
+
|
|
155
|
+
cycles = _find_cycles(call_adj)
|
|
156
|
+
cycle_node_ids = {nid for cyc in cycles for nid in cyc}
|
|
157
|
+
node_by_id = {n["id"]: n for n in nodes}
|
|
158
|
+
|
|
159
|
+
for n in nodes:
|
|
160
|
+
if n["id"] in cycle_node_ids:
|
|
161
|
+
n["cycle"] = True
|
|
162
|
+
if n["kind"] not in ("function", "method"):
|
|
163
|
+
continue
|
|
164
|
+
if n.get("routes"):
|
|
165
|
+
continue
|
|
166
|
+
label = n["label"]
|
|
167
|
+
if label in ENTRY_NAMES or label.startswith("test_") or label.startswith("Test"):
|
|
168
|
+
continue
|
|
169
|
+
if in_call_count.get(n["id"], 0) == 0:
|
|
170
|
+
n["orphan"] = True
|
|
171
|
+
|
|
172
|
+
for e in edges:
|
|
173
|
+
if e["kind"] == "call" and e["source"] in cycle_node_ids \
|
|
174
|
+
and e["target"] in cycle_node_ids:
|
|
175
|
+
e["cycle"] = True
|
|
176
|
+
|
|
177
|
+
return [{"nodes": cyc,
|
|
178
|
+
"labels": [node_by_id[nid]["label"] for nid in cyc if nid in node_by_id]}
|
|
179
|
+
for cyc in cycles]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def build_graph(files: List[FileInfo], tree: Dict,
|
|
183
|
+
warnings: List[Dict]) -> Dict:
|
|
184
|
+
nodes: List[Dict] = []
|
|
185
|
+
edges: List[Dict] = []
|
|
186
|
+
edge_seen = set()
|
|
187
|
+
|
|
188
|
+
def add_edge(src: str, dst: str, kind: str, label: str = ""):
|
|
189
|
+
if src == dst:
|
|
190
|
+
return
|
|
191
|
+
key = (src, dst, kind)
|
|
192
|
+
if key in edge_seen:
|
|
193
|
+
return
|
|
194
|
+
edge_seen.add(key)
|
|
195
|
+
edges.append({"source": src, "target": dst, "kind": kind,
|
|
196
|
+
"label": label})
|
|
197
|
+
|
|
198
|
+
# ---- nodes -------------------------------------------------------
|
|
199
|
+
fn_nodes: Dict[Tuple[str, str], str] = {} # (file, fn name) -> node id
|
|
200
|
+
name_index: Dict[str, List[Tuple[str, FileInfo, FunctionInfo]]] = {}
|
|
201
|
+
model_to_table: Dict[str, str] = {} # ORM class name -> table node id
|
|
202
|
+
table_ids: Dict[str, str] = {}
|
|
203
|
+
|
|
204
|
+
for f in files:
|
|
205
|
+
file_id = f.path
|
|
206
|
+
nodes.append({
|
|
207
|
+
"id": file_id, "kind": "file", "label": f.path.split("/")[-1],
|
|
208
|
+
"file": f.path, "layer": f.layer, "language": f.language,
|
|
209
|
+
"loc": f.loc, "warnings": f.warnings,
|
|
210
|
+
"nFunctions": len(f.functions),
|
|
211
|
+
"imports": f.imports[:30], "dbConfig": f.db_config,
|
|
212
|
+
"source": f.source,
|
|
213
|
+
})
|
|
214
|
+
for fn in f.functions:
|
|
215
|
+
nid = f"{f.path}::{fn.name}@{fn.line}"
|
|
216
|
+
fn_nodes[(f.path, fn.name)] = nid
|
|
217
|
+
name_index.setdefault(fn.name, []).append((nid, f, fn))
|
|
218
|
+
nodes.append({
|
|
219
|
+
"id": nid, "kind": fn.kind, "label": fn.name,
|
|
220
|
+
"file": f.path, "layer": f.layer, "line": fn.line,
|
|
221
|
+
"endLine": fn.end_line, "params": fn.params,
|
|
222
|
+
"returns": fn.returns, "description": fn.description,
|
|
223
|
+
"parentClass": fn.parent_class, "index": fn.index,
|
|
224
|
+
"language": f.language, "code": fn.code,
|
|
225
|
+
"routes": [{"method": r.method, "path": r.path}
|
|
226
|
+
for r in fn.routes],
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
def table_node(t_name: str, t: Optional[TableInfo] = None) -> str:
|
|
230
|
+
key = t_name.lower()
|
|
231
|
+
if key in table_ids:
|
|
232
|
+
nid = table_ids[key]
|
|
233
|
+
if t is not None: # enrich a placeholder with real definition
|
|
234
|
+
for n in nodes:
|
|
235
|
+
if n["id"] == nid:
|
|
236
|
+
if t.fields:
|
|
237
|
+
n["fields"] = t.fields
|
|
238
|
+
if t.model:
|
|
239
|
+
n["model"] = t.model
|
|
240
|
+
if t.source:
|
|
241
|
+
n["file"] = t.source
|
|
242
|
+
break
|
|
243
|
+
return nid
|
|
244
|
+
nid = f"table::{key}"
|
|
245
|
+
table_ids[key] = nid
|
|
246
|
+
nodes.append({
|
|
247
|
+
"id": nid, "kind": "table", "label": t_name,
|
|
248
|
+
"layer": "database",
|
|
249
|
+
"file": t.source if t else "",
|
|
250
|
+
"fields": t.fields if t else [],
|
|
251
|
+
"model": t.model if t else "",
|
|
252
|
+
})
|
|
253
|
+
return nid
|
|
254
|
+
|
|
255
|
+
for f in files:
|
|
256
|
+
for t in f.tables:
|
|
257
|
+
nid = table_node(t.name, t)
|
|
258
|
+
if t.model:
|
|
259
|
+
model_to_table[t.model.lower()] = nid
|
|
260
|
+
|
|
261
|
+
# ---- call edges --------------------------------------------------
|
|
262
|
+
for f in files:
|
|
263
|
+
local = {fn.name for fn in f.functions}
|
|
264
|
+
imported_mods = {i.split("/")[-1].split(".")[-1] for i in f.imports}
|
|
265
|
+
for fn in f.functions:
|
|
266
|
+
src = fn_nodes[(f.path, fn.name)]
|
|
267
|
+
for callee in fn.calls:
|
|
268
|
+
if callee == fn.name:
|
|
269
|
+
continue
|
|
270
|
+
if callee in local:
|
|
271
|
+
add_edge(src, fn_nodes[(f.path, callee)], "call",
|
|
272
|
+
f"{fn.name}() → {callee}()")
|
|
273
|
+
continue
|
|
274
|
+
candidates = name_index.get(callee, [])
|
|
275
|
+
if not candidates or callee in COMMON_NAMES:
|
|
276
|
+
continue
|
|
277
|
+
if len(candidates) == 1:
|
|
278
|
+
add_edge(src, candidates[0][0], "call",
|
|
279
|
+
f"{fn.name}() → {callee}()")
|
|
280
|
+
else:
|
|
281
|
+
# prefer a candidate whose file is imported by this file
|
|
282
|
+
hits = [c for c in candidates
|
|
283
|
+
if c[1].path.split("/")[-1].rsplit(".", 1)[0]
|
|
284
|
+
in imported_mods]
|
|
285
|
+
if len(hits) == 1:
|
|
286
|
+
add_edge(src, hits[0][0], "call",
|
|
287
|
+
f"{fn.name}() → {callee}()")
|
|
288
|
+
|
|
289
|
+
# ---- file-level connections ---------------------------------------
|
|
290
|
+
file_set = {f.path for f in files}
|
|
291
|
+
for f in files:
|
|
292
|
+
fname = f.path.split("/")[-1]
|
|
293
|
+
local = {fn.name: fn_nodes[(f.path, fn.name)] for fn in f.functions}
|
|
294
|
+
# top-level calls: page/script code calling into functions
|
|
295
|
+
for callee in f.calls:
|
|
296
|
+
if callee in local:
|
|
297
|
+
continue # a file already implicitly contains its own fns
|
|
298
|
+
candidates = name_index.get(callee, [])
|
|
299
|
+
if not candidates or callee in COMMON_NAMES:
|
|
300
|
+
continue
|
|
301
|
+
if len(candidates) == 1:
|
|
302
|
+
add_edge(f.path, candidates[0][0], "call",
|
|
303
|
+
f"{fname} → {callee}()")
|
|
304
|
+
# includes / imports that resolve to a scanned file
|
|
305
|
+
for imp in f.imports:
|
|
306
|
+
tgt = _resolve_file_ref(f.path, imp, file_set)
|
|
307
|
+
if tgt and tgt != f.path:
|
|
308
|
+
add_edge(f.path, tgt, "include",
|
|
309
|
+
f"{fname} includes {tgt.split('/')[-1]}")
|
|
310
|
+
# page navigation links (redirect/href/action)
|
|
311
|
+
for ln in f.links:
|
|
312
|
+
tgt = _resolve_file_ref(f.path, ln, file_set)
|
|
313
|
+
if tgt and tgt != f.path:
|
|
314
|
+
add_edge(f.path, tgt, "link",
|
|
315
|
+
f"{fname} links to {tgt.split('/')[-1]}")
|
|
316
|
+
|
|
317
|
+
# ---- route index (backend endpoints) -----------------------------
|
|
318
|
+
# (method, segments, handler node id, pretty label)
|
|
319
|
+
route_index: List[Tuple[str, List[str], str, str]] = []
|
|
320
|
+
for f in files:
|
|
321
|
+
for fn in f.functions:
|
|
322
|
+
nid = fn_nodes[(f.path, fn.name)]
|
|
323
|
+
for r in fn.routes:
|
|
324
|
+
route_index.append((r.method, _norm_path(r.path), nid,
|
|
325
|
+
f"{r.method} {r.path}"))
|
|
326
|
+
for r in f.routes: # file-level routes referencing a handler by name
|
|
327
|
+
handler_id = None
|
|
328
|
+
if r.handler and (f.path, r.handler) in fn_nodes:
|
|
329
|
+
handler_id = fn_nodes[(f.path, r.handler)]
|
|
330
|
+
elif r.handler and len(name_index.get(r.handler, [])) == 1:
|
|
331
|
+
handler_id = name_index[r.handler][0][0]
|
|
332
|
+
target = handler_id or f.path
|
|
333
|
+
route_index.append((r.method, _norm_path(r.path), target,
|
|
334
|
+
f"{r.method} {r.path}"))
|
|
335
|
+
if handler_id:
|
|
336
|
+
# mark handler node with its route
|
|
337
|
+
for n in nodes:
|
|
338
|
+
if n["id"] == handler_id:
|
|
339
|
+
n.setdefault("routes", []).append(
|
|
340
|
+
{"method": r.method, "path": r.path})
|
|
341
|
+
break
|
|
342
|
+
|
|
343
|
+
# ---- api edges (frontend call -> backend route) -------------------
|
|
344
|
+
def match_route(method: str, url: str) -> Optional[Tuple[str, str]]:
|
|
345
|
+
segs = _norm_path(url)
|
|
346
|
+
if not segs:
|
|
347
|
+
return None
|
|
348
|
+
best = None
|
|
349
|
+
for r_method, r_segs, target, label in route_index:
|
|
350
|
+
if not _methods_match(method, r_method):
|
|
351
|
+
continue
|
|
352
|
+
if _paths_match(segs, r_segs):
|
|
353
|
+
exact = sum(1 for x, y in zip(segs, r_segs) if x == y)
|
|
354
|
+
if best is None or exact > best[0]:
|
|
355
|
+
best = (exact, target, label)
|
|
356
|
+
return (best[1], best[2]) if best else None
|
|
357
|
+
|
|
358
|
+
for f in files:
|
|
359
|
+
callers = [(fn_nodes[(f.path, fn.name)], fn.api_calls)
|
|
360
|
+
for fn in f.functions]
|
|
361
|
+
callers.append((f.path, f.api_calls))
|
|
362
|
+
for src, api_calls in callers:
|
|
363
|
+
for call in api_calls:
|
|
364
|
+
hit = match_route(call.method, call.url)
|
|
365
|
+
if hit:
|
|
366
|
+
add_edge(src, hit[0], "api",
|
|
367
|
+
f"{call.method} {call.url} → {hit[1]}")
|
|
368
|
+
|
|
369
|
+
# ---- db edges ------------------------------------------------------
|
|
370
|
+
for f in files:
|
|
371
|
+
for fn in f.functions:
|
|
372
|
+
src = fn_nodes[(f.path, fn.name)]
|
|
373
|
+
for ref in fn.db_refs:
|
|
374
|
+
tid = model_to_table.get(ref.table.lower()) \
|
|
375
|
+
or table_ids.get(ref.table.lower())
|
|
376
|
+
if tid is None:
|
|
377
|
+
# unknown table: create it only for confident refs
|
|
378
|
+
if ref.via == "sql" or ref.table.lower() in table_ids:
|
|
379
|
+
tid = table_node(ref.table)
|
|
380
|
+
elif ref.table[0].isupper() and model_to_table:
|
|
381
|
+
continue # unmatched model name in a modeled project
|
|
382
|
+
else:
|
|
383
|
+
tid = table_node(ref.table)
|
|
384
|
+
verb = {"query": "queries", "select": "reads",
|
|
385
|
+
"insert": "inserts into", "update": "updates",
|
|
386
|
+
"delete": "deletes from",
|
|
387
|
+
"define": "defines"}.get(ref.op, ref.op)
|
|
388
|
+
add_edge(src, tid, "db", f"{fn.name}() {verb} {ref.table}")
|
|
389
|
+
|
|
390
|
+
# ---- orphans + circular dependencies --------------------------------
|
|
391
|
+
cycles = _mark_orphans_and_cycles(nodes, edges)
|
|
392
|
+
|
|
393
|
+
# ---- stats ---------------------------------------------------------
|
|
394
|
+
n_fns = sum(1 for n in nodes if n["kind"] in ("function", "method"))
|
|
395
|
+
n_orphans = sum(1 for n in nodes if n.get("orphan"))
|
|
396
|
+
stats = {
|
|
397
|
+
"files": len(files),
|
|
398
|
+
"functions": n_fns,
|
|
399
|
+
"tables": len(table_ids),
|
|
400
|
+
"connections": len(edges),
|
|
401
|
+
"routes": len(route_index),
|
|
402
|
+
"warnings": len(warnings) + sum(len(f.warnings) for f in files),
|
|
403
|
+
"orphans": n_orphans,
|
|
404
|
+
"cycles": len(cycles),
|
|
405
|
+
}
|
|
406
|
+
return {"nodes": nodes, "edges": edges, "tree": tree,
|
|
407
|
+
"warnings": warnings, "cycles": cycles, "stats": stats}
|
codebread/diff.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Diff two exported graph JSONs to see what changed between scans."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Dict, List, Tuple
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _index_files(graph: Dict) -> Dict[str, Dict]:
|
|
8
|
+
return {n["id"]: n for n in graph.get("nodes", []) if n["kind"] == "file"}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _index_functions(graph: Dict) -> Dict[str, Dict]:
|
|
12
|
+
out = {}
|
|
13
|
+
for n in graph.get("nodes", []):
|
|
14
|
+
if n["kind"] in ("function", "method"):
|
|
15
|
+
out[f"{n.get('file', '')}::{n.get('label', '')}"] = n
|
|
16
|
+
return out
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _index_tables(graph: Dict) -> Dict[str, Dict]:
|
|
20
|
+
return {n["label"].lower(): n for n in graph.get("nodes", []) if n["kind"] == "table"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def compute_diff(old: Dict, new: Dict) -> Dict:
|
|
24
|
+
"""Compare two graph dicts (as produced by analyzer.analyze / loaded
|
|
25
|
+
from export_json) and return what was added, removed and changed."""
|
|
26
|
+
old_files, new_files = _index_files(old), _index_files(new)
|
|
27
|
+
old_fns, new_fns = _index_functions(old), _index_functions(new)
|
|
28
|
+
old_tables, new_tables = _index_tables(old), _index_tables(new)
|
|
29
|
+
|
|
30
|
+
files_added = sorted(set(new_files) - set(old_files))
|
|
31
|
+
files_removed = sorted(set(old_files) - set(new_files))
|
|
32
|
+
|
|
33
|
+
fn_added = sorted(set(new_fns) - set(old_fns))
|
|
34
|
+
fn_removed = sorted(set(old_fns) - set(new_fns))
|
|
35
|
+
fn_changed: List[Tuple[str, List[str]]] = []
|
|
36
|
+
for key in sorted(set(old_fns) & set(new_fns)):
|
|
37
|
+
a, b = old_fns[key], new_fns[key]
|
|
38
|
+
diffs = []
|
|
39
|
+
if a.get("params") != b.get("params"):
|
|
40
|
+
diffs.append(f"params {a.get('params')} -> {b.get('params')}")
|
|
41
|
+
if a.get("returns") != b.get("returns"):
|
|
42
|
+
diffs.append(f"returns {a.get('returns')!r} -> {b.get('returns')!r}")
|
|
43
|
+
if a.get("line") != b.get("line"):
|
|
44
|
+
diffs.append(f"moved line {a.get('line')} -> {b.get('line')}")
|
|
45
|
+
if diffs:
|
|
46
|
+
fn_changed.append((key, diffs))
|
|
47
|
+
|
|
48
|
+
tables_added = sorted(set(new_tables) - set(old_tables))
|
|
49
|
+
tables_removed = sorted(set(old_tables) - set(new_tables))
|
|
50
|
+
tables_changed: List[Tuple[str, List[str], List[str]]] = []
|
|
51
|
+
for key in sorted(set(old_tables) & set(new_tables)):
|
|
52
|
+
a, b = old_tables[key], new_tables[key]
|
|
53
|
+
af, bf = set(a.get("fields") or []), set(b.get("fields") or [])
|
|
54
|
+
if af != bf:
|
|
55
|
+
tables_changed.append((key, sorted(bf - af), sorted(af - bf)))
|
|
56
|
+
|
|
57
|
+
old_stats, new_stats = old.get("stats", {}), new.get("stats", {})
|
|
58
|
+
stats_delta = {k: new_stats.get(k, 0) - old_stats.get(k, 0)
|
|
59
|
+
for k in set(old_stats) | set(new_stats)}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
"files_added": files_added, "files_removed": files_removed,
|
|
63
|
+
"functions_added": fn_added, "functions_removed": fn_removed,
|
|
64
|
+
"functions_changed": fn_changed,
|
|
65
|
+
"tables_added": tables_added, "tables_removed": tables_removed,
|
|
66
|
+
"tables_changed": tables_changed,
|
|
67
|
+
"stats_delta": stats_delta,
|
|
68
|
+
"old_meta": old.get("meta", {}), "new_meta": new.get("meta", {}),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def format_diff_report(d: Dict) -> str:
|
|
73
|
+
lines = []
|
|
74
|
+
om, nm = d["old_meta"], d["new_meta"]
|
|
75
|
+
lines.append(f"Diff: {om.get('name', 'old')} ({om.get('scannedAt', '?')}) -> "
|
|
76
|
+
f"{nm.get('name', 'new')} ({nm.get('scannedAt', '?')})")
|
|
77
|
+
lines.append("")
|
|
78
|
+
|
|
79
|
+
def bucket(title: str, added: List[str], removed: List[str]) -> None:
|
|
80
|
+
if not added and not removed:
|
|
81
|
+
return
|
|
82
|
+
lines.append(title)
|
|
83
|
+
for x in added:
|
|
84
|
+
lines.append(f" + {x}")
|
|
85
|
+
for x in removed:
|
|
86
|
+
lines.append(f" - {x}")
|
|
87
|
+
lines.append("")
|
|
88
|
+
|
|
89
|
+
bucket("Files:", d["files_added"], d["files_removed"])
|
|
90
|
+
bucket("Functions:", d["functions_added"], d["functions_removed"])
|
|
91
|
+
if d["functions_changed"]:
|
|
92
|
+
lines.append("Functions changed:")
|
|
93
|
+
for key, diffs in d["functions_changed"]:
|
|
94
|
+
lines.append(f" ~ {key}")
|
|
95
|
+
for line in diffs:
|
|
96
|
+
lines.append(f" {line}")
|
|
97
|
+
lines.append("")
|
|
98
|
+
bucket("Tables:", d["tables_added"], d["tables_removed"])
|
|
99
|
+
if d["tables_changed"]:
|
|
100
|
+
lines.append("Table fields changed:")
|
|
101
|
+
for key, added, removed in d["tables_changed"]:
|
|
102
|
+
bits = []
|
|
103
|
+
if added:
|
|
104
|
+
bits.append("+" + ",".join(added))
|
|
105
|
+
if removed:
|
|
106
|
+
bits.append("-" + ",".join(removed))
|
|
107
|
+
lines.append(f" ~ {key}: {'; '.join(bits)}")
|
|
108
|
+
lines.append("")
|
|
109
|
+
|
|
110
|
+
nonzero = {k: v for k, v in d["stats_delta"].items() if v}
|
|
111
|
+
if nonzero:
|
|
112
|
+
lines.append("Stats delta:")
|
|
113
|
+
for k, v in sorted(nonzero.items()):
|
|
114
|
+
sign = "+" if v > 0 else ""
|
|
115
|
+
lines.append(f" {k}: {sign}{v}")
|
|
116
|
+
lines.append("")
|
|
117
|
+
|
|
118
|
+
if not any([d["files_added"], d["files_removed"], d["functions_added"],
|
|
119
|
+
d["functions_removed"], d["functions_changed"],
|
|
120
|
+
d["tables_added"], d["tables_removed"], d["tables_changed"]]):
|
|
121
|
+
lines.append("No structural changes detected.")
|
|
122
|
+
|
|
123
|
+
return "\n".join(lines)
|
codebread/export.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Exports: graph JSON (re-loadable) and a single-file static HTML."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from typing import Dict
|
|
7
|
+
|
|
8
|
+
WEB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "web")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def export_json(graph: Dict, out_path: str) -> str:
|
|
12
|
+
out_path = os.path.abspath(out_path)
|
|
13
|
+
with open(out_path, "w", encoding="utf-8") as f:
|
|
14
|
+
json.dump(graph, f, ensure_ascii=False, indent=1)
|
|
15
|
+
return out_path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def load_json(path: str) -> Dict:
|
|
19
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
20
|
+
return json.load(f)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def export_html(graph: Dict, out_path: str) -> str:
|
|
24
|
+
"""Inline CSS+JS+data into one shareable HTML file."""
|
|
25
|
+
with open(os.path.join(WEB_DIR, "index.html"), encoding="utf-8") as f:
|
|
26
|
+
html = f.read()
|
|
27
|
+
with open(os.path.join(WEB_DIR, "style.css"), encoding="utf-8") as f:
|
|
28
|
+
css = f.read()
|
|
29
|
+
with open(os.path.join(WEB_DIR, "app.js"), encoding="utf-8") as f:
|
|
30
|
+
js = f.read()
|
|
31
|
+
|
|
32
|
+
data = json.dumps(graph, ensure_ascii=False)
|
|
33
|
+
data = data.replace("</", "<\\/") # keep </script> out of the payload
|
|
34
|
+
html = html.replace(
|
|
35
|
+
'<link rel="stylesheet" href="style.css">',
|
|
36
|
+
f"<style>\n{css}\n</style>")
|
|
37
|
+
html = html.replace(
|
|
38
|
+
'<script src="app.js"></script>',
|
|
39
|
+
f"<script>window.CODEBREAD_DATA = {data};</script>\n"
|
|
40
|
+
f"<script>\n{js}\n</script>")
|
|
41
|
+
|
|
42
|
+
out_path = os.path.abspath(out_path)
|
|
43
|
+
with open(out_path, "w", encoding="utf-8") as f:
|
|
44
|
+
f.write(html)
|
|
45
|
+
return out_path
|