osscodeiq 0.0.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 (183) hide show
  1. osscodeiq/__init__.py +0 -0
  2. osscodeiq/analyzer.py +467 -0
  3. osscodeiq/cache/__init__.py +0 -0
  4. osscodeiq/cache/hasher.py +23 -0
  5. osscodeiq/cache/store.py +300 -0
  6. osscodeiq/classifiers/__init__.py +0 -0
  7. osscodeiq/classifiers/layer_classifier.py +69 -0
  8. osscodeiq/cli.py +721 -0
  9. osscodeiq/config.py +113 -0
  10. osscodeiq/detectors/__init__.py +0 -0
  11. osscodeiq/detectors/auth/__init__.py +0 -0
  12. osscodeiq/detectors/auth/certificate_auth.py +139 -0
  13. osscodeiq/detectors/auth/ldap_auth.py +89 -0
  14. osscodeiq/detectors/auth/session_header_auth.py +120 -0
  15. osscodeiq/detectors/base.py +41 -0
  16. osscodeiq/detectors/config/__init__.py +0 -0
  17. osscodeiq/detectors/config/batch_structure.py +128 -0
  18. osscodeiq/detectors/config/cloudformation.py +183 -0
  19. osscodeiq/detectors/config/docker_compose.py +179 -0
  20. osscodeiq/detectors/config/github_actions.py +150 -0
  21. osscodeiq/detectors/config/gitlab_ci.py +216 -0
  22. osscodeiq/detectors/config/helm_chart.py +187 -0
  23. osscodeiq/detectors/config/ini_structure.py +101 -0
  24. osscodeiq/detectors/config/json_structure.py +72 -0
  25. osscodeiq/detectors/config/kubernetes.py +305 -0
  26. osscodeiq/detectors/config/kubernetes_rbac.py +212 -0
  27. osscodeiq/detectors/config/openapi.py +194 -0
  28. osscodeiq/detectors/config/package_json.py +99 -0
  29. osscodeiq/detectors/config/properties_detector.py +108 -0
  30. osscodeiq/detectors/config/pyproject_toml.py +169 -0
  31. osscodeiq/detectors/config/sql_structure.py +155 -0
  32. osscodeiq/detectors/config/toml_structure.py +93 -0
  33. osscodeiq/detectors/config/tsconfig_json.py +105 -0
  34. osscodeiq/detectors/config/yaml_structure.py +82 -0
  35. osscodeiq/detectors/cpp/__init__.py +0 -0
  36. osscodeiq/detectors/cpp/cpp_structures.py +192 -0
  37. osscodeiq/detectors/csharp/__init__.py +0 -0
  38. osscodeiq/detectors/csharp/csharp_efcore.py +184 -0
  39. osscodeiq/detectors/csharp/csharp_minimal_apis.py +156 -0
  40. osscodeiq/detectors/csharp/csharp_structures.py +317 -0
  41. osscodeiq/detectors/docs/__init__.py +0 -0
  42. osscodeiq/detectors/docs/markdown_structure.py +117 -0
  43. osscodeiq/detectors/frontend/__init__.py +0 -0
  44. osscodeiq/detectors/frontend/angular_components.py +177 -0
  45. osscodeiq/detectors/frontend/frontend_routes.py +259 -0
  46. osscodeiq/detectors/frontend/react_components.py +148 -0
  47. osscodeiq/detectors/frontend/svelte_components.py +84 -0
  48. osscodeiq/detectors/frontend/vue_components.py +150 -0
  49. osscodeiq/detectors/generic/__init__.py +1 -0
  50. osscodeiq/detectors/generic/imports_detector.py +413 -0
  51. osscodeiq/detectors/go/__init__.py +0 -0
  52. osscodeiq/detectors/go/go_orm.py +202 -0
  53. osscodeiq/detectors/go/go_structures.py +162 -0
  54. osscodeiq/detectors/go/go_web.py +157 -0
  55. osscodeiq/detectors/iac/__init__.py +0 -0
  56. osscodeiq/detectors/iac/bicep.py +135 -0
  57. osscodeiq/detectors/iac/dockerfile.py +182 -0
  58. osscodeiq/detectors/iac/terraform.py +188 -0
  59. osscodeiq/detectors/java/__init__.py +0 -0
  60. osscodeiq/detectors/java/azure_functions.py +424 -0
  61. osscodeiq/detectors/java/azure_messaging.py +350 -0
  62. osscodeiq/detectors/java/class_hierarchy.py +349 -0
  63. osscodeiq/detectors/java/config_def.py +82 -0
  64. osscodeiq/detectors/java/cosmos_db.py +105 -0
  65. osscodeiq/detectors/java/graphql_resolver.py +188 -0
  66. osscodeiq/detectors/java/grpc_service.py +142 -0
  67. osscodeiq/detectors/java/ibm_mq.py +178 -0
  68. osscodeiq/detectors/java/jaxrs.py +160 -0
  69. osscodeiq/detectors/java/jdbc.py +196 -0
  70. osscodeiq/detectors/java/jms.py +116 -0
  71. osscodeiq/detectors/java/jpa_entity.py +143 -0
  72. osscodeiq/detectors/java/kafka.py +113 -0
  73. osscodeiq/detectors/java/kafka_protocol.py +70 -0
  74. osscodeiq/detectors/java/micronaut.py +248 -0
  75. osscodeiq/detectors/java/module_deps.py +191 -0
  76. osscodeiq/detectors/java/public_api.py +206 -0
  77. osscodeiq/detectors/java/quarkus.py +176 -0
  78. osscodeiq/detectors/java/rabbitmq.py +150 -0
  79. osscodeiq/detectors/java/raw_sql.py +136 -0
  80. osscodeiq/detectors/java/repository.py +131 -0
  81. osscodeiq/detectors/java/rmi.py +129 -0
  82. osscodeiq/detectors/java/spring_events.py +117 -0
  83. osscodeiq/detectors/java/spring_rest.py +168 -0
  84. osscodeiq/detectors/java/spring_security.py +212 -0
  85. osscodeiq/detectors/java/tibco_ems.py +193 -0
  86. osscodeiq/detectors/java/websocket.py +188 -0
  87. osscodeiq/detectors/kotlin/__init__.py +0 -0
  88. osscodeiq/detectors/kotlin/kotlin_structures.py +124 -0
  89. osscodeiq/detectors/kotlin/ktor_routes.py +163 -0
  90. osscodeiq/detectors/proto/__init__.py +0 -0
  91. osscodeiq/detectors/proto/proto_structure.py +153 -0
  92. osscodeiq/detectors/python/__init__.py +0 -0
  93. osscodeiq/detectors/python/celery_tasks.py +88 -0
  94. osscodeiq/detectors/python/django_auth.py +132 -0
  95. osscodeiq/detectors/python/django_models.py +157 -0
  96. osscodeiq/detectors/python/django_views.py +74 -0
  97. osscodeiq/detectors/python/fastapi_auth.py +143 -0
  98. osscodeiq/detectors/python/fastapi_routes.py +68 -0
  99. osscodeiq/detectors/python/flask_routes.py +67 -0
  100. osscodeiq/detectors/python/kafka_python.py +175 -0
  101. osscodeiq/detectors/python/pydantic_models.py +115 -0
  102. osscodeiq/detectors/python/python_structures.py +234 -0
  103. osscodeiq/detectors/python/sqlalchemy_models.py +82 -0
  104. osscodeiq/detectors/registry.py +100 -0
  105. osscodeiq/detectors/rust/__init__.py +0 -0
  106. osscodeiq/detectors/rust/actix_web.py +234 -0
  107. osscodeiq/detectors/rust/rust_structures.py +174 -0
  108. osscodeiq/detectors/scala/__init__.py +0 -0
  109. osscodeiq/detectors/scala/scala_structures.py +128 -0
  110. osscodeiq/detectors/shell/__init__.py +0 -0
  111. osscodeiq/detectors/shell/bash_detector.py +127 -0
  112. osscodeiq/detectors/shell/powershell_detector.py +118 -0
  113. osscodeiq/detectors/typescript/__init__.py +0 -0
  114. osscodeiq/detectors/typescript/express_routes.py +55 -0
  115. osscodeiq/detectors/typescript/fastify_routes.py +156 -0
  116. osscodeiq/detectors/typescript/graphql_resolvers.py +100 -0
  117. osscodeiq/detectors/typescript/kafka_js.py +164 -0
  118. osscodeiq/detectors/typescript/mongoose_orm.py +151 -0
  119. osscodeiq/detectors/typescript/nestjs_controllers.py +99 -0
  120. osscodeiq/detectors/typescript/nestjs_guards.py +138 -0
  121. osscodeiq/detectors/typescript/passport_jwt.py +133 -0
  122. osscodeiq/detectors/typescript/prisma_orm.py +96 -0
  123. osscodeiq/detectors/typescript/remix_routes.py +160 -0
  124. osscodeiq/detectors/typescript/sequelize_orm.py +136 -0
  125. osscodeiq/detectors/typescript/typeorm_entities.py +86 -0
  126. osscodeiq/detectors/typescript/typescript_structures.py +185 -0
  127. osscodeiq/detectors/utils.py +49 -0
  128. osscodeiq/discovery/__init__.py +11 -0
  129. osscodeiq/discovery/change_detector.py +97 -0
  130. osscodeiq/discovery/file_discovery.py +342 -0
  131. osscodeiq/flow/__init__.py +0 -0
  132. osscodeiq/flow/engine.py +78 -0
  133. osscodeiq/flow/models.py +72 -0
  134. osscodeiq/flow/renderer.py +127 -0
  135. osscodeiq/flow/templates/interactive.html +252 -0
  136. osscodeiq/flow/vendor/cytoscape-dagre.min.js +8 -0
  137. osscodeiq/flow/vendor/cytoscape.min.js +32 -0
  138. osscodeiq/flow/vendor/dagre.min.js +3809 -0
  139. osscodeiq/flow/views.py +357 -0
  140. osscodeiq/graph/__init__.py +0 -0
  141. osscodeiq/graph/backend.py +52 -0
  142. osscodeiq/graph/backends/__init__.py +23 -0
  143. osscodeiq/graph/backends/kuzu.py +576 -0
  144. osscodeiq/graph/backends/networkx.py +135 -0
  145. osscodeiq/graph/backends/sqlite_backend.py +406 -0
  146. osscodeiq/graph/builder.py +297 -0
  147. osscodeiq/graph/query.py +228 -0
  148. osscodeiq/graph/store.py +183 -0
  149. osscodeiq/graph/views.py +231 -0
  150. osscodeiq/models/__init__.py +17 -0
  151. osscodeiq/models/graph.py +116 -0
  152. osscodeiq/output/__init__.py +0 -0
  153. osscodeiq/output/dot.py +171 -0
  154. osscodeiq/output/mermaid.py +160 -0
  155. osscodeiq/output/safety.py +58 -0
  156. osscodeiq/output/serializers.py +42 -0
  157. osscodeiq/parsing/__init__.py +5 -0
  158. osscodeiq/parsing/languages/__init__.py +0 -0
  159. osscodeiq/parsing/languages/base.py +23 -0
  160. osscodeiq/parsing/languages/java.py +68 -0
  161. osscodeiq/parsing/languages/python.py +57 -0
  162. osscodeiq/parsing/languages/typescript.py +95 -0
  163. osscodeiq/parsing/parser_manager.py +125 -0
  164. osscodeiq/parsing/structured/__init__.py +0 -0
  165. osscodeiq/parsing/structured/gradle_parser.py +78 -0
  166. osscodeiq/parsing/structured/json_parser.py +24 -0
  167. osscodeiq/parsing/structured/properties_parser.py +56 -0
  168. osscodeiq/parsing/structured/sql_parser.py +54 -0
  169. osscodeiq/parsing/structured/xml_parser.py +148 -0
  170. osscodeiq/parsing/structured/yaml_parser.py +38 -0
  171. osscodeiq/server/__init__.py +7 -0
  172. osscodeiq/server/app.py +53 -0
  173. osscodeiq/server/mcp_server.py +174 -0
  174. osscodeiq/server/middleware.py +16 -0
  175. osscodeiq/server/routes.py +184 -0
  176. osscodeiq/server/service.py +445 -0
  177. osscodeiq/server/templates/welcome.html +56 -0
  178. osscodeiq-0.0.0.dist-info/METADATA +30 -0
  179. osscodeiq-0.0.0.dist-info/RECORD +183 -0
  180. osscodeiq-0.0.0.dist-info/WHEEL +5 -0
  181. osscodeiq-0.0.0.dist-info/entry_points.txt +2 -0
  182. osscodeiq-0.0.0.dist-info/licenses/LICENSE +21 -0
  183. osscodeiq-0.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,135 @@
