diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f69b397129..0b2a3c867c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -25,6 +25,7 @@ Signatures below are the real ones - `tests/test_architecture_doc.py` imports ev | `export.py` | `to_json`, `to_html`, `to_obsidian`, `to_svg`, `to_graphml`, `to_canvas`, `to_cypher` | graph → graph.json, graph.html, Obsidian vault, graph.svg, … one function per format | | `wiki.py` | `to_wiki(G, communities, output_dir, ...)` | graph → one markdown article per community + `index.md` | | `callflow_html.py` | `write_callflow_html(...)` | graphify-out files → Mermaid architecture/call-flow HTML | +| `zoom_html.py` | `build_zoom_data(graph, project="")`, `write_zoom_html(graph_path, output_path, *, project_label=None)` | graph.json → one offline HTML page with three zoom levels: domains, a domain's classes by layer, a class's methods and call sites | | `ingest.py` | `ingest(url, target_dir, ...)` | URL → file saved to corpus dir | | `cache.py` | `check_semantic_cache(files, root)`, `save_semantic_cache(nodes, edges, ...)` | files → cached nodes / edges / hyperedges + the list of files still needing extraction | | `security.py` | `validate_url`, `safe_fetch`, `validate_graph_path`, `sanitize_label` | URL / path / label → validated value, or raises | diff --git a/README.md b/README.md index 1747024cce..2b765394f0 100644 --- a/README.md +++ b/README.md @@ -346,6 +346,7 @@ graphify parses ~40 programming languages locally with tree-sitter AST, and maps /graphify . --no-viz # skip the HTML, just the report + JSON /graphify . --wiki # build a markdown wiki from the graph graphify export callflow-html # Mermaid architecture/call-flow HTML (auto-regenerates on every git commit if hook is installed) +graphify export zoom-html # offline architecture view: domains, then one domain's classes by layer, then one class /graphify query "what connects auth to the database?" /graphify path "UserService" "DatabasePool" diff --git a/graphify/__main__.py b/graphify/__main__.py index e781900821..4a93c6fa95 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -989,6 +989,8 @@ def _run_cli() -> None: print(" export callflow-html emit Mermaid-based architecture/call-flow HTML") print(" [GRAPH|DIR] [--graph PATH] [--labels PATH] [--report PATH] [--sections PATH] [--output HTML]") print(" [--lang auto|zh-CN|en] [--max-sections N] [--diagram-scale N]") + print(" export zoom-html emit an offline three-level architecture view (domains, layers, class)") + print(" [--graph PATH] [--output HTML] [--label NAME]") print(" export obsidian emit Obsidian vault notes + canvas [--graph PATH] [--labels PATH] [--dir PATH]") print(" export wiki emit wiki markdown articles [--graph PATH] [--labels PATH]") print(" export svg emit graph.svg [--graph PATH] [--labels PATH]") diff --git a/graphify/cli.py b/graphify/cli.py index 7e00a85d69..fd6c6f9756 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -2974,11 +2974,12 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph": elif cmd == "export": subcmd = sys.argv[2] if len(sys.argv) > 2 else "" - if subcmd not in ("html", "callflow-html", "obsidian", "wiki", "svg", "graphml", "neo4j", "falkordb"): + if subcmd not in ("html", "callflow-html", "zoom-html", "obsidian", "wiki", "svg", "graphml", "neo4j", "falkordb"): print("Usage: graphify export ", file=sys.stderr) print(" html [--graph PATH] [--labels PATH] [--node-limit N] [--no-viz]", file=sys.stderr) print(" callflow-html [GRAPH|DIR] [--graph PATH] [--labels PATH] [--report PATH] [--sections PATH] [--output HTML]", file=sys.stderr) print(" [--lang auto|zh-CN|en] [--max-sections N] [--diagram-scale N]", file=sys.stderr) + print(" zoom-html [--graph PATH] [--output HTML] [--label NAME]", file=sys.stderr) print(" obsidian [--graph PATH] [--labels PATH] [--dir PATH]", file=sys.stderr) print(" wiki [--graph PATH] [--labels PATH]", file=sys.stderr) print(" svg [--graph PATH] [--labels PATH]", file=sys.stderr) @@ -3004,6 +3005,7 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph": callflow_diagram_scale = 1.0 callflow_max_diagram_nodes = 18 callflow_max_diagram_edges = 24 + zoom_label: str | None = None analysis_path = Path(_GRAPHIFY_OUT) / ".graphify_analysis.json" node_limit = 5000 no_viz = False @@ -3063,6 +3065,8 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph": print(" --max-diagram-nodes N representative nodes per section (default 18)") print(" --max-diagram-edges N representative edges per section (default 24)") sys.exit(0) + elif a == "--label" and i + 1 < len(args) and subcmd == "zoom-html": + zoom_label = args[i + 1]; i += 2 elif a == "--node-limit" and i + 1 < len(args): node_limit = int(args[i + 1]); i += 2 elif a == "--no-viz": @@ -3120,6 +3124,20 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph": print(f"callflow HTML written - open in any browser: {out}") sys.exit(0) + if subcmd == "zoom-html": + from graphify.zoom_html import write_zoom_html as _write_zoom_html + try: + out = _write_zoom_html( + graph_path, + callflow_output or graph_path.parent / "GRAPH_ZOOM.html", + project_label=zoom_label, + ) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + sys.exit(1) + print(f"zoom HTML written - open in any browser: {out}") + sys.exit(0) + from networkx.readwrite import json_graph as _jg from graphify.build import build_from_json as _bfj from graphify.security import check_graph_file_size_cap as _check_cap diff --git a/graphify/zoom_html.py b/graphify/zoom_html.py new file mode 100644 index 0000000000..92b6a25ebb --- /dev/null +++ b/graphify/zoom_html.py @@ -0,0 +1,1064 @@ +"""zoom_html - a three-level architecture view of graph.json (``graphify export zoom-html``). + +Level 1 shows the repository's domains, level 2 one domain with its classes laid +out in columns by layer, level 3 one class with its methods, their callers and +callees. Every judgement the page shows (domain, layer, call against the layer +order) is computed here, in Python, so it is testable; the page only draws it. + +Heuristic boundaries, all fail-closed: + * A class is a node carrying ``_callable_class``; a method is the target of a + ``method`` edge whose source is a class. + * Layers come from folder names only (Symfony by type or by domain, Laravel). + A class whose folder names no layer goes to ``other``, never guessed. + * The domain is the first folder under the code root; inside a layer folder + (``src/Controller/Account/``) it is the folder below the layer. A class + placed directly in a layer folder has no known domain. + * A template belongs to a domain only through a ``renders`` edge from one of + that domain's classes. + * Only EXTRACTED calls can mark a dependency as going against the layer or + domain order; INFERRED calls are counted and drawn, never accused. +""" + +from __future__ import annotations + +import json +import re +from html import escape +from collections import Counter, defaultdict +from pathlib import Path +from typing import Any, Optional + +from graphify.paths import restore_arc_direction + +LAYERS: tuple[tuple[str, str], ...] = ( + ("route", "Route"), + ("controller", "Controller"), + ("service", "Service"), + ("repository", "Repository"), + ("entity", "Entity / Model"), + ("view", "View"), + ("other", "Other"), +) +LAYER_INDEX = {key: i for i, (key, _) in enumerate(LAYERS)} +# Allowed call direction: a layer may call the ones after it, never the ones before. +LAYER_RANK = {"route": 0, "controller": 1, "service": 2, "repository": 3, "entity": 4} + +_LAYER_DIRS = { + "route": "route", "routes": "route", "routing": "route", + "controller": "controller", "controllers": "controller", + "service": "service", "services": "service", + "repository": "repository", "repositories": "repository", + "entity": "entity", "entities": "entity", "model": "entity", "models": "entity", + "view": "view", "views": "view", +} +_TEST_DIRS = {"test", "tests", "spec", "specs", "__tests__"} +_TEST_FILE = re.compile(r"(Test|Tests|_test|\.test|\.spec)\.[A-Za-z]+$|^test_[^/]*\.py$") +_VENDOR_DIRS = {"vendor", "vendors", "node_modules", "bower_components", "third_party", "third-party"} +_CALL_RELATIONS = {"calls", "indirect_call"} +_TEMPLATE_SUFFIXES = (".twig", ".blade.php", ".html", ".htm", ".jinja", ".jinja2", ".j2", ".vue", ".svelte") + +KIND_DOMAIN = "domain" +KIND_NONE = "none" +KIND_TESTS = "tests" +KIND_VENDOR = "vendor" +KIND_OUTSIDE = "outside" + + +def _parts(source_file: str) -> list[str]: + return [p for p in source_file.replace("\\", "/").split("/") if p and p != "."] + + +def is_test_path(source_file: str) -> bool: + parts = _parts(source_file) + if any(p.lower() in _TEST_DIRS for p in parts[:-1]): + return True + return bool(parts) and bool(_TEST_FILE.search(parts[-1])) + + +def is_vendor_path(source_file: str) -> bool: + return any(p.lower() in _VENDOR_DIRS for p in _parts(source_file)[:-1]) + + +def classify_layer(source_file: str, root: Optional[str]) -> str: + """Layer of a class from the folders between the code root and the file.""" + parts = _parts(source_file) + dirs = parts[1:-1] if root and parts and parts[0] == root else parts[:-1] + for d in dirs: + layer = _LAYER_DIRS.get(d.lower()) + if layer: + return layer + return "other" + + +def classify_domain(source_file: str, root: Optional[str]) -> Optional[str]: + """Domain folder of a class under *root*, or None when no folder names one.""" + parts = _parts(source_file) + if not root or len(parts) < 3 or parts[0] != root: + return None + rest = parts[1:-1] + first = rest[0] + if first.lower() == "http" and len(rest) > 1 and rest[1].lower() in ("controllers", "controller"): + rest = rest[1:] + first = rest[0] + if first.lower() in _LAYER_DIRS: + return rest[1] if len(rest) > 1 else None + return first + + +def _line(location: Any) -> int: + m = re.match(r"L(\d+)", str(location or "")) + return int(m.group(1)) if m else 0 + + +def _group_of(source_file: str, external: bool, root: Optional[str]) -> tuple[str, str]: + """(kind, name) of the level-1 box a class belongs to.""" + if external or not source_file or is_vendor_path(source_file): + return KIND_VENDOR, "Framework / vendor" + if is_test_path(source_file): + return KIND_TESTS, "Tests" + parts = _parts(source_file) + if not root or not parts or parts[0] != root: + return KIND_OUTSIDE, f"{parts[0]}/" if len(parts) > 1 else "(repository root)" + domain = classify_domain(source_file, root) + if domain is None: + return KIND_NONE, "(no domain)" + return KIND_DOMAIN, domain + + +def detect_code_root(class_files: list[str]) -> Optional[str]: + """Top folder holding the most application classes (tests and vendor excluded).""" + counts = Counter( + _parts(f)[0] for f in class_files + if f and len(_parts(f)) > 1 and not is_test_path(f) and not is_vendor_path(f) + ) + if not counts: + return None + return sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[0][0] + + +def order_domains(names: list[str], weights: dict[tuple[int, int], int]) -> list[int]: + """Greedy feedback-arc-set order (Eades, Lin, Smyth 1993), callers first, ties broken by name.""" + alive = set(range(len(names))) + out_w: dict[int, dict[int, int]] = defaultdict(dict) + in_w: dict[int, dict[int, int]] = defaultdict(dict) + for (s, t), w in weights.items(): + if s != t and w > 0: + out_w[s][t] = w + in_w[t][s] = w + head: list[int] = [] + tail: list[int] = [] + + def live(m: dict[int, int]) -> int: + return sum(w for n, w in m.items() if n in alive) + + while alive: + changed = True + while changed: + changed = False + for n in sorted(alive, key=lambda i: names[i]): + if live(out_w[n]) == 0: + tail.insert(0, n); alive.discard(n); changed = True + for n in sorted(alive, key=lambda i: names[i]): + if live(in_w[n]) == 0: + head.append(n); alive.discard(n); changed = True + if alive: + best = max(sorted(alive, key=lambda i: names[i]), key=lambda i: live(out_w[i]) - live(in_w[i])) + head.append(best) + alive.discard(best) + return head + tail + + +def _rows(order: list[int], forward: dict[int, set[int]]) -> dict[int, int]: + """Longest-path layering over forward edges: a domain sits below everything that calls it.""" + row = {n: 0 for n in order} + for n in order: + for t in forward.get(n, ()): + row[t] = max(row[t], row[n] + 1) + return row + + +def build_zoom_data(graph: dict, project: str = "") -> dict: + """Compute the three zoom levels from a node-link graph dict (pure, no I/O).""" + if "links" not in graph and "edges" in graph: + graph = dict(graph, links=graph["edges"]) + data = restore_arc_direction(graph) + raw_links = data.get("links") + links: list = raw_links if isinstance(raw_links, list) else [] + nodes = {str(n["id"]): n for n in data.get("nodes", []) if isinstance(n, dict) and "id" in n} + + class_ids = sorted(i for i, n in nodes.items() if n.get("_callable_class")) + class_idx = {cid: k for k, cid in enumerate(class_ids)} + root = detect_code_root([str(nodes[c].get("source_file") or "") for c in class_ids]) + + files: list[str] = [] + file_idx: dict[str, int] = {} + + def fid(path: str) -> int: + if path not in file_idx: + file_idx[path] = len(files) + files.append(path) + return file_idx[path] + + groups: list[tuple[str, str]] = [] + group_idx: dict[tuple[str, str], int] = {} + classes: list[list] = [] + for cid in class_ids: + n = nodes[cid] + src = str(n.get("source_file") or "") + key = _group_of(src, bool(n.get("external")), root) + if key not in group_idx: + group_idx[key] = len(groups) + groups.append(key) + layer = classify_layer(src, root) if key[0] in (KIND_DOMAIN, KIND_NONE) else "other" + classes.append([str(n.get("label") or cid), group_idx[key], LAYER_INDEX[layer], + fid(src), _line(n.get("source_location")), 0]) + + methods: list[list] = [] + method_of: dict[str, tuple[int, int]] = {} + for link in links: + if not isinstance(link, dict) or link.get("relation") != "method": + continue + owner, member = str(link.get("_src")), str(link.get("_tgt")) + if owner in class_idx and member in nodes and member not in method_of and member not in class_idx: + m = nodes[member] + method_of[member] = (class_idx[owner], len(methods)) + methods.append([class_idx[owner], str(m.get("label") or member), _line(m.get("source_location"))]) + classes[class_idx[owner]][5] += 1 + + def owner_of(node_id: str) -> tuple[int, int]: + if node_id in class_idx: + return class_idx[node_id], -1 + return method_of.get(node_id, (-1, -1)) + + calls: list[list] = [] + pair: dict[tuple[int, int], list[int]] = defaultdict(lambda: [0, 0]) + templates: list[list] = [] + template_idx: dict[str, int] = {} + renders: Counter = Counter() + for link in links: + if not isinstance(link, dict): + continue + rel = link.get("relation") + s_cls, s_m = owner_of(str(link.get("_src"))) + if s_cls < 0: + continue + tgt = str(link.get("_tgt")) + if rel == "renders": + t = nodes.get(tgt) + tfile = str((t or {}).get("source_file") or "") + if t is not None and tfile.lower().endswith(_TEMPLATE_SUFFIXES): + if tgt not in template_idx: + template_idx[tgt] = len(templates) + templates.append([str(t.get("label") or tgt), fid(tfile)]) + renders[(s_cls, template_idx[tgt])] += 1 + continue + if rel not in _CALL_RELATIONS: + continue + t_cls, t_m = owner_of(tgt) + if t_cls < 0: + continue + extracted = 1 if str(link.get("confidence") or "EXTRACTED").upper() == "EXTRACTED" else 0 + calls.append([s_cls, s_m, t_cls, t_m, fid(str(link.get("source_file") or "")), + _line(link.get("source_location")), extracted]) + if s_cls != t_cls: + pair[(s_cls, t_cls)][0 if extracted else 1] += 1 + + class_links = [] + for (s, t), (ext, inf) in sorted(pair.items()): + rs = LAYER_RANK.get(LAYERS[classes[s][2]][0]) + rt = LAYER_RANK.get(LAYERS[classes[t][2]][0]) + against = 1 if ext and rs is not None and rt is not None and rs > rt else 0 + class_links.append([s, t, ext, inf, against]) + + dom_pair: dict[tuple[int, int], list[int]] = defaultdict(lambda: [0, 0]) + for s, t, ext, inf, _ in class_links: + gs, gt = classes[s][1], classes[t][1] + if gs != gt: + dom_pair[(gs, gt)][0] += ext + dom_pair[(gs, gt)][1] += inf + names = [f"{k}:{n}" for k, n in groups] + order = order_domains(names, {k: v[0] for k, v in dom_pair.items()}) + position = {g: i for i, g in enumerate(order)} + forward: dict[int, set[int]] = defaultdict(set) + domain_links = [] + for (s, t), (ext, inf) in sorted(dom_pair.items()): + against = 1 if ext and position[t] < position[s] else 0 + if ext and not against: + forward[s].add(t) + domain_links.append([s, t, ext, inf, against]) + row = _rows(order, forward) + sizes = Counter(c[1] for c in classes) + domains = [{"name": n, "kind": k, "classes": sizes[i], "order": position[i], "row": row[i]} + for i, (k, n) in enumerate(groups)] + + return { + "project": project, + "root": root, + "layers": [label for _, label in LAYERS], + "layerRank": [LAYER_RANK.get(key, -1) for key, _ in LAYERS], + "files": files, + "domains": domains, + "domainLinks": domain_links, + "classes": classes, + "classLinks": class_links, + "methods": methods, + "calls": calls, + "templates": templates, + "renders": [[c, t, n] for (c, t), n in sorted(renders.items())], + } + + +def _embed(payload: dict) -> str: + return json.dumps(payload, ensure_ascii=True, separators=(",", ":")).replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026") + + +def emit_html(payload: dict) -> str: + title = escape(f"{payload.get('project') or 'graphify'} - architecture zoom") + return _TEMPLATE.replace("__TITLE__", title).replace("__DATA__", _embed(payload)) + + +def write_zoom_html(graph_path: Path, output_path: Path, *, project_label: Optional[str] = None) -> Path: + """Read graph.json, compute the three levels and write one self-contained HTML file.""" + from graphify.security import check_graph_file_size_cap + check_graph_file_size_cap(graph_path) + graph = json.loads(graph_path.read_text(encoding="utf-8")) + if project_label is None: + parent = graph_path.resolve().parent + project_label = (parent.parent if parent.name == "graphify-out" else parent).name + payload = build_zoom_data(graph, project=project_label) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(emit_html(payload), encoding="utf-8") + return output_path + + +_TEMPLATE = r""" + + + + +__TITLE__ + + + +
+

