sciogen 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sciogen/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """sciogen — a queryable knowledge graph over a codebase.
2
+
3
+ sciogen is a static analysis and indexing tool. It parses source files with
4
+ tree-sitter, normalizes them into a language-agnostic IR, resolves symbol
5
+ references to exact definitions, and materializes the result into an embedded
6
+ triple store (KuzuDB for graph topology, ChromaDB for vectors, SQLite for
7
+ operational metadata). All queries are deterministic and return typed Python
8
+ objects — never raw source code.
9
+
10
+ Public entry point::
11
+
12
+ import sciogen
13
+
14
+ graph = sciogen.open("path/to/repo") # opens (or creates) the index
15
+ graph.index() # incremental — only changed files
16
+ graph.get_callers("AuthService.login")
17
+ graph.search("password hashing logic", mode="hybrid")
18
+ """
19
+
20
+ from sciogen.api import SciogenGraph, open # noqa: A004 - deliberate, mirrors sqlite3.connect ergonomics
21
+
22
+ __version__ = "0.1.0"
23
+
24
+ __all__ = ["SciogenGraph", "open", "__version__"]
sciogen/api.py ADDED
@@ -0,0 +1,151 @@
1
+ """Public Python API — the :class:`SciogenGraph` facade.
2
+
3
+ Everything else (CLI, MCP) is a thin adapter over this class. New
4
+ capabilities are added here first; protocol layers only wrap.
5
+
6
+ Stores open lazily: constructing a SciogenGraph is free, the KuzuDB/ChromaDB
7
+ handles materialize on first use. That keeps `import sciogen` + CLI startup
8
+ instant (chromadb alone costs seconds to import).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import shutil
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from sciogen.config import Config
18
+ from sciogen.query.results import (
19
+ ContextBundle,
20
+ DependencyInfo,
21
+ DiffImpact,
22
+ ImpactGraph,
23
+ SearchResults,
24
+ SymbolInfo,
25
+ )
26
+
27
+
28
+ class SciogenGraph:
29
+ """A handle to one project's index (graph + vectors + metadata)."""
30
+
31
+ def __init__(self, project_root: str | Path, config: Config | None = None) -> None:
32
+ self.config = config or Config.for_project(project_root)
33
+ self._graph = None
34
+ self._vectors = None
35
+ self._meta = None
36
+ self._engine = None
37
+
38
+ # --------------------------------------------------------- lazy plumbing
39
+
40
+ @property
41
+ def graph_store(self):
42
+ if self._graph is None:
43
+ from sciogen.stores.graph import GraphStore
44
+
45
+ self._graph = GraphStore(self.config.graph_dir)
46
+ return self._graph
47
+
48
+ @property
49
+ def vector_store(self):
50
+ if self._vectors is None:
51
+ from sciogen.embed import get_embedder
52
+ from sciogen.stores.vectors import VectorStore
53
+
54
+ self._vectors = VectorStore(self.config.vector_dir, get_embedder(self.config.embedder))
55
+ return self._vectors
56
+
57
+ @property
58
+ def meta_store(self):
59
+ if self._meta is None:
60
+ from sciogen.stores.meta import MetaStore
61
+
62
+ self._meta = MetaStore(self.config.meta_path)
63
+ return self._meta
64
+
65
+ @property
66
+ def engine(self):
67
+ if self._engine is None:
68
+ from sciogen.query.engine import QueryEngine
69
+
70
+ self._engine = QueryEngine(self.graph_store, self.vector_store, self.meta_store)
71
+ return self._engine
72
+
73
+ # -------------------------------------------------------------- indexing
74
+
75
+ def index(self, reporter=None, rebuild: bool = False):
76
+ """Build or incrementally update the index. Returns IndexStats.
77
+
78
+ ``rebuild=True`` wipes the on-disk index first — needed after a
79
+ schema change or when switching embedders.
80
+ """
81
+ if rebuild:
82
+ self.close()
83
+ if self.config.data_dir.exists():
84
+ shutil.rmtree(self.config.data_dir)
85
+ from sciogen.index.pipeline import Pipeline
86
+
87
+ pipeline = Pipeline(
88
+ self.config, self.graph_store, self.vector_store, self.meta_store, reporter
89
+ )
90
+ return pipeline.run()
91
+
92
+ # -------------------------------------------------------------- querying
93
+
94
+ def get_symbol(self, name: str, file: str | None = None) -> SymbolInfo:
95
+ return self.engine.get_symbol(name, file)
96
+
97
+ def get_callers(self, symbol: str, depth: int = 1, min_confidence: float = 0.0) -> ImpactGraph:
98
+ return self.engine.get_callers(symbol, depth, min_confidence)
99
+
100
+ def get_dependencies(self, file: str) -> DependencyInfo:
101
+ return self.engine.get_dependencies(file.replace("\\", "/"))
102
+
103
+ def analyze_impact(
104
+ self, symbol: str, max_depth: int = 5, min_confidence: float = 0.0
105
+ ) -> ImpactGraph:
106
+ return self.engine.analyze_impact(symbol, max_depth, min_confidence)
107
+
108
+ def search(
109
+ self, query: str, mode: str = "semantic", granularity: str | None = None, k: int = 10
110
+ ) -> SearchResults:
111
+ return self.engine.search(query, mode, granularity, k)
112
+
113
+ def get_diff_impact(self, unified_diff: str) -> DiffImpact:
114
+ return self.engine.get_diff_impact(unified_diff)
115
+
116
+ def get_context_for_task(self, description: str, k: int = 15) -> ContextBundle:
117
+ return self.engine.get_context_for_task(description, k)
118
+
119
+ # ------------------------------------------------------- explore support
120
+
121
+ def snapshot(self, path_prefix: str = "") -> dict[str, Any]:
122
+ """Full graph serialization for `sciogen explore` (nodes, edges,
123
+ in-degrees). Not part of the agent-facing API."""
124
+ return {
125
+ "nodes": self.graph_store.all_nodes(path_prefix),
126
+ "edges": self.graph_store.all_edges(),
127
+ "in_degrees": self.graph_store.in_degrees(),
128
+ }
129
+
130
+ # ------------------------------------------------------------- lifecycle
131
+
132
+ def close(self) -> None:
133
+ if self._graph is not None:
134
+ self._graph.close()
135
+ self._graph = None
136
+ if self._meta is not None:
137
+ self._meta.close()
138
+ self._meta = None
139
+ self._vectors = None # chromadb PersistentClient has no close()
140
+ self._engine = None
141
+
142
+ def __enter__(self) -> "SciogenGraph":
143
+ return self
144
+
145
+ def __exit__(self, *exc) -> None:
146
+ self.close()
147
+
148
+
149
+ def open(project_root: str | Path) -> SciogenGraph: # noqa: A001
150
+ """Open (or create) the sciogen index for a project directory."""
151
+ return SciogenGraph(project_root)
@@ -0,0 +1 @@
1
+ """Typer + Rich CLI. All output goes through the shared Console singleton."""
sciogen/cli/banner.py ADDED
@@ -0,0 +1,19 @@
1
+ """ASCII banner shown by `sciogen index`.
2
+
3
+ Kept as a plain string constant (project rule: never generated
4
+ programmatically). Printed unstyled — plain text only.
5
+ """
6
+
7
+ BANNER = r"""
8
+ ███████╗ ██████╗██╗ ██████╗ ██████╗ ███████╗███╗ ██╗
9
+ ██╔════╝██╔════╝██║██╔═══██╗██╔════╝ ██╔════╝████╗ ██║
10
+ ███████╗██║ ██║██║ ██║██║ ███╗█████╗ ██╔██╗ ██║
11
+ ╚════██║██║ ██║██║ ██║██║ ██║██╔══╝ ██║╚██╗██║
12
+ ███████║╚██████╗██║╚██████╔╝╚██████╔╝███████╗██║ ╚████║
13
+ ╚══════╝ ╚═════╝╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝
14
+ Codebase Intelligence Layer v{version}
15
+ """
16
+
17
+
18
+ def banner_text(version: str) -> str:
19
+ return BANNER.format(version=version)
sciogen/cli/console.py ADDED
@@ -0,0 +1,10 @@
1
+ """The module-level Rich Console singleton.
2
+
3
+ Every CLI module imports this instance; nothing in the CLI layer calls
4
+ ``print()`` or instantiates its own Console (project rule — one console
5
+ means consistent styling, correct terminal detection, and testable output).
6
+ """
7
+
8
+ from rich.console import Console
9
+
10
+ console = Console()
@@ -0,0 +1,57 @@
1
+ """`sciogen explore` HTML generation.
2
+
3
+ Serializes the full graph snapshot into the static template
4
+ (``template.html``, kept as an asset with a ``{{GRAPH_JSON}}`` placeholder —
5
+ never built by string concatenation) and writes a single self-contained HTML
6
+ file. No server, no port; the browser gets everything upfront and progressive
7
+ disclosure happens client-side.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ _TEMPLATE_PATH = Path(__file__).parent / "template.html"
17
+
18
+ #: ChunkNodes are embedding units, not something a human inspects on a canvas.
19
+ _HIDDEN_KINDS = {"ChunkNode"}
20
+ #: Node properties worth showing in the side panel (same shape as get_symbol).
21
+ _PANEL_PROPS = (
22
+ "file", "language", "line_start", "line_end", "params", "return_type",
23
+ "complexity", "extends", "implements", "docstring", "fields", "size", "package",
24
+ )
25
+
26
+
27
+ def generate_html(snapshot: dict[str, Any], project: str, out_path: Path) -> Path:
28
+ hidden = {n["id"] for n in snapshot["nodes"] if n["kind"] in _HIDDEN_KINDS}
29
+ nodes = [
30
+ {
31
+ "id": n["id"],
32
+ "kind": n["kind"],
33
+ "name": n["name"],
34
+ "props": {k: n[k] for k in _PANEL_PROPS if n.get(k)},
35
+ }
36
+ for n in snapshot["nodes"]
37
+ if n["kind"] not in _HIDDEN_KINDS
38
+ ]
39
+ edges = [
40
+ {"src": src, "dst": dst, "kind": kind, "confidence": round(conf, 2)}
41
+ for src, dst, kind, conf in snapshot["edges"]
42
+ if src not in hidden and dst not in hidden
43
+ ]
44
+ payload = {
45
+ "nodes": nodes,
46
+ "edges": edges,
47
+ "in_degrees": {k: v for k, v in snapshot["in_degrees"].items() if k not in hidden},
48
+ }
49
+ graph_json = json.dumps(payload, separators=(",", ":")).replace("</", "<\\/")
50
+ html = (
51
+ _TEMPLATE_PATH.read_text(encoding="utf-8")
52
+ .replace("{{GRAPH_JSON}}", graph_json)
53
+ .replace("{{PROJECT}}", project)
54
+ )
55
+ out_path.parent.mkdir(parents=True, exist_ok=True)
56
+ out_path.write_text(html, encoding="utf-8")
57
+ return out_path
@@ -0,0 +1,341 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>sciogen — {{PROJECT}}</title>
7
+ <script src="https://cdn.jsdelivr.net/npm/cytoscape@3.30.2/dist/cytoscape.min.js"></script>
8
+ <style>
9
+ :root {
10
+ --surface: #fcfcfb; --page: #f9f9f7; --ink: #0b0b0b; --ink-2: #52514e;
11
+ --muted: #898781; --hairline: #e1e0d9; --edge: #c3c2b7;
12
+ --border: rgba(11,11,11,0.10);
13
+ --c-file: #2a78d6; --c-class: #1baf7a; --c-function: #eda100;
14
+ --c-type: #008300; --c-module: #4a3aa7; --c-external: #e34948;
15
+ }
16
+ @media (prefers-color-scheme: dark) {
17
+ :root {
18
+ --surface: #1a1a19; --page: #0d0d0d; --ink: #ffffff; --ink-2: #c3c2b7;
19
+ --muted: #898781; --hairline: #2c2c2a; --edge: #383835;
20
+ --border: rgba(255,255,255,0.10);
21
+ --c-file: #3987e5; --c-class: #199e70; --c-function: #c98500;
22
+ --c-type: #008300; --c-module: #9085e9; --c-external: #e66767;
23
+ }
24
+ }
25
+ * { box-sizing: border-box; margin: 0; }
26
+ body {
27
+ font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
28
+ background: var(--page); color: var(--ink); height: 100vh;
29
+ display: flex; flex-direction: column; overflow: hidden;
30
+ }
31
+ header {
32
+ display: flex; align-items: center; gap: 16px; padding: 10px 16px;
33
+ border-bottom: 1px solid var(--hairline); background: var(--surface);
34
+ flex-wrap: wrap;
35
+ }
36
+ header h1 { font-size: 15px; font-weight: 600; }
37
+ header h1 span { color: var(--muted); font-weight: 400; }
38
+ #search {
39
+ flex: 1; min-width: 180px; max-width: 360px; padding: 6px 10px;
40
+ border: 1px solid var(--hairline); border-radius: 6px;
41
+ background: var(--page); color: var(--ink); font: inherit; font-size: 13px;
42
+ }
43
+ #search:focus { outline: 2px solid var(--c-file); outline-offset: -1px; }
44
+ .layout-toggle { display: flex; border: 1px solid var(--hairline); border-radius: 6px; overflow: hidden; }
45
+ .layout-toggle button {
46
+ font: inherit; font-size: 12px; padding: 6px 12px; border: 0; cursor: pointer;
47
+ background: var(--surface); color: var(--ink-2);
48
+ }
49
+ .layout-toggle button.active { background: var(--c-file); color: #fff; }
50
+ .legend { display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; color: var(--ink-2); }
51
+ .legend .item { display: flex; align-items: center; gap: 5px; }
52
+ .legend .swatch { width: 10px; height: 10px; display: inline-block; }
53
+ main { flex: 1; display: flex; min-height: 0; }
54
+ #cy { flex: 1; background: var(--surface); min-width: 0; }
55
+ #panel {
56
+ width: 320px; border-left: 1px solid var(--hairline); background: var(--surface);
57
+ padding: 16px; overflow-y: auto; display: none; font-size: 13px;
58
+ }
59
+ #panel.open { display: block; }
60
+ #panel h2 { font-size: 14px; word-break: break-all; margin-bottom: 2px; }
61
+ #panel .kind { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; margin-bottom: 12px; }
62
+ #panel dl { display: grid; grid-template-columns: 1fr; gap: 8px; }
63
+ #panel dt { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
64
+ #panel dd { color: var(--ink-2); word-break: break-all; white-space: pre-wrap; }
65
+ #panel .close { float: right; cursor: pointer; border: 0; background: none; color: var(--muted); font-size: 16px; }
66
+ footer {
67
+ padding: 6px 16px; font-size: 12px; color: var(--muted);
68
+ border-top: 1px solid var(--hairline); background: var(--surface);
69
+ display: flex; gap: 16px; flex-wrap: wrap;
70
+ }
71
+ .edge-legend { display: flex; gap: 12px; }
72
+ .edge-legend svg { vertical-align: middle; }
73
+ </style>
74
+ </head>
75
+ <body>
76
+ <header>
77
+ <h1>{{PROJECT}} <span>· sciogen graph</span></h1>
78
+ <input id="search" type="search" placeholder="Jump to symbol… (Enter)"
79
+ aria-label="Search symbols by name">
80
+ <div class="layout-toggle" role="group" aria-label="Layout">
81
+ <button id="btn-force" class="active">Force</button>
82
+ <button id="btn-hier">Hierarchy</button>
83
+ </div>
84
+ <div class="legend" id="legend"></div>
85
+ </header>
86
+ <main>
87
+ <div id="cy" role="application" aria-label="Code knowledge graph"></div>
88
+ <aside id="panel" aria-live="polite">
89
+ <button class="close" id="panel-close" aria-label="Close panel">✕</button>
90
+ <h2 id="panel-title"></h2>
91
+ <div class="kind" id="panel-kind"></div>
92
+ <dl id="panel-props"></dl>
93
+ </aside>
94
+ </main>
95
+ <footer>
96
+ <span id="stats"></span>
97
+ <span class="edge-legend">
98
+ <span><svg width="26" height="8"><line x1="0" y1="4" x2="26" y2="4" stroke="currentColor" stroke-width="2"/></svg> confidence ≥ 0.8</span>
99
+ <span><svg width="26" height="8"><line x1="0" y1="4" x2="26" y2="4" stroke="currentColor" stroke-width="2" stroke-dasharray="6 3"/></svg> 0.5 – 0.8</span>
100
+ <span><svg width="26" height="8"><line x1="0" y1="4" x2="26" y2="4" stroke="currentColor" stroke-width="2" stroke-dasharray="2 3"/></svg> &lt; 0.5</span>
101
+ </span>
102
+ <span>Click a file to expand its symbols · click a function to reveal its calls · click again to collapse</span>
103
+ </footer>
104
+ <script>
105
+ const GRAPH = {{GRAPH_JSON}};
106
+
107
+ // ---------------------------------------------------------------- palette
108
+ const KINDS = {
109
+ FileNode: { varName: "--c-file", shape: "round-rectangle", label: "File" },
110
+ ClassNode: { varName: "--c-class", shape: "hexagon", label: "Class" },
111
+ FunctionNode: { varName: "--c-function", shape: "ellipse", label: "Function" },
112
+ TypeNode: { varName: "--c-type", shape: "diamond", label: "Type" },
113
+ ModuleNode: { varName: "--c-module", shape: "barrel", label: "Module" },
114
+ ExternalNode: { varName: "--c-external", shape: "octagon", label: "External" },
115
+ };
116
+ const css = name => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
117
+ const kindColor = kind => css((KINDS[kind] || KINDS.ExternalNode).varName);
118
+
119
+ // Legend (color + shape name — identity is never color-alone)
120
+ document.getElementById("legend").innerHTML = Object.values(KINDS).map(k =>
121
+ `<span class="item"><span class="swatch" style="background:${css(k.varName)};
122
+ border-radius:${k.shape === "ellipse" ? "50%" : "2px"}"></span>${k.label}</span>`).join("");
123
+
124
+ // ------------------------------------------------------------- data prep
125
+ const nodesById = new Map(GRAPH.nodes.map(n => [n.id, n]));
126
+ const childrenOf = new Map(); // DEFINES: parent id -> [child ids]
127
+ const parentOf = new Map();
128
+ const adjacency = new Map(); // id -> edges touching it
129
+ for (const n of GRAPH.nodes) adjacency.set(n.id, []);
130
+ for (const e of GRAPH.edges) {
131
+ if (e.kind === "DEFINES") {
132
+ if (!childrenOf.has(e.src)) childrenOf.set(e.src, []);
133
+ childrenOf.get(e.src).push(e.dst);
134
+ parentOf.set(e.dst, e.src);
135
+ }
136
+ if (adjacency.has(e.src)) adjacency.get(e.src).push(e);
137
+ if (adjacency.has(e.dst)) adjacency.get(e.dst).push(e);
138
+ }
139
+ const degree = id => GRAPH.in_degrees[id] || 0;
140
+ const maxDegree = Math.max(1, ...Object.values(GRAPH.in_degrees));
141
+ const nodeSize = id => 22 + 38 * Math.sqrt(degree(id) / maxDegree);
142
+ const shortLabel = n => n.kind === "FileNode"
143
+ ? n.name.split("/").pop()
144
+ : (n.name.split(".").pop() || n.name);
145
+
146
+ // Collapsed by default: files only. revealedBy tracks progressive disclosure
147
+ // so collapse can undo exactly what an expansion added.
148
+ const visible = new Set(GRAPH.nodes.filter(n => n.kind === "FileNode").map(n => n.id));
149
+ const revealedBy = new Map(); // node id -> id of the node that revealed it
150
+ const expanded = new Set();
151
+
152
+ // ------------------------------------------------------------- cytoscape
153
+ function edgeStyleClass(conf) { return conf >= 0.8 ? "solid" : conf >= 0.5 ? "dashed" : "dotted"; }
154
+
155
+ function buildElements() {
156
+ const els = [];
157
+ for (const id of visible) {
158
+ const n = nodesById.get(id);
159
+ if (!n) continue;
160
+ els.push({ data: { id, label: shortLabel(n), kind: n.kind, size: nodeSize(id) } });
161
+ }
162
+ const seen = new Set();
163
+ for (const e of GRAPH.edges) {
164
+ if (e.kind === "DEFINES" || e.kind === "PART_OF") continue; // structure is shown by expansion
165
+ if (!visible.has(e.src) || !visible.has(e.dst)) continue;
166
+ const key = e.src + "→" + e.dst + "→" + e.kind;
167
+ if (seen.has(key)) continue;
168
+ seen.add(key);
169
+ els.push({ data: { id: key, source: e.src, target: e.dst, kind: e.kind,
170
+ confidence: e.confidence }, classes: edgeStyleClass(e.confidence) });
171
+ }
172
+ return els;
173
+ }
174
+
175
+ function cyStyle() {
176
+ return [
177
+ { selector: "node", style: {
178
+ "background-color": ele => kindColor(ele.data("kind")),
179
+ "shape": ele => (KINDS[ele.data("kind")] || KINDS.ExternalNode).shape,
180
+ "width": "data(size)", "height": "data(size)",
181
+ "label": "data(label)", "font-size": 10, "font-family": "system-ui, sans-serif",
182
+ "color": css("--ink-2"), "text-valign": "bottom", "text-margin-y": 4,
183
+ "border-width": 1, "border-color": css("--border"),
184
+ "text-wrap": "ellipsis", "text-max-width": 120,
185
+ }},
186
+ { selector: "node:selected", style: { "border-width": 3, "border-color": css("--ink") } },
187
+ { selector: "edge", style: {
188
+ "width": 2, "line-color": css("--edge"), "curve-style": "bezier",
189
+ "target-arrow-shape": "triangle", "target-arrow-color": css("--edge"),
190
+ "arrow-scale": 0.8,
191
+ }},
192
+ { selector: "edge.dashed", style: { "line-style": "dashed" } },
193
+ { selector: "edge.dotted", style: { "line-style": "dotted" } },
194
+ { selector: "edge:selected", style: { "line-color": css("--ink"), "target-arrow-color": css("--ink") } },
195
+ ];
196
+ }
197
+
198
+ const LAYOUTS = {
199
+ force: { name: "cose", animate: false, nodeRepulsion: 40000, idealEdgeLength: 90, padding: 30 },
200
+ hier: { name: "breadthfirst", directed: true, spacingFactor: 1.2, padding: 30 },
201
+ };
202
+ let currentLayout = "force";
203
+
204
+ const cy = cytoscape({
205
+ container: document.getElementById("cy"),
206
+ elements: buildElements(),
207
+ style: cyStyle(),
208
+ layout: LAYOUTS[currentLayout],
209
+ wheelSensitivity: 0.25,
210
+ });
211
+
212
+ function refresh(fit) {
213
+ cy.json({ elements: buildElements() });
214
+ cy.style(cyStyle());
215
+ const layout = cy.layout(LAYOUTS[currentLayout]);
216
+ layout.run();
217
+ if (fit) cy.fit(undefined, 40);
218
+ updateStats();
219
+ }
220
+
221
+ function updateStats() {
222
+ document.getElementById("stats").textContent =
223
+ `${visible.size} of ${GRAPH.nodes.length} nodes shown · ${GRAPH.edges.length} edges total`;
224
+ }
225
+
226
+ // ------------------------------------------------- expand / collapse logic
227
+ function reveal(id, by) {
228
+ if (visible.has(id)) return;
229
+ visible.add(id);
230
+ revealedBy.set(id, by);
231
+ }
232
+
233
+ function expandNode(id) {
234
+ const n = nodesById.get(id);
235
+ if (!n) return;
236
+ expanded.add(id);
237
+ for (const child of (childrenOf.get(id) || [])) {
238
+ const c = nodesById.get(child);
239
+ if (c && c.kind !== "ChunkNode") reveal(child, id);
240
+ }
241
+ if (n.kind === "FileNode") {
242
+ for (const e of (adjacency.get(id) || []))
243
+ if (e.kind === "IMPORTS" && e.src === id) reveal(e.dst, id);
244
+ }
245
+ if (n.kind === "FunctionNode") {
246
+ for (const e of (adjacency.get(id) || []))
247
+ if (e.kind === "CALLS") reveal(e.src === id ? e.dst : e.src, id);
248
+ }
249
+ }
250
+
251
+ function collapseNode(id) {
252
+ expanded.delete(id);
253
+ const toRemove = [...revealedBy.entries()].filter(([, by]) => by === id).map(([k]) => k);
254
+ for (const child of toRemove) {
255
+ if (expanded.has(child)) collapseNode(child);
256
+ visible.delete(child);
257
+ revealedBy.delete(child);
258
+ }
259
+ }
260
+
261
+ cy.on("tap", "node", evt => {
262
+ const id = evt.target.id();
263
+ showPanel(nodesById.get(id));
264
+ if (expanded.has(id)) collapseNode(id); else expandNode(id);
265
+ refresh(false);
266
+ });
267
+ cy.on("tap", "edge", evt => {
268
+ const d = evt.target.data();
269
+ showEdgePanel(d);
270
+ });
271
+ cy.on("tap", evt => { if (evt.target === cy) hidePanel(); });
272
+
273
+ // ---------------------------------------------------------------- panel
274
+ const panel = document.getElementById("panel");
275
+ function showPanel(n) {
276
+ if (!n) return;
277
+ document.getElementById("panel-title").textContent = n.name;
278
+ document.getElementById("panel-kind").textContent = n.kind;
279
+ const props = Object.entries(n.props || {}).filter(([, v]) =>
280
+ v !== "" && v !== 0 && !(Array.isArray(v) && !v.length));
281
+ document.getElementById("panel-props").innerHTML = props.map(([k, v]) =>
282
+ `<div><dt>${k}</dt><dd>${escapeHtml(Array.isArray(v) ? v.join(", ") : String(v))}</dd></div>`).join("");
283
+ panel.classList.add("open");
284
+ }
285
+ function showEdgePanel(d) {
286
+ document.getElementById("panel-title").textContent = `${d.source} → ${d.target}`;
287
+ document.getElementById("panel-kind").textContent = d.kind + " edge";
288
+ document.getElementById("panel-props").innerHTML =
289
+ `<div><dt>confidence</dt><dd>${d.confidence}</dd></div>`;
290
+ panel.classList.add("open");
291
+ }
292
+ function hidePanel() { panel.classList.remove("open"); }
293
+ document.getElementById("panel-close").onclick = hidePanel;
294
+ function escapeHtml(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; }
295
+
296
+ // ---------------------------------------------------------------- search
297
+ document.getElementById("search").addEventListener("keydown", evt => {
298
+ if (evt.key !== "Enter") return;
299
+ const q = evt.target.value.trim().toLowerCase();
300
+ if (!q) return;
301
+ const match = GRAPH.nodes.find(n => n.kind !== "ChunkNode" && n.name.toLowerCase() === q)
302
+ || GRAPH.nodes.find(n => n.kind !== "ChunkNode" && n.name.toLowerCase().includes(q));
303
+ if (!match) return;
304
+ // Reveal the ancestor chain (file -> class -> symbol), then the node.
305
+ const chain = [];
306
+ let cur = match.id;
307
+ while (parentOf.has(cur)) { cur = parentOf.get(cur); chain.unshift(cur); }
308
+ for (const ancestor of chain) expandNode(ancestor);
309
+ reveal(match.id, chain[chain.length - 1] || match.id);
310
+ refresh(false);
311
+ const target = cy.getElementById(match.id);
312
+ if (target.nonempty()) {
313
+ cy.animate({ center: { eles: target }, zoom: 1.4, duration: 350 });
314
+ target.select();
315
+ showPanel(match);
316
+ }
317
+ });
318
+
319
+ // ---------------------------------------------------------- layout toggle
320
+ const btnForce = document.getElementById("btn-force");
321
+ const btnHier = document.getElementById("btn-hier");
322
+ function setLayout(name) {
323
+ currentLayout = name;
324
+ btnForce.classList.toggle("active", name === "force");
325
+ btnHier.classList.toggle("active", name === "hier");
326
+ refresh(true);
327
+ }
328
+ btnForce.onclick = () => setLayout("force");
329
+ btnHier.onclick = () => setLayout("hier");
330
+
331
+ // Re-style when the OS theme flips.
332
+ window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",
333
+ () => { cy.style(cyStyle()); document.getElementById("legend").innerHTML =
334
+ Object.values(KINDS).map(k => `<span class="item"><span class="swatch" style="background:${css(k.varName)};
335
+ border-radius:${k.shape === "ellipse" ? "50%" : "2px"}"></span>${k.label}</span>`).join(""); });
336
+
337
+ updateStats();
338
+ cy.fit(undefined, 40);
339
+ </script>
340
+ </body>
341
+ </html>