1
+ """NetworkX-backed graph backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ import networkx as nx
9
+
10
+ from osscodeiq.models.graph import (
11
+ EdgeKind,
12
+ GraphEdge,
13
+ GraphNode,
14
+ NodeKind,
15
+ )
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class NetworkXBackend:
21
+ """In-memory graph backend using NetworkX MultiDiGraph."""
22
+
23
+ def __init__(self) -> None:
24
+ self._g: nx.MultiDiGraph = nx.MultiDiGraph()
25
+
26
+ @property
27
+ def node_count(self) -> int:
28
+ return self._g.number_of_nodes()
29
+
30
+ @property
31
+ def edge_count(self) -> int:
32
+ return self._g.number_of_edges()
33
+
34
+ def add_node(self, node: GraphNode) -> None:
35
+ if node.id in self._g:
36
+ logger.debug("Duplicate node ID %s, keeping first", node.id)
37
+ return
38
+ self._g.add_node(node.id, **node.model_dump())
39
+
40
+ def add_edge(self, edge: GraphEdge) -> None:
41
+ # Only add edge if both nodes exist — prevents NetworkX from
42
+ # auto-creating phantom nodes for dangling references.
43
+ if edge.source not in self._g or edge.target not in self._g:
44
+ logger.debug(
45
+ "Skipping edge %s -> %s: missing node(s)",
46
+ edge.source, edge.target,
47
+ )
48
+ return
49
+ self._g.add_edge(edge.source, edge.target, **edge.model_dump())
50
+
51
+ def clear(self) -> None:
52
+ self._g.clear()
53
+
54
+ def get_node(self, node_id: str) -> GraphNode | None:
55
+ if node_id not in self._g:
56
+ return None
57
+ return GraphNode(**self._g.nodes[node_id])
58
+
59
+ def has_node(self, node_id: str) -> bool:
60
+ return node_id in self._g
61
+
62
+ def get_edges_between(self, source: str, target: str) -> list[GraphEdge]:
63
+ if not self._g.has_edge(source, target):
64
+ return []
65
+ return [GraphEdge(**data) for _key, data in self._g[source][target].items()]
66
+
67
+ def all_nodes(self) -> list[GraphNode]:
68
+ return [
69
+ GraphNode(**data)
70
+ for _, data in self._g.nodes(data=True)
71
+ if "id" in data and "kind" in data
72
+ ]
73
+
74
+ def all_edges(self) -> list[GraphEdge]:
75
+ return [
76
+ GraphEdge(**data)
77
+ for _, _, data in self._g.edges(data=True)
78
+ if "source" in data and "target" in data
79
+ ]
80
+
81
+ def nodes_by_kind(self, kind: NodeKind) -> list[GraphNode]:
82
+ return [
83
+ GraphNode(**data)
84
+ for _, data in self._g.nodes(data=True)
85
+ if data.get("kind") == kind.value and "id" in data
86
+ ]
87
+
88
+ def edges_by_kind(self, kind: EdgeKind) -> list[GraphEdge]:
89
+ return [
90
+ GraphEdge(**data)
91
+ for _, _, data in self._g.edges(data=True)
92
+ if data.get("kind") == kind.value and "source" in data
93
+ ]
94
+
95
+ def neighbors(self, node_id: str, edge_kinds: set[EdgeKind] | None = None, direction: str = "both") -> list[str]:
96
+ result: set[str] = set()
97
+ if direction in ("out", "both"):
98
+ for _, target, data in self._g.out_edges(node_id, data=True):
99
+ if edge_kinds is None or EdgeKind(data.get("kind", "")) in edge_kinds:
100
+ result.add(target)
101
+ if direction in ("in", "both"):
102
+ for source, _, data in self._g.in_edges(node_id, data=True):
103
+ if edge_kinds is None or EdgeKind(data.get("kind", "")) in edge_kinds:
104
+ result.add(source)
105
+ return sorted(result)
106
+
107
+ def find_cycles(self, limit: int = 100) -> list[list[str]]:
108
+ cycles: list[list[str]] = []
109
+ for cycle in nx.simple_cycles(self._g):
110
+ cycles.append(cycle)
111
+ if len(cycles) >= limit:
112
+ break
113
+ return cycles
114
+
115
+ def shortest_path(self, source: str, target: str) -> list[str] | None:
116
+ try:
117
+ return nx.shortest_path(self._g, source, target)
118
+ except nx.NetworkXNoPath:
119
+ return None
120
+
121
+ def subgraph(self, node_ids: set[str]) -> NetworkXBackend:
122
+ new_backend = NetworkXBackend()
123
+ sub = self._g.subgraph(node_ids)
124
+ new_backend._g = nx.MultiDiGraph(sub)
125
+ return new_backend
126
+
127
+ def update_node_properties(self, node_id: str, properties: dict[str, Any]) -> None:
128
+ if node_id in self._g:
129
+ data = self._g.nodes[node_id]
130
+ props = data.get("properties", {})
131
+ props.update(properties)
132
+ data["properties"] = props
133
+
134
+ def close(self) -> None:
135
+ pass # In-memory, nothing to close
@@ -0,0 +1,406 @@
1
+ """SQLite-backed graph backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import sqlite3
8
+ from typing import Any
9
+
10
+ import networkx as nx
11
+
12
+ from osscodeiq.models.graph import (
13
+ EdgeKind,
14
+ GraphEdge,
15
+ GraphNode,
16
+ NodeKind,
17
+ )
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ _SCHEMA_SQL = """
22
+ CREATE TABLE IF NOT EXISTS nodes (
23
+ id TEXT PRIMARY KEY,
24
+ kind TEXT NOT NULL,
25
+ label TEXT NOT NULL,
26
+ data JSON NOT NULL
27
+ );
28
+ CREATE TABLE IF NOT EXISTS edges (
29
+ rowid INTEGER PRIMARY KEY AUTOINCREMENT,
30
+ source TEXT NOT NULL,
31
+ target TEXT NOT NULL,
32
+ kind TEXT NOT NULL,
33
+ data JSON NOT NULL
34
+ );
35
+ CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source);
36
+ CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target);
37
+ CREATE INDEX IF NOT EXISTS idx_nodes_kind ON nodes(kind);
38
+ CREATE INDEX IF NOT EXISTS idx_edges_kind ON edges(kind);
39
+ """
40
+
41
+
42
+ def _serialize_node(node: GraphNode) -> tuple[str, str, str, str]:
43
+ """Serialize a GraphNode to a tuple suitable for INSERT."""
44
+ return (
45
+ node.id,
46
+ node.kind.value,
47
+ node.label,
48
+ json.dumps(node.model_dump(mode="json")),
49
+ )
50
+
51
+
52
+ def _deserialize_node(data_json: str) -> GraphNode:
53
+ """Reconstruct a GraphNode from its JSON representation."""
54
+ return GraphNode(**json.loads(data_json))
55
+
56
+
57
+ def _serialize_edge(edge: GraphEdge) -> tuple[str, str, str, str]:
58
+ """Serialize a GraphEdge to a tuple suitable for INSERT."""
59
+ return (
60
+ edge.source,
61
+ edge.target,
62
+ edge.kind.value,
63
+ json.dumps(edge.model_dump(mode="json")),
64
+ )
65
+
66
+
67
+ def _deserialize_edge(data_json: str) -> GraphEdge:
68
+ """Reconstruct a GraphEdge from its JSON representation."""
69
+ return GraphEdge(**json.loads(data_json))
70
+
71
+
72
+ class SqliteGraphBackend:
73
+ """Persistent graph backend using SQLite.
74
+
75
+ Stores nodes and edges in a SQLite database with JSON-serialized
76
+ Pydantic model data. Uses WAL journal mode for good write concurrency
77
+ and indexes on ``kind``, ``source``, and ``target`` columns.
78
+ """
79
+
80
+ def __init__(self, db_path: str) -> None:
81
+ self._db_path = db_path
82
+ try:
83
+ self._conn = sqlite3.connect(db_path)
84
+ self._conn.row_factory = sqlite3.Row
85
+ self._conn.execute("PRAGMA journal_mode=WAL")
86
+ self._conn.execute("PRAGMA synchronous=NORMAL")
87
+ self._conn.executescript(_SCHEMA_SQL)
88
+ self._conn.commit()
89
+ except sqlite3.Error:
90
+ logger.exception("Failed to initialize SQLite backend at %s", db_path)
91
+ raise
92
+
93
+ # ------------------------------------------------------------------
94
+ # Properties
95
+ # ------------------------------------------------------------------
96
+
97
+ @property
98
+ def node_count(self) -> int:
99
+ try:
100
+ row = self._conn.execute("SELECT COUNT(*) FROM nodes").fetchone()
101
+ return row[0]
102
+ except sqlite3.Error:
103
+ logger.exception("Failed to count nodes")
104
+ return 0
105
+
106
+ @property
107
+ def edge_count(self) -> int:
108
+ try:
109
+ row = self._conn.execute("SELECT COUNT(*) FROM edges").fetchone()
110
+ return row[0]
111
+ except sqlite3.Error:
112
+ logger.exception("Failed to count edges")
113
+ return 0
114
+
115
+ # ------------------------------------------------------------------
116
+ # Mutations
117
+ # ------------------------------------------------------------------
118
+
119
+ def add_node(self, node: GraphNode) -> None:
120
+ try:
121
+ self._conn.execute(
122
+ "INSERT OR IGNORE INTO nodes VALUES (?, ?, ?, ?)",
123
+ _serialize_node(node),
124
+ )
125
+ self._conn.commit()
126
+ except sqlite3.Error:
127
+ logger.exception("Failed to add node %s", node.id)
128
+
129
+ def add_edge(self, edge: GraphEdge) -> None:
130
+ # Only add edge if both nodes exist — consistent with KuzuDB/Neo4j behavior
131
+ if not self.has_node(edge.source) or not self.has_node(edge.target):
132
+ return
133
+ try:
134
+ self._conn.execute(
135
+ "INSERT INTO edges (source, target, kind, data) VALUES (?, ?, ?, ?)",
136
+ _serialize_edge(edge),
137
+ )
138
+ self._conn.commit()
139
+ except sqlite3.Error:
140
+ logger.exception("Failed to add edge %s -> %s", edge.source, edge.target)
141
+
142
+ def clear(self) -> None:
143
+ try:
144
+ self._conn.execute("DELETE FROM edges")
145
+ self._conn.execute("DELETE FROM nodes")
146
+ self._conn.commit()
147
+ except sqlite3.Error:
148
+ logger.exception("Failed to clear graph")
149
+
150
+ def update_node_properties(self, node_id: str, properties: dict[str, Any]) -> None:
151
+ try:
152
+ row = self._conn.execute(
153
+ "SELECT data FROM nodes WHERE id = ?", (node_id,)
154
+ ).fetchone()
155
+ if row is None:
156
+ return
157
+ node_data = json.loads(row[0])
158
+ props = node_data.get("properties", {})
159
+ props.update(properties)
160
+ node_data["properties"] = props
161
+ # Also update the label column in case callers rely on it,
162
+ # but the primary payload is the JSON blob.
163
+ node = GraphNode(**node_data)
164
+ self._conn.execute(
165
+ "UPDATE nodes SET data = ? WHERE id = ?",
166
+ (json.dumps(node.model_dump(mode="json")), node_id),
167
+ )
168
+ self._conn.commit()
169
+ except sqlite3.Error:
170
+ logger.exception("Failed to update properties for node %s", node_id)
171
+
172
+ # ------------------------------------------------------------------
173
+ # Queries — single item
174
+ # ------------------------------------------------------------------
175
+
176
+ def get_node(self, node_id: str) -> GraphNode | None:
177
+ try:
178
+ row = self._conn.execute(
179
+ "SELECT data FROM nodes WHERE id = ?", (node_id,)
180
+ ).fetchone()
181
+ if row is None:
182
+ return None
183
+ return _deserialize_node(row[0])
184
+ except sqlite3.Error:
185
+ logger.exception("Failed to get node %s", node_id)
186
+ return None
187
+
188
+ def has_node(self, node_id: str) -> bool:
189
+ try:
190
+ row = self._conn.execute(
191
+ "SELECT 1 FROM nodes WHERE id = ? LIMIT 1", (node_id,)
192
+ ).fetchone()
193
+ return row is not None
194
+ except sqlite3.Error:
195
+ logger.exception("Failed to check node %s", node_id)
196
+ return False
197
+
198
+ def get_edges_between(self, source: str, target: str) -> list[GraphEdge]:
199
+ try:
200
+ rows = self._conn.execute(
201
+ "SELECT data FROM edges WHERE source = ? AND target = ?",
202
+ (source, target),
203
+ ).fetchall()
204
+ return [_deserialize_edge(r[0]) for r in rows]
205
+ except sqlite3.Error:
206
+ logger.exception("Failed to get edges between %s and %s", source, target)
207
+ return []
208
+
209
+ # ------------------------------------------------------------------
210
+ # Queries — bulk
211
+ # ------------------------------------------------------------------
212
+
213
+ def all_nodes(self) -> list[GraphNode]:
214
+ try:
215
+ rows = self._conn.execute("SELECT data FROM nodes").fetchall()
216
+ return [_deserialize_node(r[0]) for r in rows]
217
+ except sqlite3.Error:
218
+ logger.exception("Failed to fetch all nodes")
219
+ return []
220
+
221
+ def all_edges(self) -> list[GraphEdge]:
222
+ try:
223
+ rows = self._conn.execute("SELECT data FROM edges").fetchall()
224
+ return [_deserialize_edge(r[0]) for r in rows]
225
+ except sqlite3.Error:
226
+ logger.exception("Failed to fetch all edges")
227
+ return []
228
+
229
+ def nodes_by_kind(self, kind: NodeKind) -> list[GraphNode]:
230
+ try:
231
+ rows = self._conn.execute(
232
+ "SELECT data FROM nodes WHERE kind = ?", (kind.value,)
233
+ ).fetchall()
234
+ return [_deserialize_node(r[0]) for r in rows]
235
+ except sqlite3.Error:
236
+ logger.exception("Failed to fetch nodes of kind %s", kind)
237
+ return []
238
+
239
+ def edges_by_kind(self, kind: EdgeKind) -> list[GraphEdge]:
240
+ try:
241
+ rows = self._conn.execute(
242
+ "SELECT data FROM edges WHERE kind = ?", (kind.value,)
243
+ ).fetchall()
244
+ return [_deserialize_edge(r[0]) for r in rows]
245
+ except sqlite3.Error:
246
+ logger.exception("Failed to fetch edges of kind %s", kind)
247
+ return []
248
+
249
+ # ------------------------------------------------------------------
250
+ # Graph traversal
251
+ # ------------------------------------------------------------------
252
+
253
+ def neighbors(
254
+ self,
255
+ node_id: str,
256
+ edge_kinds: set[EdgeKind] | None = None,
257
+ direction: str = "both",
258
+ ) -> list[str]:
259
+ result: set[str] = set()
260
+ try:
261
+ if direction in ("out", "both"):
262
+ if edge_kinds is not None:
263
+ placeholders = ",".join("?" for _ in edge_kinds)
264
+ rows = self._conn.execute(
265
+ f"SELECT target FROM edges WHERE source = ? AND kind IN ({placeholders})",
266
+ (node_id, *(k.value for k in edge_kinds)),
267
+ ).fetchall()
268
+ else:
269
+ rows = self._conn.execute(
270
+ "SELECT target FROM edges WHERE source = ?", (node_id,)
271
+ ).fetchall()
272
+ result.update(r[0] for r in rows)
273
+
274
+ if direction in ("in", "both"):
275
+ if edge_kinds is not None:
276
+ placeholders = ",".join("?" for _ in edge_kinds)
277
+ rows = self._conn.execute(
278
+ f"SELECT source FROM edges WHERE target = ? AND kind IN ({placeholders})",
279
+ (node_id, *(k.value for k in edge_kinds)),
280
+ ).fetchall()
281
+ else:
282
+ rows = self._conn.execute(
283
+ "SELECT source FROM edges WHERE target = ?", (node_id,)
284
+ ).fetchall()
285
+ result.update(r[0] for r in rows)
286
+ except sqlite3.Error:
287
+ logger.exception("Failed to find neighbors of %s", node_id)
288
+ return sorted(result)
289
+
290
+ # ------------------------------------------------------------------
291
+ # Advanced graph algorithms
292
+ # ------------------------------------------------------------------
293
+
294
+ def find_cycles(self, limit: int = 100) -> list[list[str]]:
295
+ """Detect cycles.
296
+
297
+ Attempts a recursive-CTE approach first for small/medium graphs.
298
+ Falls back to NetworkX ``simple_cycles`` for robustness.
299
+ """
300
+ try:
301
+ return self._find_cycles_networkx(limit)
302
+ except Exception:
303
+ logger.exception("Cycle detection failed")
304
+ return []
305
+
306
+ def _find_cycles_networkx(self, limit: int) -> list[list[str]]:
307
+ """Load the graph into NetworkX and use ``simple_cycles``."""
308
+ g = self._to_networkx()
309
+ cycles: list[list[str]] = []
310
+ for cycle in nx.simple_cycles(g):
311
+ cycles.append(cycle)
312
+ if len(cycles) >= limit:
313
+ break
314
+ return cycles
315
+
316
+ def shortest_path(self, source: str, target: str) -> list[str] | None:
317
+ """Find the shortest path between two nodes.
318
+
319
+ Uses a BFS via recursive CTE for simple cases, falling back to
320
+ NetworkX for correctness.
321
+ """
322
+ try:
323
+ return self._shortest_path_networkx(source, target)
324
+ except nx.NetworkXNoPath:
325
+ return None
326
+ except Exception:
327
+ logger.exception("Shortest-path computation failed")
328
+ return None
329
+
330
+ def _shortest_path_networkx(self, source: str, target: str) -> list[str] | None:
331
+ g = self._to_networkx()
332
+ try:
333
+ return nx.shortest_path(g, source, target)
334
+ except nx.NetworkXNoPath:
335
+ return None
336
+ except nx.NodeNotFound:
337
+ return None
338
+
339
+ # ------------------------------------------------------------------
340
+ # Subgraph extraction
341
+ # ------------------------------------------------------------------
342
+
343
+ def subgraph(self, node_ids: set[str]) -> SqliteGraphBackend:
344
+ """Return a new in-memory SqliteGraphBackend containing only the
345
+ specified nodes and the edges between them."""
346
+ sub = SqliteGraphBackend(":memory:")
347
+ try:
348
+ if not node_ids:
349
+ return sub
350
+ placeholders = ",".join("?" for _ in node_ids)
351
+ ids = tuple(node_ids)
352
+
353
+ node_rows = self._conn.execute(
354
+ f"SELECT id, kind, label, data FROM nodes WHERE id IN ({placeholders})",
355
+ ids,
356
+ ).fetchall()
357
+ if node_rows:
358
+ sub._conn.executemany(
359
+ "INSERT OR IGNORE INTO nodes VALUES (?, ?, ?, ?)",
360
+ [(r[0], r[1], r[2], r[3]) for r in node_rows],
361
+ )
362
+
363
+ edge_rows = self._conn.execute(
364
+ f"SELECT source, target, kind, data FROM edges "
365
+ f"WHERE source IN ({placeholders}) AND target IN ({placeholders})",
366
+ ids + ids,
367
+ ).fetchall()
368
+ if edge_rows:
369
+ sub._conn.executemany(
370
+ "INSERT INTO edges (source, target, kind, data) VALUES (?, ?, ?, ?)",
371
+ [(r[0], r[1], r[2], r[3]) for r in edge_rows],
372
+ )
373
+
374
+ sub._conn.commit()
375
+ except sqlite3.Error:
376
+ logger.exception("Failed to extract subgraph")
377
+ return sub
378
+
379
+ # ------------------------------------------------------------------
380
+ # Lifecycle
381
+ # ------------------------------------------------------------------
382
+
383
+ def close(self) -> None:
384
+ try:
385
+ self._conn.commit()
386
+ self._conn.close()
387
+ except sqlite3.Error:
388
+ logger.exception("Error closing SQLite connection")
389
+
390
+ # ------------------------------------------------------------------
391
+ # Internal helpers
392
+ # ------------------------------------------------------------------
393
+
394
+ def _to_networkx(self) -> nx.DiGraph:
395
+ """Load the full graph into a NetworkX DiGraph for algorithm use."""
396
+ g = nx.DiGraph()
397
+ try:
398
+ for row in self._conn.execute("SELECT id FROM nodes").fetchall():
399
+ g.add_node(row[0])
400
+ for row in self._conn.execute(
401
+ "SELECT source, target FROM edges"
402
+ ).fetchall():
403
+ g.add_edge(row[0], row[1])
404
+ except sqlite3.Error:
405
+ logger.exception("Failed to load graph into NetworkX")
406
+ return g