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.
Files changed (42) hide show
  1. {codebread-1.1.1 → codebread-1.1.4}/PKG-INFO +10 -4
  2. {codebread-1.1.1 → codebread-1.1.4}/README.md +9 -3
  3. {codebread-1.1.1 → codebread-1.1.4}/codebread/connections.py +41 -10
  4. {codebread-1.1.1 → codebread-1.1.4}/codebread/context.py +34 -8
  5. {codebread-1.1.1 → codebread-1.1.4}/codebread/models.py +3 -0
  6. {codebread-1.1.1 → codebread-1.1.4}/codebread/parsers/generic_parser.py +116 -0
  7. {codebread-1.1.1 → codebread-1.1.4}/codebread/scanner.py +34 -20
  8. {codebread-1.1.1 → codebread-1.1.4}/codebread/web/app.js +50 -0
  9. {codebread-1.1.1 → codebread-1.1.4}/codebread/web/index.html +13 -0
  10. {codebread-1.1.1 → codebread-1.1.4}/codebread/web/style.css +37 -5
  11. {codebread-1.1.1 → codebread-1.1.4}/codebread.egg-info/PKG-INFO +10 -4
  12. {codebread-1.1.1 → codebread-1.1.4}/pyproject.toml +1 -1
  13. {codebread-1.1.1 → codebread-1.1.4}/tests/test_generic_parser.py +62 -0
  14. {codebread-1.1.1 → codebread-1.1.4}/tests/test_scanner.py +12 -0
  15. {codebread-1.1.1 → codebread-1.1.4}/LICENSE +0 -0
  16. {codebread-1.1.1 → codebread-1.1.4}/codebread/__init__.py +0 -0
  17. {codebread-1.1.1 → codebread-1.1.4}/codebread/analyzer.py +0 -0
  18. {codebread-1.1.1 → codebread-1.1.4}/codebread/classifier.py +0 -0
  19. {codebread-1.1.1 → codebread-1.1.4}/codebread/cli.py +0 -0
  20. {codebread-1.1.1 → codebread-1.1.4}/codebread/deobfuscate.py +0 -0
  21. {codebread-1.1.1 → codebread-1.1.4}/codebread/diff.py +0 -0
  22. {codebread-1.1.1 → codebread-1.1.4}/codebread/export.py +0 -0
  23. {codebread-1.1.1 → codebread-1.1.4}/codebread/languages.py +0 -0
  24. {codebread-1.1.1 → codebread-1.1.4}/codebread/parsers/__init__.py +0 -0
  25. {codebread-1.1.1 → codebread-1.1.4}/codebread/parsers/javascript_parser.py +0 -0
  26. {codebread-1.1.1 → codebread-1.1.4}/codebread/parsers/python_parser.py +0 -0
  27. {codebread-1.1.1 → codebread-1.1.4}/codebread/server.py +0 -0
  28. {codebread-1.1.1 → codebread-1.1.4}/codebread.egg-info/SOURCES.txt +0 -0
  29. {codebread-1.1.1 → codebread-1.1.4}/codebread.egg-info/dependency_links.txt +0 -0
  30. {codebread-1.1.1 → codebread-1.1.4}/codebread.egg-info/entry_points.txt +0 -0
  31. {codebread-1.1.1 → codebread-1.1.4}/codebread.egg-info/requires.txt +0 -0
  32. {codebread-1.1.1 → codebread-1.1.4}/codebread.egg-info/top_level.txt +0 -0
  33. {codebread-1.1.1 → codebread-1.1.4}/setup.cfg +0 -0
  34. {codebread-1.1.1 → codebread-1.1.4}/tests/test_analyzer.py +0 -0
  35. {codebread-1.1.1 → codebread-1.1.4}/tests/test_config_parser.py +0 -0
  36. {codebread-1.1.1 → codebread-1.1.4}/tests/test_connections.py +0 -0
  37. {codebread-1.1.1 → codebread-1.1.4}/tests/test_context.py +0 -0
  38. {codebread-1.1.1 → codebread-1.1.4}/tests/test_deobfuscate.py +0 -0
  39. {codebread-1.1.1 → codebread-1.1.4}/tests/test_javascript_parser.py +0 -0
  40. {codebread-1.1.1 → codebread-1.1.4}/tests/test_python_parser.py +0 -0
  41. {codebread-1.1.1 → codebread-1.1.4}/tests/test_server.py +0 -0
  42. {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.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=b0541c&label=pypi"></a>
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=b0541c&label=pypi"></a>
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"] = t.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
- 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
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 = 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]))
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}` | `{fn['file']}:{fn['label']}()` |" for m, p, fn in routes]
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.get("description"):
129
- line += f": {fn['description']}"
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.get("description"): # descriptions already name their tables
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
- elif fn.get("orphan"):
143
- extra.append("no callers found")
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
- """Loads the root .gitignore (via pathspec when available)."""
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._spec = None
93
- self._simple: Optional[_SimpleGitignore] = None
94
- gi = os.path.join(root, ".gitignore")
95
- if os.path.isfile(gi):
96
- try:
97
- with open(gi, "r", encoding="utf-8", errors="replace") as f:
98
- lines = f.readlines()
99
- except OSError:
100
- lines = []
101
- try:
102
- import pathspec # type: ignore
103
- self._spec = pathspec.PathSpec.from_lines("gitwildmatch", lines)
104
- except ImportError:
105
- self._simple = _SimpleGitignore(lines)
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
- if self._spec is not None:
110
- probe = rel + "/" if is_dir else rel
111
- return self._spec.match_file(probe)
112
- if self._simple is not None:
113
- return self._simple.matches(rel, is_dir)
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
- --accent: #b0541c;
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: rgba(176, 84, 28, 0.08);
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: #e59a62;
55
- --accent-ink: #1a0f08;
56
- --soft: rgba(229, 154, 98, 0.11);
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.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=b0541c&label=pypi"></a>
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.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