forgeoptimizer 1.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.
- forgecli/__init__.py +4 -0
- forgecli/build/__init__.py +135 -0
- forgecli/build/apply.py +218 -0
- forgecli/build/caveman_optimize.py +37 -0
- forgecli/build/diff_extract.py +218 -0
- forgecli/build/llm.py +167 -0
- forgecli/build/optimize.py +37 -0
- forgecli/build/pipeline.py +76 -0
- forgecli/build/retrieval.py +157 -0
- forgecli/build/summarize.py +76 -0
- forgecli/build/test_run.py +75 -0
- forgecli/builder/__init__.py +11 -0
- forgecli/builder/builder.py +53 -0
- forgecli/builder/editor.py +36 -0
- forgecli/builder/formatter.py +31 -0
- forgecli/cli/__init__.py +5 -0
- forgecli/cli/bootstrap.py +281 -0
- forgecli/cli/commands_graph.py +137 -0
- forgecli/cli/commands_wrappers.py +63 -0
- forgecli/cli/main.py +122 -0
- forgecli/cli/ui.py +56 -0
- forgecli/config/__init__.py +28 -0
- forgecli/config/loader.py +85 -0
- forgecli/config/settings.py +204 -0
- forgecli/config/writer.py +90 -0
- forgecli/core/__init__.py +35 -0
- forgecli/core/container.py +68 -0
- forgecli/core/context.py +54 -0
- forgecli/core/credentials.py +114 -0
- forgecli/core/errors.py +27 -0
- forgecli/core/events.py +67 -0
- forgecli/core/logging.py +47 -0
- forgecli/core/models.py +159 -0
- forgecli/core/plugins.py +56 -0
- forgecli/core/service.py +22 -0
- forgecli/docs/__init__.py +5 -0
- forgecli/docs/generator.py +119 -0
- forgecli/engine/__init__.py +105 -0
- forgecli/engine/context.py +171 -0
- forgecli/engine/defaults.py +89 -0
- forgecli/engine/events.py +185 -0
- forgecli/engine/execution.py +526 -0
- forgecli/engine/plugins.py +146 -0
- forgecli/engine/runner.py +155 -0
- forgecli/engine/stages/__init__.py +33 -0
- forgecli/engine/stages/caveman_optimizer.py +47 -0
- forgecli/engine/stages/context_optimizer.py +44 -0
- forgecli/engine/stages/execution_engine_stage.py +108 -0
- forgecli/engine/stages/git_engine.py +30 -0
- forgecli/engine/stages/intent_analyzer.py +38 -0
- forgecli/engine/stages/model_router.py +66 -0
- forgecli/engine/stages/planning_engine.py +45 -0
- forgecli/engine/stages/repository_analyzer.py +54 -0
- forgecli/engine/stages/validation_engine.py +89 -0
- forgecli/git/__init__.py +9 -0
- forgecli/git/repo.py +55 -0
- forgecli/git/service.py +45 -0
- forgecli/graph/__init__.py +56 -0
- forgecli/graph/backend_graphify.py +453 -0
- forgecli/graph/edge.py +19 -0
- forgecli/graph/graph.py +85 -0
- forgecli/graph/graphify.py +412 -0
- forgecli/graph/indexer.py +82 -0
- forgecli/graph/node.py +47 -0
- forgecli/graph/repository.py +164 -0
- forgecli/memory/__init__.py +12 -0
- forgecli/memory/cache.py +61 -0
- forgecli/memory/history.py +138 -0
- forgecli/memory/store.py +87 -0
- forgecli/optimizer/__init__.py +14 -0
- forgecli/optimizer/caveman/__init__.py +173 -0
- forgecli/optimizer/caveman/cli.py +64 -0
- forgecli/optimizer/caveman/decorator.py +67 -0
- forgecli/optimizer/caveman/factory.py +51 -0
- forgecli/optimizer/caveman/ruleset.py +156 -0
- forgecli/optimizer/caveman/state.py +52 -0
- forgecli/optimizer/chunker.py +85 -0
- forgecli/optimizer/optimizer.py +81 -0
- forgecli/optimizer/ponytail/__init__.py +183 -0
- forgecli/optimizer/ponytail/cli.py +168 -0
- forgecli/optimizer/ponytail/decorator.py +70 -0
- forgecli/optimizer/ponytail/factory.py +51 -0
- forgecli/optimizer/ponytail/ruleset.py +168 -0
- forgecli/optimizer/ponytail/state.py +53 -0
- forgecli/optimizer/ranker.py +37 -0
- forgecli/optimizer/summarizer.py +44 -0
- forgecli/orchestrator/__init__.py +707 -0
- forgecli/planner/__init__.py +56 -0
- forgecli/planner/agent.py +61 -0
- forgecli/planner/plan.py +65 -0
- forgecli/planner/planner.py +17 -0
- forgecli/planner/render.py +271 -0
- forgecli/planner/serialize.py +106 -0
- forgecli/planner/software.py +834 -0
- forgecli/platform/__init__.py +91 -0
- forgecli/platform/core.py +201 -0
- forgecli/platform/deps.py +354 -0
- forgecli/platform/paths.py +236 -0
- forgecli/platform/shell.py +176 -0
- forgecli/platform/update.py +252 -0
- forgecli/plugins/__init__.py +251 -0
- forgecli/prompts/__init__.py +11 -0
- forgecli/prompts/loader.py +28 -0
- forgecli/prompts/registry.py +32 -0
- forgecli/prompts/renderer.py +23 -0
- forgecli/providers/__init__.py +39 -0
- forgecli/providers/anthropic.py +206 -0
- forgecli/providers/base.py +206 -0
- forgecli/providers/builtin.py +26 -0
- forgecli/providers/conversation.py +78 -0
- forgecli/providers/google.py +295 -0
- forgecli/providers/http_base.py +211 -0
- forgecli/providers/mock.py +113 -0
- forgecli/providers/openai.py +202 -0
- forgecli/providers/openai_compatible.py +728 -0
- forgecli/providers/router.py +345 -0
- forgecli/providers/router_state.py +89 -0
- forgecli/review/__init__.py +45 -0
- forgecli/review/analyzer.py +124 -0
- forgecli/review/analyzers/__init__.py +1 -0
- forgecli/review/analyzers/architecture.py +258 -0
- forgecli/review/analyzers/complexity.py +167 -0
- forgecli/review/analyzers/dead_code.py +262 -0
- forgecli/review/analyzers/duplicates.py +163 -0
- forgecli/review/analyzers/performance.py +165 -0
- forgecli/review/analyzers/security.py +251 -0
- forgecli/review/finding.py +68 -0
- forgecli/review/report.py +311 -0
- forgecli/review/repository.py +131 -0
- forgecli/review/suggestions.py +98 -0
- forgecli/runtime/__init__.py +6 -0
- forgecli/runtime/cache_store.py +77 -0
- forgecli/runtime/prepare.py +199 -0
- forgecli/runtime/wrappers.py +152 -0
- forgecli/sdk/__init__.py +132 -0
- forgecli/sdk/events.py +206 -0
- forgecli/sdk/interfaces.py +282 -0
- forgecli/sdk/loader.py +243 -0
- forgecli/sdk/manager.py +693 -0
- forgecli/sdk/manifest.py +397 -0
- forgecli/sdk/sandbox.py +197 -0
- forgecli/sdk/version.py +316 -0
- forgecli/templates/__init__.py +9 -0
- forgecli/templates/engine.py +37 -0
- forgecli/templates/registry.py +32 -0
- forgecli/utils/__init__.py +19 -0
- forgecli/utils/fs.py +91 -0
- forgecli/utils/ids.py +11 -0
- forgecli/utils/io.py +27 -0
- forgecli/utils/paths.py +70 -0
- forgecli/utils/timing.py +25 -0
- forgeoptimizer-1.0.0.dist-info/METADATA +169 -0
- forgeoptimizer-1.0.0.dist-info/RECORD +156 -0
- forgeoptimizer-1.0.0.dist-info/WHEEL +4 -0
- forgeoptimizer-1.0.0.dist-info/entry_points.txt +2 -0
- forgeoptimizer-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"""Adapter that exposes a :class:`RepositoryGraph` backed by Graphify.
|
|
2
|
+
|
|
3
|
+
This module owns the translation from Graphify's on-disk ``graph.json``
|
|
4
|
+
into the typed :class:`GraphSnapshot` returned by
|
|
5
|
+
:class:`forgecli.graph.repository.RepositoryGraph`. It also routes the
|
|
6
|
+
high-level operations (``query``, ``explain``, ``shortest_path``,
|
|
7
|
+
``affected``) to the corresponding Graphify CLI subcommands.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from collections import Counter, defaultdict
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
from forgecli.core.errors import ConfigError
|
|
18
|
+
from forgecli.graph.graphify import (
|
|
19
|
+
GraphifyArtifacts,
|
|
20
|
+
GraphifyClient,
|
|
21
|
+
GraphifyNotFoundError,
|
|
22
|
+
)
|
|
23
|
+
from forgecli.graph.repository import (
|
|
24
|
+
BuildResult,
|
|
25
|
+
ExplainResult,
|
|
26
|
+
GraphCommunity,
|
|
27
|
+
GraphEdge,
|
|
28
|
+
GraphNode,
|
|
29
|
+
GraphSnapshot,
|
|
30
|
+
QueryResult,
|
|
31
|
+
RepositoryGraph,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
35
|
+
from collections.abc import Iterable
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_CITED_NODE_RE = re.compile(r"\b([A-Za-z0-9_./-]+(?:\.[A-Za-z0-9_./-]+)*)")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class GraphifyRepositoryGraph(RepositoryGraph):
|
|
42
|
+
"""A :class:`RepositoryGraph` powered by the Graphify CLI."""
|
|
43
|
+
|
|
44
|
+
name = "graphify"
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
root: Path,
|
|
49
|
+
*,
|
|
50
|
+
client: GraphifyClient | None = None,
|
|
51
|
+
artifacts: GraphifyArtifacts | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
self._root = Path(root).resolve()
|
|
54
|
+
self._client = client or GraphifyClient()
|
|
55
|
+
self._artifacts = artifacts or GraphifyArtifacts.for_root(self._root)
|
|
56
|
+
self._cached: GraphSnapshot | None = None
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def root(self) -> Path:
|
|
60
|
+
return self._root
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def client(self) -> GraphifyClient:
|
|
64
|
+
return self._client
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def artifacts(self) -> GraphifyArtifacts:
|
|
68
|
+
return self._artifacts
|
|
69
|
+
|
|
70
|
+
# ------------------------------------------------------------------
|
|
71
|
+
# Availability
|
|
72
|
+
# ------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
async def is_available(self) -> bool:
|
|
75
|
+
return await self._client.is_installed()
|
|
76
|
+
|
|
77
|
+
async def install_hint(self) -> str:
|
|
78
|
+
return (
|
|
79
|
+
"Graphify is not installed.\n"
|
|
80
|
+
"Install it with: uv tool install graphifyy\n"
|
|
81
|
+
"Docs: https://graphifylabs.ai/"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
# Build / load
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
async def build(
|
|
89
|
+
self,
|
|
90
|
+
*,
|
|
91
|
+
force: bool = False,
|
|
92
|
+
no_cluster: bool = False,
|
|
93
|
+
extra_args: Iterable[str] | None = None,
|
|
94
|
+
) -> BuildResult:
|
|
95
|
+
outcome = await self._client.build(
|
|
96
|
+
self._root,
|
|
97
|
+
force=force,
|
|
98
|
+
no_cluster=no_cluster,
|
|
99
|
+
extra_args=tuple(extra_args or ()),
|
|
100
|
+
)
|
|
101
|
+
snapshot = self._snapshot_from_payload(outcome.graph_payload)
|
|
102
|
+
self._cached = snapshot
|
|
103
|
+
return BuildResult(
|
|
104
|
+
snapshot=snapshot,
|
|
105
|
+
artifacts={
|
|
106
|
+
"graph_json": str(outcome.artifacts.graph_json),
|
|
107
|
+
"graph_html": str(outcome.artifacts.graph_html),
|
|
108
|
+
"graph_report": str(outcome.artifacts.graph_report),
|
|
109
|
+
"manifest_json": str(outcome.artifacts.manifest_json),
|
|
110
|
+
},
|
|
111
|
+
raw_output=outcome.stdout,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
async def update_graph(
|
|
115
|
+
self,
|
|
116
|
+
*,
|
|
117
|
+
force: bool = False,
|
|
118
|
+
no_cluster: bool = False,
|
|
119
|
+
) -> BuildResult:
|
|
120
|
+
outcome = await self._client.update(
|
|
121
|
+
self._root,
|
|
122
|
+
force=force,
|
|
123
|
+
no_cluster=no_cluster,
|
|
124
|
+
)
|
|
125
|
+
snapshot = self._snapshot_from_payload(outcome.graph_payload)
|
|
126
|
+
self._cached = snapshot
|
|
127
|
+
return BuildResult(
|
|
128
|
+
snapshot=snapshot,
|
|
129
|
+
artifacts={
|
|
130
|
+
"graph_json": str(outcome.artifacts.graph_json),
|
|
131
|
+
"graph_html": str(outcome.artifacts.graph_html),
|
|
132
|
+
"graph_report": str(outcome.artifacts.graph_report),
|
|
133
|
+
"manifest_json": str(outcome.artifacts.manifest_json),
|
|
134
|
+
},
|
|
135
|
+
raw_output=outcome.stdout,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
async def load(self) -> GraphSnapshot:
|
|
139
|
+
"""Load a previously-built graph from ``self.artifacts.graph_json``."""
|
|
140
|
+
if self._cached is not None:
|
|
141
|
+
return self._cached
|
|
142
|
+
if not self._artifacts.graph_json.exists():
|
|
143
|
+
raise ConfigError(
|
|
144
|
+
f"No graph.json found at {self._artifacts.graph_json}. "
|
|
145
|
+
"Run `forge graph build` first."
|
|
146
|
+
)
|
|
147
|
+
payload = self._client.load_graph(self._artifacts.graph_json)
|
|
148
|
+
snapshot = self._snapshot_from_payload(payload)
|
|
149
|
+
self._cached = snapshot
|
|
150
|
+
return snapshot
|
|
151
|
+
|
|
152
|
+
# ------------------------------------------------------------------
|
|
153
|
+
# High-level operations
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
async def query(self, question: str, *, budget: int = 2000) -> QueryResult:
|
|
157
|
+
snapshot = await self._ensure_loaded()
|
|
158
|
+
answer = await self._client.query(
|
|
159
|
+
self._root,
|
|
160
|
+
question,
|
|
161
|
+
budget=budget,
|
|
162
|
+
graph_path=self._artifacts.graph_json,
|
|
163
|
+
)
|
|
164
|
+
cited = _extract_cited_nodes(answer, snapshot)
|
|
165
|
+
return QueryResult(
|
|
166
|
+
question=question,
|
|
167
|
+
answer=answer.strip(),
|
|
168
|
+
cited_nodes=tuple(cited),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
async def explain(self, target: str) -> ExplainResult:
|
|
172
|
+
snapshot = await self._ensure_loaded()
|
|
173
|
+
text = await self._client.explain(
|
|
174
|
+
self._root,
|
|
175
|
+
target,
|
|
176
|
+
graph_path=self._artifacts.graph_json,
|
|
177
|
+
)
|
|
178
|
+
related = _related_nodes(target, snapshot)
|
|
179
|
+
return ExplainResult(
|
|
180
|
+
target=target,
|
|
181
|
+
explanation=text.strip(),
|
|
182
|
+
related=tuple(related),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
async def shortest_path(self, a: str, b: str) -> list[GraphEdge]:
|
|
186
|
+
"""Return the shortest edge path between ``a`` and ``b``.
|
|
187
|
+
|
|
188
|
+
Implementation: the Graphify CLI emits a human-readable path; we
|
|
189
|
+
reconstruct the edge list from the in-memory snapshot by BFS
|
|
190
|
+
over node labels, then optionally cross-check against the CLI
|
|
191
|
+
output for confirmation.
|
|
192
|
+
"""
|
|
193
|
+
snapshot = await self._ensure_loaded()
|
|
194
|
+
ids_a = _resolve_label(a, snapshot)
|
|
195
|
+
ids_b = _resolve_label(b, snapshot)
|
|
196
|
+
if not ids_a or not ids_b:
|
|
197
|
+
return []
|
|
198
|
+
edges = _bfs_shortest_path(snapshot, ids_a[0], ids_b[0])
|
|
199
|
+
if not edges:
|
|
200
|
+
# Fall back to invoking the Graphify CLI for confirmation.
|
|
201
|
+
await self._client.path(
|
|
202
|
+
self._root, a, b, graph_path=self._artifacts.graph_json
|
|
203
|
+
)
|
|
204
|
+
return edges
|
|
205
|
+
|
|
206
|
+
async def affected(
|
|
207
|
+
self,
|
|
208
|
+
target: str,
|
|
209
|
+
*,
|
|
210
|
+
relation: Iterable[str] | None = None,
|
|
211
|
+
depth: int = 2,
|
|
212
|
+
) -> list[GraphEdge]:
|
|
213
|
+
snapshot = await self._ensure_loaded()
|
|
214
|
+
ids = _resolve_label(target, snapshot)
|
|
215
|
+
if not ids:
|
|
216
|
+
return []
|
|
217
|
+
# Always call Graphify for the authoritative traversal text.
|
|
218
|
+
await self._client.affected(
|
|
219
|
+
self._root,
|
|
220
|
+
target,
|
|
221
|
+
relation=relation,
|
|
222
|
+
depth=depth,
|
|
223
|
+
graph_path=self._artifacts.graph_json,
|
|
224
|
+
)
|
|
225
|
+
return _reverse_reachable(snapshot, ids[0], depth=depth, relation=relation)
|
|
226
|
+
|
|
227
|
+
# ------------------------------------------------------------------
|
|
228
|
+
# Snapshot construction
|
|
229
|
+
# ------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
async def _ensure_loaded(self) -> GraphSnapshot:
|
|
232
|
+
if self._cached is not None:
|
|
233
|
+
return self._cached
|
|
234
|
+
if self._artifacts.graph_json.exists():
|
|
235
|
+
payload = self._client.load_graph(self._artifacts.graph_json)
|
|
236
|
+
snapshot = self._snapshot_from_payload(payload)
|
|
237
|
+
self._cached = snapshot
|
|
238
|
+
return snapshot
|
|
239
|
+
raise ConfigError(
|
|
240
|
+
f"No graph.json found at {self._artifacts.graph_json}. "
|
|
241
|
+
"Run `forge graph build` first."
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def _snapshot_from_payload(self, payload: dict[str, Any]) -> GraphSnapshot:
|
|
245
|
+
nodes: list[GraphNode] = []
|
|
246
|
+
for raw in payload.get("nodes", []):
|
|
247
|
+
nodes.append(
|
|
248
|
+
GraphNode(
|
|
249
|
+
id=str(raw.get("id", raw.get("label", ""))),
|
|
250
|
+
label=str(raw.get("label", "")),
|
|
251
|
+
file_type=raw.get("file_type"),
|
|
252
|
+
source_file=raw.get("source_file"),
|
|
253
|
+
source_location=raw.get("source_location"),
|
|
254
|
+
community=raw.get("community"),
|
|
255
|
+
norm_label=raw.get("norm_label"),
|
|
256
|
+
extra={
|
|
257
|
+
k: v
|
|
258
|
+
for k, v in raw.items()
|
|
259
|
+
if k
|
|
260
|
+
not in {
|
|
261
|
+
"id",
|
|
262
|
+
"label",
|
|
263
|
+
"file_type",
|
|
264
|
+
"source_file",
|
|
265
|
+
"source_location",
|
|
266
|
+
"community",
|
|
267
|
+
"norm_label",
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
edges: list[GraphEdge] = []
|
|
274
|
+
for raw in payload.get("links", []):
|
|
275
|
+
edges.append(
|
|
276
|
+
GraphEdge(
|
|
277
|
+
source=str(raw.get("source", "")),
|
|
278
|
+
target=str(raw.get("target", "")),
|
|
279
|
+
relation=str(raw.get("relation", "")),
|
|
280
|
+
confidence=raw.get("confidence"),
|
|
281
|
+
confidence_score=raw.get("confidence_score"),
|
|
282
|
+
source_file=raw.get("source_file"),
|
|
283
|
+
source_location=raw.get("source_location"),
|
|
284
|
+
weight=float(raw.get("weight", 1.0) or 1.0),
|
|
285
|
+
extra={
|
|
286
|
+
k: v
|
|
287
|
+
for k, v in raw.items()
|
|
288
|
+
if k
|
|
289
|
+
not in {
|
|
290
|
+
"source",
|
|
291
|
+
"target",
|
|
292
|
+
"relation",
|
|
293
|
+
"confidence",
|
|
294
|
+
"confidence_score",
|
|
295
|
+
"source_file",
|
|
296
|
+
"source_location",
|
|
297
|
+
"weight",
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
)
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
communities = _build_communities(nodes, edges, payload)
|
|
304
|
+
|
|
305
|
+
return GraphSnapshot(
|
|
306
|
+
root=str(self._root),
|
|
307
|
+
nodes=tuple(nodes),
|
|
308
|
+
edges=tuple(edges),
|
|
309
|
+
communities=tuple(communities),
|
|
310
|
+
directed=bool(payload.get("directed", False)),
|
|
311
|
+
multigraph=bool(payload.get("multigraph", False)),
|
|
312
|
+
metadata={"hyperedges": payload.get("hyperedges", [])},
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# ---------------------------------------------------------------------------
|
|
317
|
+
# Helpers
|
|
318
|
+
# ---------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _build_communities(
|
|
322
|
+
nodes: list[GraphNode],
|
|
323
|
+
edges: list[GraphEdge],
|
|
324
|
+
payload: dict[str, Any],
|
|
325
|
+
) -> list[GraphCommunity]:
|
|
326
|
+
"""Group nodes by their ``community`` id; preserve numeric ordering."""
|
|
327
|
+
if not nodes:
|
|
328
|
+
return []
|
|
329
|
+
counter: Counter[int] = Counter()
|
|
330
|
+
members: dict[int, list[str]] = defaultdict(list)
|
|
331
|
+
for node in nodes:
|
|
332
|
+
if node.community is None:
|
|
333
|
+
continue
|
|
334
|
+
counter[node.community] += 1
|
|
335
|
+
members[node.community].append(node.id)
|
|
336
|
+
labels = payload.get("community_labels") or {}
|
|
337
|
+
communities: list[GraphCommunity] = []
|
|
338
|
+
for cid in sorted(counter):
|
|
339
|
+
communities.append(
|
|
340
|
+
GraphCommunity(
|
|
341
|
+
id=cid,
|
|
342
|
+
size=counter[cid],
|
|
343
|
+
label=labels.get(str(cid)) or labels.get(cid),
|
|
344
|
+
members=tuple(members[cid]),
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
return communities
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _resolve_label(label_or_id: str, snapshot: GraphSnapshot) -> list[str]:
|
|
351
|
+
"""Return node ids matching ``label_or_id`` by id, label, or norm_label."""
|
|
352
|
+
needle = label_or_id.strip()
|
|
353
|
+
if not needle:
|
|
354
|
+
return []
|
|
355
|
+
ids: list[str] = []
|
|
356
|
+
for node in snapshot.nodes:
|
|
357
|
+
if needle == node.id or needle == node.label or needle == (node.norm_label or ""):
|
|
358
|
+
ids.append(node.id)
|
|
359
|
+
return ids
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _bfs_shortest_path(
|
|
363
|
+
snapshot: GraphSnapshot, src: str, dst: str
|
|
364
|
+
) -> list[GraphEdge]:
|
|
365
|
+
"""Breadth-first shortest path over undirected edges."""
|
|
366
|
+
if src == dst:
|
|
367
|
+
return []
|
|
368
|
+
adj: dict[str, list[tuple[str, GraphEdge]]] = defaultdict(list)
|
|
369
|
+
for edge in snapshot.edges:
|
|
370
|
+
adj[edge.source].append((edge.target, edge))
|
|
371
|
+
adj[edge.target].append((edge.source, edge))
|
|
372
|
+
visited = {src}
|
|
373
|
+
queue: list[tuple[str, list[GraphEdge]]] = [(src, [])]
|
|
374
|
+
while queue:
|
|
375
|
+
node, path = queue.pop(0)
|
|
376
|
+
for nxt, edge in adj.get(node, ()):
|
|
377
|
+
if nxt in visited:
|
|
378
|
+
continue
|
|
379
|
+
new_path = [*path, edge]
|
|
380
|
+
if nxt == dst:
|
|
381
|
+
return new_path
|
|
382
|
+
visited.add(nxt)
|
|
383
|
+
queue.append((nxt, new_path))
|
|
384
|
+
return []
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _reverse_reachable(
|
|
388
|
+
snapshot: GraphSnapshot,
|
|
389
|
+
start: str,
|
|
390
|
+
*,
|
|
391
|
+
depth: int,
|
|
392
|
+
relation: Iterable[str] | None,
|
|
393
|
+
) -> list[GraphEdge]:
|
|
394
|
+
"""Return edges traversed when walking *into* ``start`` up to ``depth``."""
|
|
395
|
+
rels = set(relation) if relation else None
|
|
396
|
+
out: list[GraphEdge] = []
|
|
397
|
+
seen: set[tuple[str, str, str]] = set()
|
|
398
|
+
current = {start}
|
|
399
|
+
for _ in range(max(depth, 0)):
|
|
400
|
+
next_layer: set[str] = set()
|
|
401
|
+
for edge in snapshot.edges:
|
|
402
|
+
if edge.target in current and (rels is None or edge.relation in rels):
|
|
403
|
+
key = (edge.source, edge.target, edge.relation)
|
|
404
|
+
if key in seen:
|
|
405
|
+
continue
|
|
406
|
+
seen.add(key)
|
|
407
|
+
out.append(edge)
|
|
408
|
+
next_layer.add(edge.source)
|
|
409
|
+
current = next_layer
|
|
410
|
+
if not current:
|
|
411
|
+
break
|
|
412
|
+
return out
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _related_nodes(target: str, snapshot: GraphSnapshot) -> list[GraphNode]:
|
|
416
|
+
"""Return nodes within one hop of any id matching ``target``."""
|
|
417
|
+
ids = set(_resolve_label(target, snapshot))
|
|
418
|
+
if not ids:
|
|
419
|
+
return []
|
|
420
|
+
related: list[GraphNode] = []
|
|
421
|
+
seen: set[str] = set()
|
|
422
|
+
for edge in snapshot.edges:
|
|
423
|
+
if edge.source in ids and edge.target not in seen:
|
|
424
|
+
node = snapshot.node(edge.target)
|
|
425
|
+
if node is not None:
|
|
426
|
+
related.append(node)
|
|
427
|
+
seen.add(node.id)
|
|
428
|
+
elif edge.target in ids and edge.source not in seen:
|
|
429
|
+
node = snapshot.node(edge.source)
|
|
430
|
+
if node is not None:
|
|
431
|
+
related.append(node)
|
|
432
|
+
seen.add(node.id)
|
|
433
|
+
return related
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _extract_cited_nodes(answer: str, snapshot: GraphSnapshot) -> list[str]:
|
|
437
|
+
"""Pull node ids that look like ``name.py`` or symbol names from ``answer``."""
|
|
438
|
+
cited: list[str] = []
|
|
439
|
+
seen: set[str] = set()
|
|
440
|
+
if not answer:
|
|
441
|
+
return cited
|
|
442
|
+
labels = {node.label for node in snapshot.nodes}
|
|
443
|
+
ids = {node.id for node in snapshot.nodes}
|
|
444
|
+
for token in _CITED_NODE_RE.findall(answer):
|
|
445
|
+
if token in seen:
|
|
446
|
+
continue
|
|
447
|
+
if token in labels or token in ids:
|
|
448
|
+
cited.append(token)
|
|
449
|
+
seen.add(token)
|
|
450
|
+
return cited
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
__all__ = ["GraphifyNotFoundError", "GraphifyRepositoryGraph"]
|
forgecli/graph/edge.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Edge value type for the code graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from forgecli.graph.node import EdgeKind
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Edge:
|
|
13
|
+
"""A directed edge in the code graph."""
|
|
14
|
+
|
|
15
|
+
source: str
|
|
16
|
+
target: str
|
|
17
|
+
kind: EdgeKind
|
|
18
|
+
weight: float = 1.0
|
|
19
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
forgecli/graph/graph.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""In-memory representation of the repository code graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from forgecli.graph.edge import Edge
|
|
10
|
+
from forgecli.graph.node import Node, NodeKind
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CodeGraph:
|
|
14
|
+
"""Mutable, in-memory graph of repository symbols and their relations.
|
|
15
|
+
|
|
16
|
+
The graph is intentionally simple: nodes and edges are stored in
|
|
17
|
+
dictionaries keyed by id. A real implementation may serialize to disk
|
|
18
|
+
or back this with a proper graph database; the surface area stays the
|
|
19
|
+
same.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
self._nodes: dict[str, Node] = {}
|
|
24
|
+
self._outgoing: dict[str, list[Edge]] = defaultdict(list)
|
|
25
|
+
self._incoming: dict[str, list[Edge]] = defaultdict(list)
|
|
26
|
+
|
|
27
|
+
# --- node operations -------------------------------------------------
|
|
28
|
+
|
|
29
|
+
def add_node(self, node: Node) -> None:
|
|
30
|
+
self._nodes[node.id] = node
|
|
31
|
+
|
|
32
|
+
def remove_node(self, node_id: str) -> None:
|
|
33
|
+
self._nodes.pop(node_id, None)
|
|
34
|
+
for edge in self._outgoing.pop(node_id, ()):
|
|
35
|
+
self._incoming.get(edge.target, []).remove(edge)
|
|
36
|
+
for edge in self._incoming.pop(node_id, ()):
|
|
37
|
+
self._outgoing.get(edge.source, []).remove(edge)
|
|
38
|
+
|
|
39
|
+
def has_node(self, node_id: str) -> bool:
|
|
40
|
+
return node_id in self._nodes
|
|
41
|
+
|
|
42
|
+
def get_node(self, node_id: str) -> Node | None:
|
|
43
|
+
return self._nodes.get(node_id)
|
|
44
|
+
|
|
45
|
+
def nodes(self, kind: NodeKind | None = None) -> Iterator[Node]:
|
|
46
|
+
for node in self._nodes.values():
|
|
47
|
+
if kind is None or node.kind is kind:
|
|
48
|
+
yield node
|
|
49
|
+
|
|
50
|
+
# --- edge operations -------------------------------------------------
|
|
51
|
+
|
|
52
|
+
def add_edge(self, edge: Edge) -> None:
|
|
53
|
+
self._outgoing[edge.source].append(edge)
|
|
54
|
+
self._incoming[edge.target].append(edge)
|
|
55
|
+
|
|
56
|
+
def remove_edge(self, source: str, target: str, kind: Any | None = None) -> None:
|
|
57
|
+
self._outgoing[source] = [
|
|
58
|
+
e
|
|
59
|
+
for e in self._outgoing.get(source, ())
|
|
60
|
+
if not (e.target == target and (kind is None or e.kind is kind))
|
|
61
|
+
]
|
|
62
|
+
self._incoming[target] = [
|
|
63
|
+
e
|
|
64
|
+
for e in self._incoming.get(target, ())
|
|
65
|
+
if not (e.source == source and (kind is None or e.kind is kind))
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
def outgoing(self, node_id: str) -> list[Edge]:
|
|
69
|
+
return list(self._outgoing.get(node_id, ()))
|
|
70
|
+
|
|
71
|
+
def incoming(self, node_id: str) -> list[Edge]:
|
|
72
|
+
return list(self._incoming.get(node_id, ()))
|
|
73
|
+
|
|
74
|
+
# --- bulk helpers -----------------------------------------------------
|
|
75
|
+
|
|
76
|
+
def clear(self) -> None:
|
|
77
|
+
self._nodes.clear()
|
|
78
|
+
self._outgoing.clear()
|
|
79
|
+
self._incoming.clear()
|
|
80
|
+
|
|
81
|
+
def stats(self) -> dict[str, int]:
|
|
82
|
+
return {
|
|
83
|
+
"nodes": len(self._nodes),
|
|
84
|
+
"edges": sum(len(v) for v in self._outgoing.values()),
|
|
85
|
+
}
|