+ +
Show
+
+
+
+ +
+ + + + +""" diff --git a/tests/test_zoom_html.py b/tests/test_zoom_html.py new file mode 100644 index 0000000000..e7a7e4b77a --- /dev/null +++ b/tests/test_zoom_html.py @@ -0,0 +1,208 @@ +"""Tests for graphify export zoom-html (graphify/zoom_html.py).""" +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from graphify.zoom_html import ( + LAYERS, + build_zoom_data, + classify_domain, + classify_layer, + write_zoom_html, +) + +PYTHON = sys.executable + + +@pytest.mark.parametrize("path,root,layer,domain", [ + ("src/Item/Controller/ItemController.php", "src", "controller", "Item"), + ("src/Item/Entity/Item.php", "src", "entity", "Item"), + ("src/Item/Pricing/PriceRule.php", "src", "other", "Item"), + ("src/Controller/Item/ItemListController.php", "src", "controller", "Item"), + ("src/Repository/ItemRepository.php", "src", "repository", None), + ("src/Kernel.php", "src", "other", None), + ("app/Http/Controllers/Item/ItemController.php", "app", "controller", "Item"), + ("app/Http/Middleware/Locale.php", "app", "other", "Http"), + ("app/Models/Item.php", "app", "entity", None), + ("app/Services/Item/ItemService.php", "app", "service", "Item"), + ("lib/Item/Service/ItemService.php", "src", "service", None), +]) +def test_layer_and_domain_come_from_folder_names_only(path, root, layer, domain): + assert classify_layer(path, root) == layer + assert classify_domain(path, root) == domain + + +def _cls(cid, label, path): + return {"id": cid, "label": label, "source_file": path, "source_location": "L3", "_callable_class": True} + + +def _meth(mid, label, path, line): + return {"id": mid, "label": label, "source_file": path, "source_location": f"L{line}"} + + +def _graph() -> dict: + nodes = [ + _cls("ctl", "ItemController", "src/Item/Controller/ItemController.php"), + _meth("ctl_show", ".show()", "src/Item/Controller/ItemController.php", 10), + _cls("svc", "ItemService", "src/Item/Service/ItemService.php"), + _meth("svc_find", ".find()", "src/Item/Service/ItemService.php", 12), + _meth("svc_price", ".price()", "src/Item/Service/ItemService.php", 20), + _cls("repo", "ItemRepository", "src/Item/Repository/ItemRepository.php"), + _meth("repo_load", ".load()", "src/Item/Repository/ItemRepository.php", 8), + _cls("ent", "Item", "src/Item/Entity/Item.php"), + _meth("ent_total", ".total()", "src/Item/Entity/Item.php", 30), + _cls("clock", "SystemClock", "src/Shared/Clock/SystemClock.php"), + _meth("clock_now", ".now()", "src/Shared/Clock/SystemClock.php", 5), + _cls("test", "ItemTest", "tests/Item/ItemTest.php"), + _meth("test_it", ".testIt()", "tests/Item/ItemTest.php", 9), + _cls("lib", "Client", "vendor/acme/http/Client.php"), + _cls("mig", "Version1", "migrations/Version1.php"), + {"id": "tpl", "label": "show.html.twig", "source_file": "templates/item/show.html.twig"}, + ] + def call(s, t, line, conf="EXTRACTED"): + return {"source": s, "target": t, "relation": "calls", "confidence": conf, + "source_file": "x.php", "source_location": f"L{line}"} + links = [ + {"source": c, "target": m, "relation": "method"} for c, m in [ + ("ctl", "ctl_show"), ("svc", "svc_find"), ("svc", "svc_price"), ("repo", "repo_load"), + ("ent", "ent_total"), ("clock", "clock_now"), ("test", "test_it")] + ] + [ + call("ctl_show", "svc_find", 11), + call("svc_find", "repo_load", 13), + call("ent_total", "svc_price", 31), + call("repo_load", "ctl_show", 9, conf="INFERRED"), + call("svc_find", "clock_now", 14), + call("svc_price", "clock_now", 21), + call("clock_now", "ent_total", 6), + call("test_it", "ctl_show", 10), + call("ctl_show", "lib", 12), + {"source": "ctl_show", "target": "tpl", "relation": "renders"}, + {"source": "mig", "target": "ent", "relation": "references"}, + ] + return {"directed": False, "nodes": nodes, "links": links} + + +def _by_label(data): + return {c[0]: i for i, c in enumerate(data["classes"])} + + +def _layer(data, label): + return LAYERS[data["classes"][_by_label(data)[label]][2]][0] + + +def _domain(data, label): + return data["domains"][data["classes"][_by_label(data)[label]][1]] + + +def test_groups_split_domains_from_tests_vendor_and_outside_code(): + data = build_zoom_data(_graph(), project="project-a") + assert data["root"] == "src" + assert _domain(data, "ItemController")["name"] == "Item" + assert _domain(data, "SystemClock")["name"] == "Shared" + assert _domain(data, "ItemTest")["kind"] == "tests" + assert _domain(data, "Client")["kind"] == "vendor" + assert _domain(data, "Version1")["kind"] == "outside" + assert {d["name"]: d["classes"] for d in data["domains"] if d["kind"] == "domain"} == {"Item": 4, "Shared": 1} + + +def test_layers_follow_folders_and_unknown_folder_is_other(): + data = build_zoom_data(_graph()) + assert [_layer(data, n) for n in ("ItemController", "ItemService", "ItemRepository", "Item", "SystemClock")] == [ + "controller", "service", "repository", "entity", "other"] + + +def test_only_an_extracted_call_to_an_earlier_layer_is_marked_against(): + data = build_zoom_data(_graph()) + ix = _by_label(data) + flags = {(data["classes"][s][0], data["classes"][t][0]): (ext, inf, ag) for s, t, ext, inf, ag in data["classLinks"]} + assert flags[("Item", "ItemService")] == (1, 0, 1) + assert flags[("ItemController", "ItemService")] == (1, 0, 0) + assert flags[("ItemRepository", "ItemController")] == (0, 1, 0) + assert flags[("ItemService", "SystemClock")] == (2, 0, 0) + assert ix["ItemTest"] in {s for s, *_ in data["classLinks"]} + + +def test_domain_links_are_aggregated_and_the_minority_direction_closes_the_cycle(): + data = build_zoom_data(_graph()) + names = [d["name"] for d in data["domains"]] + links = {(names[s], names[t]): (ext, inf, ag) for s, t, ext, inf, ag in data["domainLinks"]} + assert links[("Item", "Shared")] == (2, 0, 0) + assert links[("Shared", "Item")] == (1, 0, 1) + item, shared = data["domains"][names.index("Item")], data["domains"][names.index("Shared")] + assert item["order"] < shared["order"] and item["row"] < shared["row"] + + +def test_arc_direction_comes_from_src_tgt_markers_not_list_order(): + graph = _graph() + for link in graph["links"]: + if link.get("relation") == "calls" and link["source"] == "ent_total": + link["_src"], link["_tgt"] = "ent_total", "svc_price" + link["source"], link["target"] = "svc_price", "ent_total" + data = build_zoom_data(graph) + pairs = {(data["classes"][s][0], data["classes"][t][0]): ag for s, t, _, _, ag in data["classLinks"]} + assert pairs[("Item", "ItemService")] == 1 + assert ("ItemService", "Item") not in pairs + + +def test_templates_join_a_domain_only_through_renders(): + data = build_zoom_data(_graph()) + assert [t[0] for t in data["templates"]] == ["show.html.twig"] + assert data["renders"] == [[_by_label(data)["ItemController"], 0, 1]] + + +def test_level_three_keeps_methods_and_call_sites(): + data = build_zoom_data(_graph()) + ix = _by_label(data) + assert data["classes"][ix["ItemService"]][5] == 2 + methods = {m[1]: m for m in data["methods"]} + assert methods[".find()"][2] == 12 + call = next(c for c in data["calls"] if data["methods"][c[1]][1] == ".show()" and c[3] >= 0 and data["methods"][c[3]][1] == ".find()") + assert data["files"][call[4]] == "x.php" and call[5] == 11 and call[6] == 1 + to_class = [c for c in data["calls"] if c[2] == ix["Client"]] + assert to_class and to_class[0][3] == -1 + + +def test_edges_key_graph_is_accepted(): + graph = _graph() + graph["edges"] = graph.pop("links") + assert len(build_zoom_data(graph)["classLinks"]) == len(build_zoom_data(_graph())["classLinks"]) + + +def test_html_is_offline_and_labels_cannot_break_out_of_the_data_block(tmp_path): + graph = _graph() + graph["nodes"][0]["label"] = "" + out_dir = tmp_path / "graphify-out" + out_dir.mkdir() + (out_dir / "graph.json").write_text(json.dumps(graph), encoding="utf-8") + out = write_zoom_html(out_dir / "graph.json", tmp_path / "zoom.html") + html = out.read_text(encoding="utf-8") + assert "") == 2 + assert "