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.
- osscodeiq/__init__.py +0 -0
- osscodeiq/analyzer.py +467 -0
- osscodeiq/cache/__init__.py +0 -0
- osscodeiq/cache/hasher.py +23 -0
- osscodeiq/cache/store.py +300 -0
- osscodeiq/classifiers/__init__.py +0 -0
- osscodeiq/classifiers/layer_classifier.py +69 -0
- osscodeiq/cli.py +721 -0
- osscodeiq/config.py +113 -0
- osscodeiq/detectors/__init__.py +0 -0
- osscodeiq/detectors/auth/__init__.py +0 -0
- osscodeiq/detectors/auth/certificate_auth.py +139 -0
- osscodeiq/detectors/auth/ldap_auth.py +89 -0
- osscodeiq/detectors/auth/session_header_auth.py +120 -0
- osscodeiq/detectors/base.py +41 -0
- osscodeiq/detectors/config/__init__.py +0 -0
- osscodeiq/detectors/config/batch_structure.py +128 -0
- osscodeiq/detectors/config/cloudformation.py +183 -0
- osscodeiq/detectors/config/docker_compose.py +179 -0
- osscodeiq/detectors/config/github_actions.py +150 -0
- osscodeiq/detectors/config/gitlab_ci.py +216 -0
- osscodeiq/detectors/config/helm_chart.py +187 -0
- osscodeiq/detectors/config/ini_structure.py +101 -0
- osscodeiq/detectors/config/json_structure.py +72 -0
- osscodeiq/detectors/config/kubernetes.py +305 -0
- osscodeiq/detectors/config/kubernetes_rbac.py +212 -0
- osscodeiq/detectors/config/openapi.py +194 -0
- osscodeiq/detectors/config/package_json.py +99 -0
- osscodeiq/detectors/config/properties_detector.py +108 -0
- osscodeiq/detectors/config/pyproject_toml.py +169 -0
- osscodeiq/detectors/config/sql_structure.py +155 -0
- osscodeiq/detectors/config/toml_structure.py +93 -0
- osscodeiq/detectors/config/tsconfig_json.py +105 -0
- osscodeiq/detectors/config/yaml_structure.py +82 -0
- osscodeiq/detectors/cpp/__init__.py +0 -0
- osscodeiq/detectors/cpp/cpp_structures.py +192 -0
- osscodeiq/detectors/csharp/__init__.py +0 -0
- osscodeiq/detectors/csharp/csharp_efcore.py +184 -0
- osscodeiq/detectors/csharp/csharp_minimal_apis.py +156 -0
- osscodeiq/detectors/csharp/csharp_structures.py +317 -0
- osscodeiq/detectors/docs/__init__.py +0 -0
- osscodeiq/detectors/docs/markdown_structure.py +117 -0
- osscodeiq/detectors/frontend/__init__.py +0 -0
- osscodeiq/detectors/frontend/angular_components.py +177 -0
- osscodeiq/detectors/frontend/frontend_routes.py +259 -0
- osscodeiq/detectors/frontend/react_components.py +148 -0
- osscodeiq/detectors/frontend/svelte_components.py +84 -0
- osscodeiq/detectors/frontend/vue_components.py +150 -0
- osscodeiq/detectors/generic/__init__.py +1 -0
- osscodeiq/detectors/generic/imports_detector.py +413 -0
- osscodeiq/detectors/go/__init__.py +0 -0
- osscodeiq/detectors/go/go_orm.py +202 -0
- osscodeiq/detectors/go/go_structures.py +162 -0
- osscodeiq/detectors/go/go_web.py +157 -0
- osscodeiq/detectors/iac/__init__.py +0 -0
- osscodeiq/detectors/iac/bicep.py +135 -0
- osscodeiq/detectors/iac/dockerfile.py +182 -0
- osscodeiq/detectors/iac/terraform.py +188 -0
- osscodeiq/detectors/java/__init__.py +0 -0
- osscodeiq/detectors/java/azure_functions.py +424 -0
- osscodeiq/detectors/java/azure_messaging.py +350 -0
- osscodeiq/detectors/java/class_hierarchy.py +349 -0
- osscodeiq/detectors/java/config_def.py +82 -0
- osscodeiq/detectors/java/cosmos_db.py +105 -0
- osscodeiq/detectors/java/graphql_resolver.py +188 -0
- osscodeiq/detectors/java/grpc_service.py +142 -0
- osscodeiq/detectors/java/ibm_mq.py +178 -0
- osscodeiq/detectors/java/jaxrs.py +160 -0
- osscodeiq/detectors/java/jdbc.py +196 -0
- osscodeiq/detectors/java/jms.py +116 -0
- osscodeiq/detectors/java/jpa_entity.py +143 -0
- osscodeiq/detectors/java/kafka.py +113 -0
- osscodeiq/detectors/java/kafka_protocol.py +70 -0
- osscodeiq/detectors/java/micronaut.py +248 -0
- osscodeiq/detectors/java/module_deps.py +191 -0
- osscodeiq/detectors/java/public_api.py +206 -0
- osscodeiq/detectors/java/quarkus.py +176 -0
- osscodeiq/detectors/java/rabbitmq.py +150 -0
- osscodeiq/detectors/java/raw_sql.py +136 -0
- osscodeiq/detectors/java/repository.py +131 -0
- osscodeiq/detectors/java/rmi.py +129 -0
- osscodeiq/detectors/java/spring_events.py +117 -0
- osscodeiq/detectors/java/spring_rest.py +168 -0
- osscodeiq/detectors/java/spring_security.py +212 -0
- osscodeiq/detectors/java/tibco_ems.py +193 -0
- osscodeiq/detectors/java/websocket.py +188 -0
- osscodeiq/detectors/kotlin/__init__.py +0 -0
- osscodeiq/detectors/kotlin/kotlin_structures.py +124 -0
- osscodeiq/detectors/kotlin/ktor_routes.py +163 -0
- osscodeiq/detectors/proto/__init__.py +0 -0
- osscodeiq/detectors/proto/proto_structure.py +153 -0
- osscodeiq/detectors/python/__init__.py +0 -0
- osscodeiq/detectors/python/celery_tasks.py +88 -0
- osscodeiq/detectors/python/django_auth.py +132 -0
- osscodeiq/detectors/python/django_models.py +157 -0
- osscodeiq/detectors/python/django_views.py +74 -0
- osscodeiq/detectors/python/fastapi_auth.py +143 -0
- osscodeiq/detectors/python/fastapi_routes.py +68 -0
- osscodeiq/detectors/python/flask_routes.py +67 -0
- osscodeiq/detectors/python/kafka_python.py +175 -0
- osscodeiq/detectors/python/pydantic_models.py +115 -0
- osscodeiq/detectors/python/python_structures.py +234 -0
- osscodeiq/detectors/python/sqlalchemy_models.py +82 -0
- osscodeiq/detectors/registry.py +100 -0
- osscodeiq/detectors/rust/__init__.py +0 -0
- osscodeiq/detectors/rust/actix_web.py +234 -0
- osscodeiq/detectors/rust/rust_structures.py +174 -0
- osscodeiq/detectors/scala/__init__.py +0 -0
- osscodeiq/detectors/scala/scala_structures.py +128 -0
- osscodeiq/detectors/shell/__init__.py +0 -0
- osscodeiq/detectors/shell/bash_detector.py +127 -0
- osscodeiq/detectors/shell/powershell_detector.py +118 -0
- osscodeiq/detectors/typescript/__init__.py +0 -0
- osscodeiq/detectors/typescript/express_routes.py +55 -0
- osscodeiq/detectors/typescript/fastify_routes.py +156 -0
- osscodeiq/detectors/typescript/graphql_resolvers.py +100 -0
- osscodeiq/detectors/typescript/kafka_js.py +164 -0
- osscodeiq/detectors/typescript/mongoose_orm.py +151 -0
- osscodeiq/detectors/typescript/nestjs_controllers.py +99 -0
- osscodeiq/detectors/typescript/nestjs_guards.py +138 -0
- osscodeiq/detectors/typescript/passport_jwt.py +133 -0
- osscodeiq/detectors/typescript/prisma_orm.py +96 -0
- osscodeiq/detectors/typescript/remix_routes.py +160 -0
- osscodeiq/detectors/typescript/sequelize_orm.py +136 -0
- osscodeiq/detectors/typescript/typeorm_entities.py +86 -0
- osscodeiq/detectors/typescript/typescript_structures.py +185 -0
- osscodeiq/detectors/utils.py +49 -0
- osscodeiq/discovery/__init__.py +11 -0
- osscodeiq/discovery/change_detector.py +97 -0
- osscodeiq/discovery/file_discovery.py +342 -0
- osscodeiq/flow/__init__.py +0 -0
- osscodeiq/flow/engine.py +78 -0
- osscodeiq/flow/models.py +72 -0
- osscodeiq/flow/renderer.py +127 -0
- osscodeiq/flow/templates/interactive.html +252 -0
- osscodeiq/flow/vendor/cytoscape-dagre.min.js +8 -0
- osscodeiq/flow/vendor/cytoscape.min.js +32 -0
- osscodeiq/flow/vendor/dagre.min.js +3809 -0
- osscodeiq/flow/views.py +357 -0
- osscodeiq/graph/__init__.py +0 -0
- osscodeiq/graph/backend.py +52 -0
- osscodeiq/graph/backends/__init__.py +23 -0
- osscodeiq/graph/backends/kuzu.py +576 -0
- osscodeiq/graph/backends/networkx.py +135 -0
- osscodeiq/graph/backends/sqlite_backend.py +406 -0
- osscodeiq/graph/builder.py +297 -0
- osscodeiq/graph/query.py +228 -0
- osscodeiq/graph/store.py +183 -0
- osscodeiq/graph/views.py +231 -0
- osscodeiq/models/__init__.py +17 -0
- osscodeiq/models/graph.py +116 -0
- osscodeiq/output/__init__.py +0 -0
- osscodeiq/output/dot.py +171 -0
- osscodeiq/output/mermaid.py +160 -0
- osscodeiq/output/safety.py +58 -0
- osscodeiq/output/serializers.py +42 -0
- osscodeiq/parsing/__init__.py +5 -0
- osscodeiq/parsing/languages/__init__.py +0 -0
- osscodeiq/parsing/languages/base.py +23 -0
- osscodeiq/parsing/languages/java.py +68 -0
- osscodeiq/parsing/languages/python.py +57 -0
- osscodeiq/parsing/languages/typescript.py +95 -0
- osscodeiq/parsing/parser_manager.py +125 -0
- osscodeiq/parsing/structured/__init__.py +0 -0
- osscodeiq/parsing/structured/gradle_parser.py +78 -0
- osscodeiq/parsing/structured/json_parser.py +24 -0
- osscodeiq/parsing/structured/properties_parser.py +56 -0
- osscodeiq/parsing/structured/sql_parser.py +54 -0
- osscodeiq/parsing/structured/xml_parser.py +148 -0
- osscodeiq/parsing/structured/yaml_parser.py +38 -0
- osscodeiq/server/__init__.py +7 -0
- osscodeiq/server/app.py +53 -0
- osscodeiq/server/mcp_server.py +174 -0
- osscodeiq/server/middleware.py +16 -0
- osscodeiq/server/routes.py +184 -0
- osscodeiq/server/service.py +445 -0
- osscodeiq/server/templates/welcome.html +56 -0
- osscodeiq-0.0.0.dist-info/METADATA +30 -0
- osscodeiq-0.0.0.dist-info/RECORD +183 -0
- osscodeiq-0.0.0.dist-info/WHEEL +5 -0
- osscodeiq-0.0.0.dist-info/entry_points.txt +2 -0
- osscodeiq-0.0.0.dist-info/licenses/LICENSE +21 -0
- 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
|
osscodeiq/graph/query.py
ADDED
|
@@ -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)
|