codeui-python 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.
- codeui/__init__.py +23 -0
- codeui/agent/__init__.py +15 -0
- codeui/agent/context.py +207 -0
- codeui/agent/contribution.py +224 -0
- codeui/agent/edit_tools.py +280 -0
- codeui/agent/integrations.py +243 -0
- codeui/agent/mcp_server.py +94 -0
- codeui/analysis/__init__.py +1 -0
- codeui/analysis/api_drift.py +79 -0
- codeui/analysis/base.py +42 -0
- codeui/analysis/circular.py +116 -0
- codeui/analysis/dead_code.py +63 -0
- codeui/analysis/duplicates.py +69 -0
- codeui/analysis/runner.py +57 -0
- codeui/analysis/shadowing.py +49 -0
- codeui/analysis/undefined.py +97 -0
- codeui/analysis/unreachable.py +28 -0
- codeui/analysis/unresolved_import.py +153 -0
- codeui/cli/__init__.py +1 -0
- codeui/cli/main.py +437 -0
- codeui/core/__init__.py +1 -0
- codeui/core/cache.py +135 -0
- codeui/core/graph.py +435 -0
- codeui/core/ir.py +218 -0
- codeui/core/override.py +230 -0
- codeui/core/repo.py +85 -0
- codeui/core/resolver.py +281 -0
- codeui/errors.py +98 -0
- codeui/lang/__init__.py +1 -0
- codeui/lang/base.py +117 -0
- codeui/lang/generic.py +90 -0
- codeui/lang/go.py +167 -0
- codeui/lang/python.py +294 -0
- codeui/lang/registry.py +83 -0
- codeui/lang/rust.py +185 -0
- codeui/lang/ts.py +396 -0
- codeui/plugins/__init__.py +1 -0
- codeui/plugins/registry.py +31 -0
- codeui/report/__init__.py +1 -0
- codeui/report/json_emitter.py +24 -0
- codeui/report/markdown_emitter.py +33 -0
- codeui/report/sarif_emitter.py +56 -0
- codeui/server/__init__.py +1 -0
- codeui/server/server.py +705 -0
- codeui/tracer/__init__.py +1 -0
- codeui/tracer/tracer.py +147 -0
- codeui_python-0.1.0.dist-info/METADATA +191 -0
- codeui_python-0.1.0.dist-info/RECORD +52 -0
- codeui_python-0.1.0.dist-info/WHEEL +5 -0
- codeui_python-0.1.0.dist-info/entry_points.txt +2 -0
- codeui_python-0.1.0.dist-info/licenses/LICENSE +21 -0
- codeui_python-0.1.0.dist-info/top_level.txt +1 -0
codeui/core/cache.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Cache layer for per-file analysis results based on content hashes."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict
|
|
7
|
+
|
|
8
|
+
class AnalysisCache:
|
|
9
|
+
"""SQLite-backed persistent cache for per-file symbol and edge extraction.
|
|
10
|
+
Example:
|
|
11
|
+
>>> cache = AnalysisCache(Path(".codeui/test_cache.db"))
|
|
12
|
+
>>> cache.get("a.py", "hash123") is None
|
|
13
|
+
True
|
|
14
|
+
>>> cache.close()
|
|
15
|
+
"""
|
|
16
|
+
def __init__(self, db_path: Path) -> None:
|
|
17
|
+
self.db_path = db_path
|
|
18
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
self.conn = sqlite3.connect(str(self.db_path))
|
|
20
|
+
self._init_db()
|
|
21
|
+
|
|
22
|
+
def _init_db(self) -> None:
|
|
23
|
+
"""Create cache tables if they do not exist."""
|
|
24
|
+
with self.conn:
|
|
25
|
+
self.conn.execute(
|
|
26
|
+
"""
|
|
27
|
+
CREATE TABLE IF NOT EXISTS file_cache (
|
|
28
|
+
file_path TEXT PRIMARY KEY,
|
|
29
|
+
content_hash TEXT NOT NULL,
|
|
30
|
+
payload_json TEXT NOT NULL
|
|
31
|
+
)
|
|
32
|
+
"""
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def get(self, file_path: str, content_hash: str) -> Dict[str, Any] | None:
|
|
36
|
+
"""Fetch cached symbols and edges for a file matching content hash.
|
|
37
|
+
Example:
|
|
38
|
+
>>> cache = AnalysisCache(Path(".codeui/test_cache.db"))
|
|
39
|
+
>>> cache.get("file.py", "hash") is None
|
|
40
|
+
True
|
|
41
|
+
>>> cache.close()
|
|
42
|
+
"""
|
|
43
|
+
cursor = self.conn.cursor()
|
|
44
|
+
cursor.execute(
|
|
45
|
+
"SELECT content_hash, payload_json FROM file_cache WHERE file_path = ?",
|
|
46
|
+
(file_path,),
|
|
47
|
+
)
|
|
48
|
+
row = cursor.fetchone()
|
|
49
|
+
if row and row[0] == content_hash:
|
|
50
|
+
return json.loads(row[1])
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
def put(self, file_path: str, content_hash: str, payload: Dict[str, Any]) -> None:
|
|
54
|
+
"""Save analysis payload into cache.
|
|
55
|
+
Example:
|
|
56
|
+
>>> cache = AnalysisCache(Path(".codeui/test_cache.db"))
|
|
57
|
+
>>> cache.put("file.py", "h1", {"symbols": []})
|
|
58
|
+
>>> cache.get("file.py", "h1")["symbols"]
|
|
59
|
+
[]
|
|
60
|
+
>>> cache.close()
|
|
61
|
+
"""
|
|
62
|
+
payload_json = json.dumps(payload, sort_keys=True)
|
|
63
|
+
with self.conn:
|
|
64
|
+
self.conn.execute(
|
|
65
|
+
"""
|
|
66
|
+
INSERT OR REPLACE INTO file_cache (file_path, content_hash, payload_json)
|
|
67
|
+
VALUES (?, ?, ?)
|
|
68
|
+
""",
|
|
69
|
+
(file_path, content_hash, payload_json),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def invalidate(self, file_path: str) -> None:
|
|
73
|
+
"""Invalidate cached entry for file.
|
|
74
|
+
Example:
|
|
75
|
+
>>> cache = AnalysisCache(Path(".codeui/test_cache.db"))
|
|
76
|
+
>>> cache.invalidate("file.py")
|
|
77
|
+
>>> cache.close()
|
|
78
|
+
"""
|
|
79
|
+
with self.conn:
|
|
80
|
+
self.conn.execute("DELETE FROM file_cache WHERE file_path = ?", (file_path,))
|
|
81
|
+
|
|
82
|
+
def put_symbols(self, file_path: str, content_hash: str, symbols: Any) -> None:
|
|
83
|
+
"""Store list of Symbol objects in cache."""
|
|
84
|
+
self.put(file_path, content_hash, {"symbols": [s.to_dict() if hasattr(s, "to_dict") else s for s in symbols]})
|
|
85
|
+
|
|
86
|
+
def get_symbols(self, file_path: str, content_hash: str) -> Any:
|
|
87
|
+
"""Retrieve list of Symbol objects from cache."""
|
|
88
|
+
data = self.get(file_path, content_hash)
|
|
89
|
+
if data is None or "symbols" not in data:
|
|
90
|
+
return None
|
|
91
|
+
from codeui.core.ir import Location, Symbol, SymbolKind, Visibility
|
|
92
|
+
result = []
|
|
93
|
+
for s in data["symbols"]:
|
|
94
|
+
loc_data = s.get("location", {})
|
|
95
|
+
loc = Location(
|
|
96
|
+
file_id=loc_data.get("file_id", file_path),
|
|
97
|
+
start_line=loc_data.get("start_line", 1),
|
|
98
|
+
start_col=loc_data.get("start_col", 0),
|
|
99
|
+
end_line=loc_data.get("end_line", 1),
|
|
100
|
+
end_col=loc_data.get("end_col", 0),
|
|
101
|
+
)
|
|
102
|
+
sym = Symbol(
|
|
103
|
+
id=s["id"],
|
|
104
|
+
name=s["name"],
|
|
105
|
+
qualified_name=s["qualified_name"],
|
|
106
|
+
kind=SymbolKind(s["kind"]),
|
|
107
|
+
language=s["language"],
|
|
108
|
+
location=loc,
|
|
109
|
+
parent_id=s.get("parent_id"),
|
|
110
|
+
signature=s.get("signature"),
|
|
111
|
+
visibility=Visibility(s.get("visibility", "public")),
|
|
112
|
+
modifiers=tuple(s.get("modifiers", ())),
|
|
113
|
+
content_hash=s.get("content_hash", ""),
|
|
114
|
+
)
|
|
115
|
+
result.append(sym)
|
|
116
|
+
return result
|
|
117
|
+
|
|
118
|
+
def close(self) -> None:
|
|
119
|
+
"""Close SQLite database connection.
|
|
120
|
+
Example:
|
|
121
|
+
>>> cache = AnalysisCache(Path(".codeui/test_cache.db"))
|
|
122
|
+
>>> cache.close()
|
|
123
|
+
"""
|
|
124
|
+
self.conn.close()
|
|
125
|
+
|
|
126
|
+
Cache = AnalysisCache
|
|
127
|
+
|
|
128
|
+
def compute_content_hash(content: str) -> str:
|
|
129
|
+
"""Compute SHA256 hash of source code text.
|
|
130
|
+
Example:
|
|
131
|
+
>>> h = compute_content_hash("print('hello')")
|
|
132
|
+
>>> len(h)
|
|
133
|
+
64
|
|
134
|
+
"""
|
|
135
|
+
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
codeui/core/graph.py
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""In-memory universal code graph store."""
|
|
2
|
+
import fnmatch
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import asdict
|
|
5
|
+
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
|
|
6
|
+
from codeui.core.ir import Edge, EdgeKind, FileClassification, Finding, LayerKind, Location, Severity, Symbol, SymbolKind, Visibility
|
|
7
|
+
from codeui.errors import SymbolNotFoundError, FileNotFoundInGraphError
|
|
8
|
+
|
|
9
|
+
def classify_file(file_path: str, entry_points: Optional[Iterable[str]] = None, layer_rules: Optional[Dict[str, Any]] = None) -> FileClassification:
|
|
10
|
+
"""Classify a file path into architectural layer, feature, and module.
|
|
11
|
+
Example:
|
|
12
|
+
>>> c = classify_file("src/components/auth/LoginModal.tsx")
|
|
13
|
+
>>> str(c.layer)
|
|
14
|
+
'frontend'
|
|
15
|
+
>>> c.feature
|
|
16
|
+
'components/auth'
|
|
17
|
+
>>> c2 = classify_file("custom/path/app.py", entry_points=["custom/path/app.py"])
|
|
18
|
+
>>> str(c2.layer)
|
|
19
|
+
'entry'
|
|
20
|
+
"""
|
|
21
|
+
clean_path = file_path.replace("\\", "/").lstrip("./")
|
|
22
|
+
parts = [p for p in clean_path.split("/") if p]
|
|
23
|
+
if not parts:
|
|
24
|
+
return FileClassification(LayerKind.OTHER, "root", "root")
|
|
25
|
+
|
|
26
|
+
filename = parts[-1]
|
|
27
|
+
name_lower = filename.lower()
|
|
28
|
+
stem = filename.rsplit(".", 1)[0] if "." in filename else filename
|
|
29
|
+
stem_lower = stem.lower()
|
|
30
|
+
parts_lower = [p.lower() for p in parts]
|
|
31
|
+
|
|
32
|
+
if entry_points:
|
|
33
|
+
clean_entries = [str(ep).replace("\\", "/").lstrip("./") for ep in entry_points]
|
|
34
|
+
for ep in clean_entries:
|
|
35
|
+
ep_sub = ep.split("/", 1)[1] if "/" in ep else ep
|
|
36
|
+
is_entry_match = (
|
|
37
|
+
clean_path == ep
|
|
38
|
+
or clean_path == ep_sub
|
|
39
|
+
or (any(c in ep for c in "*?[") and fnmatch.fnmatch(clean_path, ep))
|
|
40
|
+
)
|
|
41
|
+
if is_entry_match:
|
|
42
|
+
return FileClassification(LayerKind.ENTRY, "app_entry", parts[0] if len(parts) > 1 else "root")
|
|
43
|
+
|
|
44
|
+
if any(p in ("tests", "test", "__tests__", "spec", "specs", "benchmarks") for p in parts_lower) or any(name_lower.endswith(s) for s in (".test.ts", ".test.tsx", ".test.js", ".test.jsx", ".spec.ts", ".spec.tsx", ".spec.js", ".spec.jsx", "_test.py", "_test.go", "_spec.rb", "_test.rs")):
|
|
45
|
+
return FileClassification(LayerKind.TEST, "test", "test")
|
|
46
|
+
|
|
47
|
+
if filename in ("tsconfig.json", "package.json", "pyproject.toml", "vite.config.ts", "vite.config.js", "webpack.config.js", "Cargo.toml", "go.mod", "Makefile", "Dockerfile", ".codeui.json", "codeui.json") or filename.startswith("."):
|
|
48
|
+
return FileClassification(LayerKind.CONFIG, "config", "config")
|
|
49
|
+
|
|
50
|
+
entry_stems = {"__main__", "main", "app", "cli", "run", "entry", "start", "server"}
|
|
51
|
+
is_root_level = len(parts) <= 1 or (len(parts) == 2 and parts[0] in ("src", "cmd", "bin", "app"))
|
|
52
|
+
is_entry = (
|
|
53
|
+
(stem_lower in entry_stems and is_root_level)
|
|
54
|
+
or (filename in ("__main__.py", "main.py", "main.go", "main.rs", "main.ts", "main.js", "index.ts", "index.js", "index.tsx", "index.jsx", "app.py", "app.ts", "app.tsx", "app.js") and is_root_level)
|
|
55
|
+
or (stem_lower.endswith("_entry") or stem_lower.endswith("_main"))
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
if is_entry:
|
|
59
|
+
return FileClassification(LayerKind.ENTRY, "app_entry", parts[0] if len(parts) > 1 else "root")
|
|
60
|
+
|
|
61
|
+
content_parts = parts[1:] if len(parts) > 1 and parts[0].lower() in ("src", "pkg", "crates", "services", "apps", "packages", "libs", "cmd", "internal", "modules") else parts
|
|
62
|
+
module = content_parts[0] if len(content_parts) > 1 else ("root" if len(parts) == 1 else parts[0])
|
|
63
|
+
|
|
64
|
+
if len(content_parts) > 1:
|
|
65
|
+
feature = "/".join(content_parts[:-1])
|
|
66
|
+
else:
|
|
67
|
+
feature = "app_entry" if is_entry else "root"
|
|
68
|
+
|
|
69
|
+
if layer_rules:
|
|
70
|
+
for layer_str, patterns in layer_rules.items():
|
|
71
|
+
if isinstance(patterns, (list, tuple, set)):
|
|
72
|
+
for pattern in patterns:
|
|
73
|
+
pat_clean = str(pattern).replace("\\", "/").lstrip("./").lower()
|
|
74
|
+
pat_base = pat_clean.rstrip("/*").rstrip("*")
|
|
75
|
+
is_match = (
|
|
76
|
+
pat_clean == clean_path.lower()
|
|
77
|
+
or (any(c in pat_clean for c in "*?[") and fnmatch.fnmatch(clean_path.lower(), pat_clean))
|
|
78
|
+
or (pat_base and (clean_path.lower().startswith(pat_base + "/") or clean_path.lower() == pat_base))
|
|
79
|
+
or (len(parts) > 1 and any(p.lower() == pat_base for p in parts[:-1]))
|
|
80
|
+
)
|
|
81
|
+
if is_match:
|
|
82
|
+
try:
|
|
83
|
+
lk = LayerKind(layer_str)
|
|
84
|
+
return FileClassification(lk, feature, module)
|
|
85
|
+
except ValueError:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
shared_kw = {"utils", "util", "common", "shared", "helpers", "helper", "types", "constants", "errors", "exceptions"}
|
|
89
|
+
hooks_kw = {"hooks", "composables"}
|
|
90
|
+
frontend_kw = {"components", "screens", "views", "pages", "features", "ui", "frontend", "styles", "widgets", "assets", "static", "public", "templates", "client", "web"}
|
|
91
|
+
backend_kw = {"server", "services", "service", "api", "backend", "domain", "data", "db", "models", "model", "controllers", "controller", "routes", "route", "handler", "handlers", "repository", "queries", "libraries", "srv", "analysis", "report", "plugins", "lang", "tracer", "agent"}
|
|
92
|
+
|
|
93
|
+
is_hook_stem = (stem.startswith("use") and len(stem) > 3 and stem[3].isupper() and name_lower.endswith((".ts", ".tsx", ".js", ".jsx", ".vue", ".svelte"))) or (stem.startswith("use_") and name_lower.endswith(".py") and any(p in ("hooks", "composables") for p in parts_lower))
|
|
94
|
+
|
|
95
|
+
layer = LayerKind.OTHER
|
|
96
|
+
if any(p in shared_kw for p in parts_lower[:-1]) or stem_lower in shared_kw:
|
|
97
|
+
layer = LayerKind.SHARED
|
|
98
|
+
elif any(p in hooks_kw for p in parts_lower[:-1]) or is_hook_stem:
|
|
99
|
+
layer = LayerKind.HOOKS
|
|
100
|
+
elif any(p in frontend_kw for p in parts_lower[:-1]) or stem_lower in ("button", "modal", "card", "view", "page", "screen", "layout", "canvas", "sidebar") or name_lower.endswith((".css", ".scss", ".less", ".html", ".jsx", ".tsx", ".vue", ".svelte")):
|
|
101
|
+
layer = LayerKind.FRONTEND
|
|
102
|
+
elif any(p in backend_kw for p in parts_lower[:-1]) or stem_lower in ("server", "daemon", "service", "api", "routes", "db", "models", "controller", "handler", "backend", "tracer", "context", "graph", "resolver", "runner"):
|
|
103
|
+
layer = LayerKind.BACKEND
|
|
104
|
+
elif is_entry:
|
|
105
|
+
layer = LayerKind.ENTRY
|
|
106
|
+
|
|
107
|
+
if layer == LayerKind.ENTRY:
|
|
108
|
+
feature = "app_entry"
|
|
109
|
+
|
|
110
|
+
return FileClassification(layer=layer, feature=feature, module=module)
|
|
111
|
+
|
|
112
|
+
class Graph:
|
|
113
|
+
"""Universal code graph storing symbols, edges, and findings.
|
|
114
|
+
Example:
|
|
115
|
+
>>> g = Graph()
|
|
116
|
+
>>> loc = Location("main.py", 1, 0, 2, 0)
|
|
117
|
+
>>> s = Symbol("s1", "main", "main.main", SymbolKind.FUNCTION, "python", loc, None, "def main():", Visibility.PUBLIC, (), "h1")
|
|
118
|
+
>>> g.add_symbol(s)
|
|
119
|
+
>>> g.get_symbol("s1").name
|
|
120
|
+
'main'
|
|
121
|
+
"""
|
|
122
|
+
def __init__(self) -> None:
|
|
123
|
+
self._symbols: Dict[str, Symbol] = {}
|
|
124
|
+
self._edges: List[Edge] = []
|
|
125
|
+
self._edge_keys: Set[Tuple[str, str, str]] = set()
|
|
126
|
+
self._out_edges: Dict[str, List[Edge]] = {}
|
|
127
|
+
self._in_edges: Dict[str, List[Edge]] = {}
|
|
128
|
+
self._file_symbols: Dict[str, Set[str]] = {}
|
|
129
|
+
self._findings: List[Finding] = []
|
|
130
|
+
self._entry_points: Set[str] = set()
|
|
131
|
+
self._layer_rules: Dict[str, Any] = {}
|
|
132
|
+
|
|
133
|
+
def set_entry_points(self, entry_points: Iterable[str]) -> None:
|
|
134
|
+
"""Set user-specified application entry points.
|
|
135
|
+
Example:
|
|
136
|
+
>>> g = Graph()
|
|
137
|
+
>>> g.set_entry_points(["codeui/__init__.py"])
|
|
138
|
+
>>> "codeui/__init__.py" in g._entry_points
|
|
139
|
+
True
|
|
140
|
+
"""
|
|
141
|
+
self._entry_points = {str(ep).replace("\\", "/").lstrip("./") for ep in entry_points}
|
|
142
|
+
|
|
143
|
+
def set_layer_rules(self, rules: Dict[str, Any]) -> None:
|
|
144
|
+
"""Set user-specified layer classification rules.
|
|
145
|
+
Example:
|
|
146
|
+
>>> g = Graph()
|
|
147
|
+
>>> g.set_layer_rules({"frontend": ["ui/*"]})
|
|
148
|
+
>>> "frontend" in g._layer_rules
|
|
149
|
+
True
|
|
150
|
+
"""
|
|
151
|
+
self._layer_rules = dict(rules)
|
|
152
|
+
|
|
153
|
+
def add_symbol(self, symbol: Symbol) -> None:
|
|
154
|
+
"""Add or replace a symbol in the graph.
|
|
155
|
+
Example:
|
|
156
|
+
>>> g = Graph()
|
|
157
|
+
>>> loc = Location("a.py", 1, 0, 2, 0)
|
|
158
|
+
>>> s = Symbol("s1", "f", "a.f", SymbolKind.FUNCTION, "python", loc, None, None, Visibility.PUBLIC, (), "h")
|
|
159
|
+
>>> g.add_symbol(s)
|
|
160
|
+
>>> g.has_symbol("s1")
|
|
161
|
+
True
|
|
162
|
+
"""
|
|
163
|
+
file_id = symbol.location.file_id if symbol.location else ""
|
|
164
|
+
file_lower = file_id.lower()
|
|
165
|
+
if "__pycache__" in file_lower or ".pytest_cache" in file_lower or file_lower.endswith((".pyc", ".pyo", ".pyd")):
|
|
166
|
+
return
|
|
167
|
+
self._symbols[symbol.id] = symbol
|
|
168
|
+
if file_id not in self._file_symbols:
|
|
169
|
+
self._file_symbols[file_id] = set()
|
|
170
|
+
self._file_symbols[file_id].add(symbol.id)
|
|
171
|
+
|
|
172
|
+
def remove_file_symbols(self, file_id: str) -> None:
|
|
173
|
+
"""Remove all symbols and associated edges for a file.
|
|
174
|
+
Example:
|
|
175
|
+
>>> g = Graph()
|
|
176
|
+
>>> loc = Location("a.py", 1, 0, 2, 0)
|
|
177
|
+
>>> s = Symbol("s1", "f", "a.f", SymbolKind.FUNCTION, "python", loc, None, None, Visibility.PUBLIC, (), "h")
|
|
178
|
+
>>> g.add_symbol(s)
|
|
179
|
+
>>> g.remove_file_symbols("a.py")
|
|
180
|
+
>>> g.has_symbol("s1")
|
|
181
|
+
False
|
|
182
|
+
"""
|
|
183
|
+
symbol_ids = self._file_symbols.pop(file_id, set())
|
|
184
|
+
for sym_id in symbol_ids:
|
|
185
|
+
self._symbols.pop(sym_id, None)
|
|
186
|
+
self._out_edges.pop(sym_id, None)
|
|
187
|
+
self._in_edges.pop(sym_id, None)
|
|
188
|
+
self._edges = [
|
|
189
|
+
e for e in self._edges
|
|
190
|
+
if e.source_id not in symbol_ids and e.target_id not in symbol_ids
|
|
191
|
+
]
|
|
192
|
+
self._rebuild_edge_indexes()
|
|
193
|
+
|
|
194
|
+
def add_edge(self, edge: Edge) -> None:
|
|
195
|
+
"""Add a directed edge between symbols.
|
|
196
|
+
Example:
|
|
197
|
+
>>> g = Graph()
|
|
198
|
+
>>> edge = Edge("s1", "s2", EdgeKind.CALLS, 1.0, 1.0, None)
|
|
199
|
+
>>> g.add_edge(edge)
|
|
200
|
+
>>> len(g.get_edges())
|
|
201
|
+
1
|
|
202
|
+
"""
|
|
203
|
+
s_lower = edge.source_id.lower()
|
|
204
|
+
t_lower = edge.target_id.lower()
|
|
205
|
+
if "__pycache__" in s_lower or "__pycache__" in t_lower or s_lower.endswith((".pyc", ".pyo", ".pyd")) or t_lower.endswith((".pyc", ".pyo", ".pyd")):
|
|
206
|
+
return
|
|
207
|
+
key = (edge.source_id, edge.target_id, str(edge.kind))
|
|
208
|
+
if key in self._edge_keys:
|
|
209
|
+
return
|
|
210
|
+
self._edge_keys.add(key)
|
|
211
|
+
self._edges.append(edge)
|
|
212
|
+
self._out_edges.setdefault(edge.source_id, []).append(edge)
|
|
213
|
+
self._in_edges.setdefault(edge.target_id, []).append(edge)
|
|
214
|
+
|
|
215
|
+
def add_finding(self, finding: Finding) -> None:
|
|
216
|
+
"""Add a defect finding to the graph.
|
|
217
|
+
Example:
|
|
218
|
+
>>> g = Graph()
|
|
219
|
+
>>> loc = Location("a.py", 1, 0, 1, 5)
|
|
220
|
+
>>> f = Finding("f1", "rule1", "msg", Severity.ERROR, loc, None, None, 1.0)
|
|
221
|
+
>>> g.add_finding(f)
|
|
222
|
+
>>> len(g.get_findings())
|
|
223
|
+
1
|
|
224
|
+
"""
|
|
225
|
+
self._findings.append(finding)
|
|
226
|
+
|
|
227
|
+
def get_findings(self) -> List[Finding]:
|
|
228
|
+
"""Return all findings sorted deterministically.
|
|
229
|
+
Example:
|
|
230
|
+
>>> g = Graph()
|
|
231
|
+
>>> g.get_findings()
|
|
232
|
+
[]
|
|
233
|
+
"""
|
|
234
|
+
return sorted(self._findings, key=lambda f: f.id)
|
|
235
|
+
|
|
236
|
+
def get_symbol(self, symbol_id: str) -> Symbol:
|
|
237
|
+
"""Fetch symbol by ID or raise SymbolNotFoundError.
|
|
238
|
+
Example:
|
|
239
|
+
>>> g = Graph()
|
|
240
|
+
>>> loc = Location("a.py", 1, 0, 2, 0)
|
|
241
|
+
>>> g.add_symbol(Symbol("s1", "f", "a.f", SymbolKind.FUNCTION, "py", loc, None, None, Visibility.PUBLIC, (), "h"))
|
|
242
|
+
>>> g.get_symbol("s1").name
|
|
243
|
+
'f'
|
|
244
|
+
"""
|
|
245
|
+
if symbol_id not in self._symbols:
|
|
246
|
+
raise SymbolNotFoundError(symbol_id)
|
|
247
|
+
return self._symbols[symbol_id]
|
|
248
|
+
|
|
249
|
+
def has_symbol(self, symbol_id: str) -> bool:
|
|
250
|
+
"""Check if symbol exists.
|
|
251
|
+
Example:
|
|
252
|
+
>>> g = Graph()
|
|
253
|
+
>>> g.has_symbol("missing")
|
|
254
|
+
False
|
|
255
|
+
"""
|
|
256
|
+
return symbol_id in self._symbols
|
|
257
|
+
|
|
258
|
+
def get_symbols(self) -> List[Symbol]:
|
|
259
|
+
"""Return list of all symbols in the graph.
|
|
260
|
+
Example:
|
|
261
|
+
>>> g = Graph()
|
|
262
|
+
>>> g.get_symbols()
|
|
263
|
+
[]
|
|
264
|
+
"""
|
|
265
|
+
return self.get_all_symbols()
|
|
266
|
+
|
|
267
|
+
def get_all_symbols(self) -> List[Symbol]:
|
|
268
|
+
"""Return all symbols sorted deterministically by ID.
|
|
269
|
+
Example:
|
|
270
|
+
>>> g = Graph()
|
|
271
|
+
>>> g.get_all_symbols()
|
|
272
|
+
[]
|
|
273
|
+
"""
|
|
274
|
+
return sorted(self._symbols.values(), key=lambda s: s.id)
|
|
275
|
+
|
|
276
|
+
def get_symbols_by_file(self, file_id: str) -> List[Symbol]:
|
|
277
|
+
"""Return all symbols belonging to a specific file.
|
|
278
|
+
Example:
|
|
279
|
+
>>> g = Graph()
|
|
280
|
+
>>> g.get_symbols_by_file("nonexistent.py")
|
|
281
|
+
[]
|
|
282
|
+
"""
|
|
283
|
+
sym_ids = self._file_symbols.get(file_id)
|
|
284
|
+
if sym_ids is None:
|
|
285
|
+
clean = file_id.replace("\\", "/").lstrip("./")
|
|
286
|
+
for k, v in self._file_symbols.items():
|
|
287
|
+
k_clean = k.replace("\\", "/").lstrip("./")
|
|
288
|
+
if k_clean == clean or k_clean.endswith(clean) or clean.endswith(k_clean):
|
|
289
|
+
sym_ids = v
|
|
290
|
+
break
|
|
291
|
+
if not sym_ids:
|
|
292
|
+
return []
|
|
293
|
+
symbols = [self._symbols[sid] for sid in sym_ids if sid in self._symbols]
|
|
294
|
+
return sorted(symbols, key=lambda s: (s.location.start_line if s.location else 0, s.id))
|
|
295
|
+
|
|
296
|
+
def get_all_files(self) -> List[str]:
|
|
297
|
+
"""Return list of all analyzed file paths sorted.
|
|
298
|
+
Example:
|
|
299
|
+
>>> g = Graph()
|
|
300
|
+
>>> g.get_all_files()
|
|
301
|
+
[]
|
|
302
|
+
"""
|
|
303
|
+
return sorted(list(self._file_symbols.keys()))
|
|
304
|
+
|
|
305
|
+
def get_edges(self) -> List[Edge]:
|
|
306
|
+
"""Return all graph edges sorted deterministically.
|
|
307
|
+
Example:
|
|
308
|
+
>>> g = Graph()
|
|
309
|
+
>>> g.get_edges()
|
|
310
|
+
[]
|
|
311
|
+
"""
|
|
312
|
+
return sorted(self._edges, key=lambda e: (e.source_id, e.target_id, str(e.kind)))
|
|
313
|
+
|
|
314
|
+
def get_outgoing_edges(self, source_id: str, kind: EdgeKind | None = None) -> List[Edge]:
|
|
315
|
+
"""Return outgoing edges from source symbol.
|
|
316
|
+
Example:
|
|
317
|
+
>>> g = Graph()
|
|
318
|
+
>>> g.get_outgoing_edges("s1")
|
|
319
|
+
[]
|
|
320
|
+
"""
|
|
321
|
+
edges = self._out_edges.get(source_id, [])
|
|
322
|
+
if kind is not None:
|
|
323
|
+
edges = [e for e in edges if e.kind == kind]
|
|
324
|
+
return sorted(edges, key=lambda e: (e.target_id, str(e.kind)))
|
|
325
|
+
|
|
326
|
+
def get_incoming_edges(self, target_id: str, kind: EdgeKind | None = None) -> List[Edge]:
|
|
327
|
+
"""Return incoming edges targeting symbol.
|
|
328
|
+
Example:
|
|
329
|
+
>>> g = Graph()
|
|
330
|
+
>>> g.get_incoming_edges("s2")
|
|
331
|
+
[]
|
|
332
|
+
"""
|
|
333
|
+
edges = self._in_edges.get(target_id, [])
|
|
334
|
+
if kind is not None:
|
|
335
|
+
edges = [e for e in edges if e.kind == kind]
|
|
336
|
+
return sorted(edges, key=lambda e: (e.source_id, e.target_id, str(e.kind)))
|
|
337
|
+
|
|
338
|
+
def get_subgraph(self, symbol_id: str, depth: int | None = None) -> Tuple[List[Symbol], List[Edge]]:
|
|
339
|
+
"""Extract a sub-graph surrounding a target symbol up to specified depth or all reachable nodes if depth is None.
|
|
340
|
+
Example:
|
|
341
|
+
>>> g = Graph()
|
|
342
|
+
>>> loc = Location("a.py", 1, 0, 2, 0)
|
|
343
|
+
>>> g.add_symbol(Symbol("s1", "f", "a.f", SymbolKind.FUNCTION, "py", loc, None, None, Visibility.PUBLIC, (), "h"))
|
|
344
|
+
>>> syms, edges = g.get_subgraph("s1", 1)
|
|
345
|
+
>>> len(syms)
|
|
346
|
+
1
|
|
347
|
+
"""
|
|
348
|
+
if symbol_id not in self._symbols:
|
|
349
|
+
raise SymbolNotFoundError(symbol_id)
|
|
350
|
+
visited_symbols: Set[str] = {symbol_id}
|
|
351
|
+
frontier: Set[str] = {symbol_id}
|
|
352
|
+
collected_edges: Set[Edge] = set()
|
|
353
|
+
curr_depth = 0
|
|
354
|
+
while frontier:
|
|
355
|
+
if depth is not None and curr_depth >= depth:
|
|
356
|
+
break
|
|
357
|
+
next_frontier: Set[str] = set()
|
|
358
|
+
for current_id in frontier:
|
|
359
|
+
out_edges = self._out_edges.get(current_id, [])
|
|
360
|
+
in_edges = self._in_edges.get(current_id, [])
|
|
361
|
+
for e in out_edges:
|
|
362
|
+
collected_edges.add(e)
|
|
363
|
+
if e.target_id in self._symbols and e.target_id not in visited_symbols:
|
|
364
|
+
visited_symbols.add(e.target_id)
|
|
365
|
+
next_frontier.add(e.target_id)
|
|
366
|
+
for e in in_edges:
|
|
367
|
+
collected_edges.add(e)
|
|
368
|
+
if e.source_id in self._symbols and e.source_id not in visited_symbols:
|
|
369
|
+
visited_symbols.add(e.source_id)
|
|
370
|
+
next_frontier.add(e.source_id)
|
|
371
|
+
frontier = next_frontier
|
|
372
|
+
curr_depth += 1
|
|
373
|
+
symbols = [self._symbols[sid] for sid in sorted(visited_symbols)]
|
|
374
|
+
edges = sorted(list(collected_edges), key=lambda e: (e.source_id, e.target_id, str(e.kind)))
|
|
375
|
+
return symbols, edges
|
|
376
|
+
|
|
377
|
+
def get_file_classification(self, file_id: str) -> FileClassification:
|
|
378
|
+
"""Return architectural classification for a specific file.
|
|
379
|
+
Example:
|
|
380
|
+
>>> g = Graph()
|
|
381
|
+
>>> c = g.get_file_classification("src/components/Button.tsx")
|
|
382
|
+
>>> str(c.layer)
|
|
383
|
+
'frontend'
|
|
384
|
+
"""
|
|
385
|
+
return classify_file(file_id, entry_points=self._entry_points, layer_rules=self._layer_rules)
|
|
386
|
+
|
|
387
|
+
def get_files_info(self) -> Dict[str, Dict[str, Any]]:
|
|
388
|
+
"""Return detailed architectural information for all files in the graph.
|
|
389
|
+
Example:
|
|
390
|
+
>>> g = Graph()
|
|
391
|
+
>>> loc = Location("a.py", 1, 0, 1, 10)
|
|
392
|
+
>>> g.add_symbol(Symbol("a.py", "a.py", "a.py", SymbolKind.FILE, "python", loc, None, None, Visibility.PUBLIC, (), "h"))
|
|
393
|
+
>>> info = g.get_files_info()
|
|
394
|
+
>>> "a.py" in info
|
|
395
|
+
True
|
|
396
|
+
"""
|
|
397
|
+
result: Dict[str, Dict[str, Any]] = {}
|
|
398
|
+
for file_id in self.get_all_files():
|
|
399
|
+
classification = self.get_file_classification(file_id)
|
|
400
|
+
syms = self.get_symbols_by_file(file_id)
|
|
401
|
+
result[file_id] = {
|
|
402
|
+
"path": file_id,
|
|
403
|
+
"layer": str(classification.layer),
|
|
404
|
+
"feature": classification.feature,
|
|
405
|
+
"module": classification.module,
|
|
406
|
+
"symbol_count": len(syms),
|
|
407
|
+
"symbols_count": len(syms),
|
|
408
|
+
"symbol_ids": [s.id for s in syms],
|
|
409
|
+
}
|
|
410
|
+
return result
|
|
411
|
+
|
|
412
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
413
|
+
"""Convert graph state to deterministic JSON-serializable dictionary.
|
|
414
|
+
Example:
|
|
415
|
+
>>> g = Graph()
|
|
416
|
+
>>> d = g.to_dict()
|
|
417
|
+
>>> "symbols" in d and "edges" in d
|
|
418
|
+
True
|
|
419
|
+
"""
|
|
420
|
+
return {
|
|
421
|
+
"symbols": [s.to_dict() for s in self.get_all_symbols()],
|
|
422
|
+
"edges": [e.to_dict() for e in self.get_edges()],
|
|
423
|
+
"findings": [f.to_dict() for f in self.get_findings()],
|
|
424
|
+
"files": self.get_all_files(),
|
|
425
|
+
"files_info": self.get_files_info(),
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
def _rebuild_edge_indexes(self) -> None:
|
|
429
|
+
"""Rebuild outgoing and incoming edge index maps."""
|
|
430
|
+
self._out_edges.clear()
|
|
431
|
+
self._in_edges.clear()
|
|
432
|
+
self._edge_keys = {(e.source_id, e.target_id, str(e.kind)) for e in self._edges}
|
|
433
|
+
for edge in self._edges:
|
|
434
|
+
self._out_edges.setdefault(edge.source_id, []).append(edge)
|
|
435
|
+
self._in_edges.setdefault(edge.target_id, []).append(edge)
|