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 +24 -0
- sciogen/api.py +151 -0
- sciogen/cli/__init__.py +1 -0
- sciogen/cli/banner.py +19 -0
- sciogen/cli/console.py +10 -0
- sciogen/cli/explore/__init__.py +57 -0
- sciogen/cli/explore/template.html +341 -0
- sciogen/cli/main.py +322 -0
- sciogen/config.py +82 -0
- sciogen/discovery.py +125 -0
- sciogen/embed/__init__.py +51 -0
- sciogen/embed/hashing.py +53 -0
- sciogen/embed/nomic.py +34 -0
- sciogen/index/__init__.py +5 -0
- sciogen/index/pipeline.py +363 -0
- sciogen/ir.py +182 -0
- sciogen/mcp/__init__.py +1 -0
- sciogen/mcp/server.py +94 -0
- sciogen/normalize/__init__.py +32 -0
- sciogen/normalize/base.py +121 -0
- sciogen/normalize/generic.py +18 -0
- sciogen/normalize/javascript.py +365 -0
- sciogen/normalize/python.py +358 -0
- sciogen/parsing/__init__.py +1 -0
- sciogen/parsing/languages.py +67 -0
- sciogen/parsing/parser.py +46 -0
- sciogen/query/__init__.py +27 -0
- sciogen/query/engine.py +326 -0
- sciogen/query/results.py +172 -0
- sciogen/resolve/__init__.py +5 -0
- sciogen/resolve/resolver.py +398 -0
- sciogen/stores/__init__.py +1 -0
- sciogen/stores/graph.py +316 -0
- sciogen/stores/meta.py +324 -0
- sciogen/stores/vectors.py +108 -0
- sciogen-0.1.0.dist-info/METADATA +217 -0
- sciogen-0.1.0.dist-info/RECORD +40 -0
- sciogen-0.1.0.dist-info/WHEEL +4 -0
- sciogen-0.1.0.dist-info/entry_points.txt +2 -0
- sciogen-0.1.0.dist-info/licenses/LICENSE +21 -0
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)
|
sciogen/cli/__init__.py
ADDED
|
@@ -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> < 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>
|