From 49720d66316ecf12d393a34cd86b366b583dfa53 Mon Sep 17 00:00:00 2001 From: petertzy <97914108+petertzy@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:08:01 +0100 Subject: [PATCH 1/8] feat: add architecture diagrams and generator --- docs/architecture/index.html | 83 +++++++++++ docs/architecture/template.html | 68 +++++++++ tools/generate_architecture.py | 237 ++++++++++++++++++++++++++++++++ 3 files changed, 388 insertions(+) create mode 100644 docs/architecture/index.html create mode 100644 docs/architecture/template.html create mode 100644 tools/generate_architecture.py diff --git a/docs/architecture/index.html b/docs/architecture/index.html new file mode 100644 index 00000000..3319805f --- /dev/null +++ b/docs/architecture/index.html @@ -0,0 +1,83 @@ + +Markdown Reader | Architecture Explorer + +

Markdown Reader / Architecture Explorer

Interactive source-grounded architecture prototype

Read-only prototype · main
+
+

System overview

Application processes and integration boundaries

Click nodes to inspect · Drag to pan · Scroll to zoom
Application moduleLocal resourceExternal serviceRuntime process
+
+ + diff --git a/docs/architecture/template.html b/docs/architecture/template.html new file mode 100644 index 00000000..a68dbb8d --- /dev/null +++ b/docs/architecture/template.html @@ -0,0 +1,68 @@ + +Markdown Reader | Architecture Explorer + +

Markdown Reader / Architecture Explorer

Interactive source-grounded architecture prototype

Read-only prototype · main
+
+

System overview

Application processes and integration boundaries

Click nodes to inspect · Drag to pan · Scroll to zoom
Application moduleLocal resourceExternal serviceRuntime process
+
+ diff --git a/tools/generate_architecture.py b/tools/generate_architecture.py new file mode 100644 index 00000000..97a72d84 --- /dev/null +++ b/tools/generate_architecture.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +"""Generate a self-contained architecture explorer from repository source files.""" + +import argparse +import ast +import json +import os +import re +from bisect import bisect_left +from collections import defaultdict +from pathlib import Path + +EXCLUDED = { + ".git", + "node_modules", + ".next", + "dist", + "build", + ".venv", + "__pycache__", + "coverage", +} +HIDDEN_FUNCTION_VIEWS = {"backend/ai_logic.py", "backend/citation_logic.py"} +CALL_RE = re.compile(r"\b([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?)\s*\(") +TS_DEF = re.compile( + r"\b(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(|\b(?:export\s+)?(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][\w$]*)\s*=>", + re.M, +) +TS_IMPORT = re.compile( + r"\b(?:import|export)\s+(?:[^;]*?\s+from\s+)?[\'\"]([^\'\"]+)[\'\"]", re.S +) + + +def files(root): + extensions = {".py", ".ts", ".tsx", ".js", ".mjs"} + for directory, dirs, names in os.walk(root): + dirs[:] = sorted(d for d in dirs if d not in EXCLUDED) + for name in sorted(names): + path = Path(directory) / name + if path.suffix in extensions and path.is_file(): + yield path + + +def analyze(root): + entries = [] + for path in files(root): + rel = path.relative_to(root).as_posix() + source = path.read_text(encoding="utf-8", errors="replace") + defs = [] + edges = [] + if path.suffix == ".py": + try: + tree = ast.parse(source, filename=rel) + except SyntaxError: + continue + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + defs.append( + (node.name, node.lineno, node.end_lineno or node.lineno) + ) + for call in ast.walk(node): + if isinstance(call, ast.Call): + if isinstance(call.func, ast.Name): + edges.append((node.name, call.func.id, call.lineno)) + elif isinstance(call.func, ast.Attribute): + edges.append((node.name, call.func.attr, call.lineno)) + else: + newline_positions = [i for i, char in enumerate(source) if char == "\n"] + + def line_number(offset): + return bisect_left(newline_positions, offset) + 1 + + matches = list(TS_DEF.finditer(source)) + for i, match in enumerate(matches): + name = match.group(1) or match.group(2) + start = line_number(match.start()) + end_pos = ( + matches[i + 1].start() if i + 1 < len(matches) else len(source) + ) + end = line_number(end_pos) + defs.append((name, start, end)) + body = source[match.end() : end_pos] + for call in CALL_RE.finditer(body): + callee = call.group(1).split(".")[-1] + edges.append( + (name, callee, line_number(match.end() + call.start())) + ) + entries.append({"file": rel, "defs": defs, "edges": edges}) + return entries + + +def make_views(entries, root): + grouped = defaultdict(list) + for entry in entries: + path = entry["file"] + if path.startswith("frontend/src/"): + group = "Frontend functions" + elif path.startswith("backend/"): + group = "Backend functions" + elif path.startswith("frontend/src-tauri/"): + group = "Desktop functions" + else: + continue + grouped[group].append(entry) + views = [] + for index, (group, subset) in enumerate(grouped.items()): + by_name = defaultdict(list) + for entry in subset: + for name, start, end in entry["defs"]: + by_name[name].append((entry["file"], start, end)) + nodes = [] + edges = set() + ids = {} + for entry in subset: + for name, start, end in entry["defs"]: + identifier = f"{entry['file']}:{name}:{start}" + ids[(entry["file"], name)] = identifier + nodes.append( + { + "id": identifier, + "title": name[:29], + "subtitle": entry["file"].split("/")[-1][:35], + "files": [entry["file"]], + "line": start, + "description": f"Defined at {entry['file']}:{start}-{end}.", + "kind": "module", + "x": 0, + "y": 0, + } + ) + for entry in subset: + for caller, callee, line in entry["edges"]: + src = ids.get((entry["file"], caller)) + if not src: + continue + candidates = by_name.get(callee, []) + same_file = [x for x in candidates if x[0] == entry["file"]] + if same_file: + target = ids.get((same_file[0][0], callee)) + if target and target != src: + edges.add((src, target)) + # Split large graphs into per-file views to retain legibility. + for entry in subset: + if entry["file"] in HIDDEN_FUNCTION_VIEWS: + continue + local = [n.copy() for n in nodes if n["files"][0] == entry["file"]] + if len(local) < 2: + continue + local_ids = {n["id"] for n in local} + local_edges = [ + list(e) + for e in sorted(edges) + if e[0] in local_ids and e[1] in local_ids + ] + if len(local) > 120: + local = local[:120] + local_ids = {n["id"] for n in local} + local_edges = [ + e for e in local_edges if e[0] in local_ids and e[1] in local_ids + ] + views.append( + { + "id": f"functions-{len(views)}", + "title": entry["file"].split("/")[-1] + " calls", + "subtitle": entry["file"] + " | local static calls", + "nodes": local, + "edges": local_edges, + } + ) + return views + + +def inject(template, views, sha): + # Existing explorer uses a constant view array and a DOM-based SVG renderer. + script = ( + '" + ) + template = template.replace( + "