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,297 @@
1
+ """Graph builder that aggregates detector results and runs cross-file linkers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from dataclasses import dataclass, field
7
+ from typing import Protocol, runtime_checkable
8
+
9
+ from osscodeiq.graph.backend import GraphBackend
10
+ from osscodeiq.graph.store import GraphStore
11
+ from osscodeiq.models.graph import GraphEdge, GraphNode, EdgeKind, NodeKind
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ @dataclass
17
+ class LinkResult:
18
+ """Result returned by a Linker: new nodes and edges to add to the graph."""
19
+
20
+ nodes: list[GraphNode] = field(default_factory=list)
21
+ edges: list[GraphEdge] = field(default_factory=list)
22
+
23
+
24
+ @runtime_checkable
25
+ class Linker(Protocol):
26
+ """Cross-file relationship inferencer."""
27
+
28
+ def link(self, store: GraphStore) -> LinkResult:
29
+ ...
30
+
31
+
32
+ class TopicLinker:
33
+ """Links Kafka/RabbitMQ producers to consumers via shared topic names.
34
+
35
+ Scans for TOPIC/QUEUE nodes and matches PRODUCES edges with CONSUMES
36
+ edges on the same topic label to create direct producer-to-consumer edges.
37
+ """
38
+
39
+ def link(self, store: GraphStore) -> LinkResult:
40
+ edges: list[GraphEdge] = []
41
+
42
+ # Collect topic/queue nodes by label for matching
43
+ topic_nodes = store.nodes_by_kind(NodeKind.TOPIC) + store.nodes_by_kind(
44
+ NodeKind.QUEUE
45
+ )
46
+ topic_ids_by_label: dict[str, list[str]] = {}
47
+ for node in topic_nodes:
48
+ topic_ids_by_label.setdefault(node.label, []).append(node.id)
49
+
50
+ # For each topic label, find producers and consumers
51
+ produces_edges = store.edges_by_kind(EdgeKind.PRODUCES)
52
+ consumes_edges = store.edges_by_kind(EdgeKind.CONSUMES)
53
+
54
+ # Map topic_id -> list of producer node ids
55
+ producers_by_topic: dict[str, list[str]] = {}
56
+ for edge in produces_edges:
57
+ producers_by_topic.setdefault(edge.target, []).append(edge.source)
58
+
59
+ # Map topic_id -> list of consumer node ids
60
+ consumers_by_topic: dict[str, list[str]] = {}
61
+ for edge in consumes_edges:
62
+ consumers_by_topic.setdefault(edge.target, []).append(edge.source)
63
+
64
+ # Create CALLS edges from producers to consumers on the same topic
65
+ for label, topic_ids in topic_ids_by_label.items():
66
+ producers: set[str] = set()
67
+ consumers: set[str] = set()
68
+ for tid in topic_ids:
69
+ producers.update(producers_by_topic.get(tid, []))
70
+ consumers.update(consumers_by_topic.get(tid, []))
71
+
72
+ for prod in sorted(producers):
73
+ for cons in sorted(consumers):
74
+ if prod != cons:
75
+ edges.append(
76
+ GraphEdge(
77
+ source=prod,
78
+ target=cons,
79
+ kind=EdgeKind.CALLS,
80
+ label=f"via topic '{label}'",
81
+ properties={"inferred": True, "topic": label},
82
+ )
83
+ )
84
+
85
+ if edges:
86
+ logger.debug("TopicLinker created %d edges", len(edges))
87
+ return LinkResult(edges=edges)
88
+
89
+
90
+ class EntityLinker:
91
+ """Links JPA entities to repositories that query them.
92
+
93
+ Scans for ENTITY and REPOSITORY nodes and creates QUERIES edges
94
+ from repositories to the entities they manage, matching by naming
95
+ conventions and existing MAPS_TO relationships.
96
+ """
97
+
98
+ def link(self, store: GraphStore) -> LinkResult:
99
+ edges: list[GraphEdge] = []
100
+
101
+ entities = store.nodes_by_kind(NodeKind.ENTITY)
102
+ repositories = store.nodes_by_kind(NodeKind.REPOSITORY)
103
+
104
+ if not entities or not repositories:
105
+ return LinkResult(edges=edges)
106
+
107
+ # Build entity lookup by simple name (last part of FQN or label)
108
+ entity_by_name: dict[str, GraphNode] = {}
109
+ for entity in entities:
110
+ # Use label as the simple class name
111
+ entity_by_name[entity.label.lower()] = entity
112
+ if entity.fqn:
113
+ simple = entity.fqn.rsplit(".", 1)[-1]
114
+ entity_by_name[simple.lower()] = entity
115
+
116
+ # Check existing QUERIES edges to avoid duplicates
117
+ existing_queries = {
118
+ (e.source, e.target) for e in store.edges_by_kind(EdgeKind.QUERIES)
119
+ }
120
+
121
+ for repo in repositories:
122
+ # Try to match repository name to entity name
123
+ # Convention: FooRepository -> Foo entity
124
+ repo_name = repo.label
125
+ for suffix in ("Repository", "Repo", "Dao", "DAO"):
126
+ if repo_name.endswith(suffix):
127
+ entity_name = repo_name[: -len(suffix)].lower()
128
+ if entity_name in entity_by_name:
129
+ entity = entity_by_name[entity_name]
130
+ if (repo.id, entity.id) not in existing_queries:
131
+ edges.append(
132
+ GraphEdge(
133
+ source=repo.id,
134
+ target=entity.id,
135
+ kind=EdgeKind.QUERIES,
136
+ label=f"{repo.label} queries {entity.label}",
137
+ properties={"inferred": True},
138
+ )
139
+ )
140
+ break
141
+
142
+ if edges:
143
+ logger.debug("EntityLinker created %d edges", len(edges))
144
+ return LinkResult(edges=edges)
145
+
146
+
147
+ class ModuleContainmentLinker:
148
+ """Links classes to their owning modules via CONTAINS edges.
149
+
150
+ Groups nodes by their ``module`` field and creates MODULE nodes
151
+ with CONTAINS edges pointing to each member node.
152
+ """
153
+
154
+ def link(self, store: GraphStore) -> LinkResult:
155
+ edges: list[GraphEdge] = []
156
+ new_nodes: list[GraphNode] = []
157
+
158
+ # Collect existing module nodes
159
+ existing_modules = {n.id for n in store.nodes_by_kind(NodeKind.MODULE)}
160
+
161
+ # Group nodes by module name
162
+ nodes_by_module: dict[str, list[GraphNode]] = {}
163
+ for node in store.all_nodes():
164
+ if node.module and node.kind != NodeKind.MODULE:
165
+ nodes_by_module.setdefault(node.module, []).append(node)
166
+
167
+ # Check existing CONTAINS edges to avoid duplicates
168
+ existing_contains = {
169
+ (e.source, e.target) for e in store.edges_by_kind(EdgeKind.CONTAINS)
170
+ }
171
+
172
+ for module_name, members in nodes_by_module.items():
173
+ module_id = f"module:{module_name}"
174
+
175
+ # Create module node if it doesn't exist
176
+ if module_id not in existing_modules:
177
+ new_nodes.append(
178
+ GraphNode(
179
+ id=module_id,
180
+ kind=NodeKind.MODULE,
181
+ label=module_name,
182
+ fqn=module_name,
183
+ )
184
+ )
185
+
186
+ for member in members:
187
+ if (module_id, member.id) not in existing_contains:
188
+ edges.append(
189
+ GraphEdge(
190
+ source=module_id,
191
+ target=member.id,
192
+ kind=EdgeKind.CONTAINS,
193
+ label=f"{module_name} contains {member.label}",
194
+ properties={"inferred": True},
195
+ )
196
+ )
197
+
198
+ if edges:
199
+ logger.debug("ModuleContainmentLinker created %d edges", len(edges))
200
+ return LinkResult(nodes=new_nodes, edges=edges)
201
+
202
+
203
+ class GraphBuilder:
204
+ """Aggregates detector results and runs cross-file linkers to build a graph.
205
+
206
+ Edges are buffered and flushed after all nodes are added to ensure
207
+ consistent behavior across all storage backends. Some backends
208
+ (NetworkX, SQLite, KuzuDB) reject edges referencing non-existent
209
+ nodes, so all nodes must be present before edges are inserted.
210
+ """
211
+
212
+ def __init__(self, backend: GraphBackend | None = None) -> None:
213
+ self._store = GraphStore(backend=backend)
214
+ self._pending_nodes: list[GraphNode] = []
215
+ self._pending_edges: list[GraphEdge] = []
216
+ self._linkers: list[Linker] = [
217
+ TopicLinker(),
218
+ EntityLinker(),
219
+ ModuleContainmentLinker(),
220
+ ]
221
+
222
+ def add_nodes(self, nodes: list[GraphNode]) -> None:
223
+ """Buffer nodes for deferred insertion."""
224
+ self._pending_nodes.extend(nodes)
225
+
226
+ def add_edges(self, edges: list[GraphEdge]) -> None:
227
+ """Buffer edges for deferred insertion."""
228
+ self._pending_edges.extend(edges)
229
+
230
+ def flush(self) -> None:
231
+ """Insert all buffered nodes then edges into the store.
232
+
233
+ Nodes first, then edges — ensures backends that validate node
234
+ existence won't reject valid cross-file edges. Uses bulk insert
235
+ when the backend supports it (e.g. KuzuDB CSV COPY FROM).
236
+ """
237
+ backend = self._store._backend
238
+
239
+ # Flush nodes
240
+ if self._pending_nodes:
241
+ if hasattr(backend, "bulk_add_nodes"):
242
+ backend.bulk_add_nodes(self._pending_nodes)
243
+ else:
244
+ for node in self._pending_nodes:
245
+ self._store.add_node(node)
246
+ self._pending_nodes.clear()
247
+
248
+ # Flush edges
249
+ if self._pending_edges:
250
+ if hasattr(backend, "bulk_add_edges"):
251
+ backend.bulk_add_edges(self._pending_edges)
252
+ else:
253
+ for edge in self._pending_edges:
254
+ self._store.add_edge(edge)
255
+ self._pending_edges.clear()
256
+
257
+ def merge_detector_result(self, result: object) -> None:
258
+ """Merge a DetectorResult into the graph.
259
+
260
+ Accepts any object with ``nodes`` and ``edges`` attributes
261
+ (duck-typed to avoid circular imports with DetectorResult).
262
+ """
263
+ nodes: list[GraphNode] = getattr(result, "nodes", [])
264
+ edges: list[GraphEdge] = getattr(result, "edges", [])
265
+ self.add_nodes(nodes)
266
+ self.add_edges(edges) # buffered, not inserted yet
267
+
268
+ def run_linkers(self) -> None:
269
+ """Flush pending nodes and edges, then run all registered linkers."""
270
+ # Flush all buffered detector data so linkers see the full graph
271
+ self.flush()
272
+
273
+ for linker in self._linkers:
274
+ try:
275
+ result = linker.link(self._store)
276
+
277
+ if result.nodes:
278
+ self.add_nodes(result.nodes)
279
+
280
+ if result.edges:
281
+ self._pending_edges.extend(result.edges)
282
+ except Exception:
283
+ logger.warning(
284
+ "Linker %s failed",
285
+ type(linker).__name__,
286
+ exc_info=True,
287
+ )
288
+
289
+ # Flush linker-produced nodes and edges
290
+ self.flush()
291
+
292
+ def build(self) -> GraphStore:
293
+ """Return the assembled graph store."""
294
+ # Safety: flush any remaining edges
295
+ if self._pending_edges:
296
+ self.flush()
297
+ return self._store
@@ -0,0 +1,228 @@
1
+ """Composable query builder for the OSSCodeIQ graph."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass, field
8
+
9
+ from osscodeiq.graph.store import GraphStore
10
+ from osscodeiq.models.graph import (
11
+ EdgeKind,
12
+ GraphEdge,
13
+ GraphNode,
14
+ NodeKind,
15
+ )
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class _FocusSpec:
20
+ """Describes a neighbourhood-focus operation."""
21
+
22
+ node_id: str
23
+ hops: int
24
+ direction: str
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class GraphQuery:
29
+ """Immutable, composable query builder over a :class:`GraphStore`.
30
+
31
+ Every filter method returns a **new** ``GraphQuery``; the original
32
+ is never mutated. Call :meth:`execute` to materialise the result
33
+ as a new :class:`GraphStore`.
34
+ """
35
+
36
+ _store: GraphStore
37
+ _module_filters: tuple[list[str], ...] = ()
38
+ _node_kind_filters: tuple[list[NodeKind], ...] = ()
39
+ _edge_kind_filters: tuple[list[EdgeKind], ...] = ()
40
+ _path_filters: tuple[str, ...] = ()
41
+ _annotation_filters: tuple[str, ...] = ()
42
+ _focus_specs: tuple[_FocusSpec, ...] = ()
43
+ _node_predicates: tuple[Callable[[GraphNode], bool], ...] = ()
44
+ _edge_predicates: tuple[Callable[[GraphEdge], bool], ...] = ()
45
+
46
+ # -- convenience constructor ------------------------------------
47
+
48
+ def __init__(self, store: GraphStore) -> None: # noqa: D107
49
+ object.__setattr__(self, "_store", store)
50
+ object.__setattr__(self, "_module_filters", ())
51
+ object.__setattr__(self, "_node_kind_filters", ())
52
+ object.__setattr__(self, "_edge_kind_filters", ())
53
+ object.__setattr__(self, "_path_filters", ())
54
+ object.__setattr__(self, "_annotation_filters", ())
55
+ object.__setattr__(self, "_focus_specs", ())
56
+ object.__setattr__(self, "_node_predicates", ())
57
+ object.__setattr__(self, "_edge_predicates", ())
58
+
59
+ def _copy(self, **overrides: object) -> GraphQuery:
60
+ """Return a shallow copy with selected field overrides."""
61
+ new = object.__new__(GraphQuery)
62
+ for attr in (
63
+ "_store",
64
+ "_module_filters",
65
+ "_node_kind_filters",
66
+ "_edge_kind_filters",
67
+ "_path_filters",
68
+ "_annotation_filters",
69
+ "_focus_specs",
70
+ "_node_predicates",
71
+ "_edge_predicates",
72
+ ):
73
+ object.__setattr__(new, attr, overrides.get(attr, getattr(self, attr)))
74
+ return new
75
+
76
+ # -- filter methods (each returns a new GraphQuery) -------------
77
+
78
+ def filter_modules(self, modules: list[str]) -> GraphQuery:
79
+ """Keep only nodes belonging to one of the listed modules."""
80
+ return self._copy(_module_filters=self._module_filters + (modules,))
81
+
82
+ def filter_node_kinds(self, kinds: list[NodeKind]) -> GraphQuery:
83
+ """Keep only nodes whose kind is in *kinds*."""
84
+ return self._copy(_node_kind_filters=self._node_kind_filters + (kinds,))
85
+
86
+ def filter_edge_kinds(self, kinds: list[EdgeKind]) -> GraphQuery:
87
+ """Keep only edges whose kind is in *kinds*."""
88
+ return self._copy(_edge_kind_filters=self._edge_kind_filters + (kinds,))
89
+
90
+ def filter_path(self, glob_pattern: str) -> GraphQuery:
91
+ """Keep only nodes whose source file path matches *glob_pattern*."""
92
+ return self._copy(_path_filters=self._path_filters + (glob_pattern,))
93
+
94
+ def filter_annotation(self, annotation: str) -> GraphQuery:
95
+ """Keep only nodes that carry *annotation*."""
96
+ return self._copy(_annotation_filters=self._annotation_filters + (annotation,))
97
+
98
+ def focus(self, node_id: str, hops: int = 2, direction: str = "both") -> GraphQuery:
99
+ """Restrict to the *hops*-neighbourhood around *node_id*."""
100
+ spec = _FocusSpec(node_id=node_id, hops=hops, direction=direction)
101
+ return self._copy(_focus_specs=self._focus_specs + (spec,))
102
+
103
+ # -- semantic queries -------------------------------------------
104
+
105
+ def consumers_of(self, target_id: str) -> GraphQuery:
106
+ """Find nodes that consume from *target_id*."""
107
+ def _pred(edge: GraphEdge) -> bool:
108
+ return edge.target == target_id and edge.kind in {
109
+ EdgeKind.CONSUMES,
110
+ EdgeKind.LISTENS,
111
+ }
112
+ return self._copy(_edge_predicates=self._edge_predicates + (_pred,))
113
+
114
+ def producers_of(self, target_id: str) -> GraphQuery:
115
+ """Find nodes that produce to *target_id*."""
116
+ def _pred(edge: GraphEdge) -> bool:
117
+ return edge.target == target_id and edge.kind in {
118
+ EdgeKind.PRODUCES,
119
+ EdgeKind.PUBLISHES,
120
+ }
121
+ return self._copy(_edge_predicates=self._edge_predicates + (_pred,))
122
+
123
+ def callers_of(self, target_id: str) -> GraphQuery:
124
+ """Find nodes that call *target_id*."""
125
+ def _pred(edge: GraphEdge) -> bool:
126
+ return edge.target == target_id and edge.kind == EdgeKind.CALLS
127
+ return self._copy(_edge_predicates=self._edge_predicates + (_pred,))
128
+
129
+ def dependencies_of(self, module_id: str) -> GraphQuery:
130
+ """Find modules that *module_id* depends on."""
131
+ def _pred(edge: GraphEdge) -> bool:
132
+ return edge.source == module_id and edge.kind in {
133
+ EdgeKind.DEPENDS_ON,
134
+ EdgeKind.IMPORTS,
135
+ EdgeKind.CALLS,
136
+ EdgeKind.INJECTS,
137
+ }
138
+ return self._copy(_edge_predicates=self._edge_predicates + (_pred,))
139
+
140
+ def dependents_of(self, module_id: str) -> GraphQuery:
141
+ """Find modules that depend on *module_id*."""
142
+ def _pred(edge: GraphEdge) -> bool:
143
+ return edge.target == module_id and edge.kind in {
144
+ EdgeKind.DEPENDS_ON,
145
+ EdgeKind.IMPORTS,
146
+ EdgeKind.CALLS,
147
+ EdgeKind.INJECTS,
148
+ }
149
+ return self._copy(_edge_predicates=self._edge_predicates + (_pred,))
150
+
151
+ # -- execution --------------------------------------------------
152
+
153
+ def execute(self) -> GraphStore:
154
+ """Apply all accumulated filters and return a new :class:`GraphStore`."""
155
+ store = self._store
156
+
157
+ # 1. Apply focus specs first (they restrict the working set)
158
+ if self._focus_specs:
159
+ focused_ids: set[str] = set()
160
+ for spec in self._focus_specs:
161
+ ego_store = store.ego(spec.node_id, spec.hops)
162
+ focused_ids.update(n.id for n in ego_store.all_nodes())
163
+ store = store.subgraph(focused_ids)
164
+
165
+ # 2. Build composite node filter
166
+ def _node_ok(node: GraphNode) -> bool:
167
+ # Module filters (OR within a single call, AND across calls)
168
+ for mod_list in self._module_filters:
169
+ if node.module not in mod_list and node.id not in mod_list:
170
+ return False
171
+
172
+ # Node-kind filters
173
+ for kind_list in self._node_kind_filters:
174
+ if node.kind not in kind_list:
175
+ return False
176
+
177
+ # Path filters (any pattern match)
178
+ if self._path_filters:
179
+ loc = node.location
180
+ if loc is None:
181
+ return False
182
+ if not any(fnmatch.fnmatch(loc.file_path, p) for p in self._path_filters):
183
+ return False
184
+
185
+ # Annotation filters (all must be present)
186
+ for ann in self._annotation_filters:
187
+ if ann not in node.annotations:
188
+ return False
189
+
190
+ # Custom node predicates
191
+ for pred in self._node_predicates:
192
+ if not pred(node):
193
+ return False
194
+
195
+ return True
196
+
197
+ # 3. Build composite edge filter
198
+ def _edge_ok(edge: GraphEdge) -> bool:
199
+ for kind_list in self._edge_kind_filters:
200
+ if edge.kind not in kind_list:
201
+ return False
202
+ # Edge predicates are OR-combined: if any are set, at least
203
+ # one must match. This allows semantic queries to union.
204
+ if self._edge_predicates:
205
+ if not any(pred(edge) for pred in self._edge_predicates):
206
+ return False
207
+ return True
208
+
209
+ # When we have edge predicates but no node filters, we should
210
+ # also include nodes referenced by matching edges.
211
+ if self._edge_predicates and not (
212
+ self._module_filters
213
+ or self._node_kind_filters
214
+ or self._path_filters
215
+ or self._annotation_filters
216
+ or self._node_predicates
217
+ ):
218
+ # Collect node IDs from matching edges
219
+ keep_ids: set[str] = set()
220
+ for edge in store.all_edges():
221
+ if _edge_ok(edge):
222
+ keep_ids.add(edge.source)
223
+ keep_ids.add(edge.target)
224
+ store = store.subgraph(keep_ids)
225
+ # Re-filter edges only
226
+ return store.filter(edge_filter=_edge_ok)
227
+
228
+ return store.filter(node_filter=_node_ok, edge_filter=_edge_ok)