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.
Files changed (52) hide show
  1. codeui/__init__.py +23 -0
  2. codeui/agent/__init__.py +15 -0
  3. codeui/agent/context.py +207 -0
  4. codeui/agent/contribution.py +224 -0
  5. codeui/agent/edit_tools.py +280 -0
  6. codeui/agent/integrations.py +243 -0
  7. codeui/agent/mcp_server.py +94 -0
  8. codeui/analysis/__init__.py +1 -0
  9. codeui/analysis/api_drift.py +79 -0
  10. codeui/analysis/base.py +42 -0
  11. codeui/analysis/circular.py +116 -0
  12. codeui/analysis/dead_code.py +63 -0
  13. codeui/analysis/duplicates.py +69 -0
  14. codeui/analysis/runner.py +57 -0
  15. codeui/analysis/shadowing.py +49 -0
  16. codeui/analysis/undefined.py +97 -0
  17. codeui/analysis/unreachable.py +28 -0
  18. codeui/analysis/unresolved_import.py +153 -0
  19. codeui/cli/__init__.py +1 -0
  20. codeui/cli/main.py +437 -0
  21. codeui/core/__init__.py +1 -0
  22. codeui/core/cache.py +135 -0
  23. codeui/core/graph.py +435 -0
  24. codeui/core/ir.py +218 -0
  25. codeui/core/override.py +230 -0
  26. codeui/core/repo.py +85 -0
  27. codeui/core/resolver.py +281 -0
  28. codeui/errors.py +98 -0
  29. codeui/lang/__init__.py +1 -0
  30. codeui/lang/base.py +117 -0
  31. codeui/lang/generic.py +90 -0
  32. codeui/lang/go.py +167 -0
  33. codeui/lang/python.py +294 -0
  34. codeui/lang/registry.py +83 -0
  35. codeui/lang/rust.py +185 -0
  36. codeui/lang/ts.py +396 -0
  37. codeui/plugins/__init__.py +1 -0
  38. codeui/plugins/registry.py +31 -0
  39. codeui/report/__init__.py +1 -0
  40. codeui/report/json_emitter.py +24 -0
  41. codeui/report/markdown_emitter.py +33 -0
  42. codeui/report/sarif_emitter.py +56 -0
  43. codeui/server/__init__.py +1 -0
  44. codeui/server/server.py +705 -0
  45. codeui/tracer/__init__.py +1 -0
  46. codeui/tracer/tracer.py +147 -0
  47. codeui_python-0.1.0.dist-info/METADATA +191 -0
  48. codeui_python-0.1.0.dist-info/RECORD +52 -0
  49. codeui_python-0.1.0.dist-info/WHEEL +5 -0
  50. codeui_python-0.1.0.dist-info/entry_points.txt +2 -0
  51. codeui_python-0.1.0.dist-info/licenses/LICENSE +21 -0
  52. 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)