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.
Files changed (156) hide show
  1. forgecli/__init__.py +4 -0
  2. forgecli/build/__init__.py +135 -0
  3. forgecli/build/apply.py +218 -0
  4. forgecli/build/caveman_optimize.py +37 -0
  5. forgecli/build/diff_extract.py +218 -0
  6. forgecli/build/llm.py +167 -0
  7. forgecli/build/optimize.py +37 -0
  8. forgecli/build/pipeline.py +76 -0
  9. forgecli/build/retrieval.py +157 -0
  10. forgecli/build/summarize.py +76 -0
  11. forgecli/build/test_run.py +75 -0
  12. forgecli/builder/__init__.py +11 -0
  13. forgecli/builder/builder.py +53 -0
  14. forgecli/builder/editor.py +36 -0
  15. forgecli/builder/formatter.py +31 -0
  16. forgecli/cli/__init__.py +5 -0
  17. forgecli/cli/bootstrap.py +281 -0
  18. forgecli/cli/commands_graph.py +137 -0
  19. forgecli/cli/commands_wrappers.py +63 -0
  20. forgecli/cli/main.py +122 -0
  21. forgecli/cli/ui.py +56 -0
  22. forgecli/config/__init__.py +28 -0
  23. forgecli/config/loader.py +85 -0
  24. forgecli/config/settings.py +204 -0
  25. forgecli/config/writer.py +90 -0
  26. forgecli/core/__init__.py +35 -0
  27. forgecli/core/container.py +68 -0
  28. forgecli/core/context.py +54 -0
  29. forgecli/core/credentials.py +114 -0
  30. forgecli/core/errors.py +27 -0
  31. forgecli/core/events.py +67 -0
  32. forgecli/core/logging.py +47 -0
  33. forgecli/core/models.py +159 -0
  34. forgecli/core/plugins.py +56 -0
  35. forgecli/core/service.py +22 -0
  36. forgecli/docs/__init__.py +5 -0
  37. forgecli/docs/generator.py +119 -0
  38. forgecli/engine/__init__.py +105 -0
  39. forgecli/engine/context.py +171 -0
  40. forgecli/engine/defaults.py +89 -0
  41. forgecli/engine/events.py +185 -0
  42. forgecli/engine/execution.py +526 -0
  43. forgecli/engine/plugins.py +146 -0
  44. forgecli/engine/runner.py +155 -0
  45. forgecli/engine/stages/__init__.py +33 -0
  46. forgecli/engine/stages/caveman_optimizer.py +47 -0
  47. forgecli/engine/stages/context_optimizer.py +44 -0
  48. forgecli/engine/stages/execution_engine_stage.py +108 -0
  49. forgecli/engine/stages/git_engine.py +30 -0
  50. forgecli/engine/stages/intent_analyzer.py +38 -0
  51. forgecli/engine/stages/model_router.py +66 -0
  52. forgecli/engine/stages/planning_engine.py +45 -0
  53. forgecli/engine/stages/repository_analyzer.py +54 -0
  54. forgecli/engine/stages/validation_engine.py +89 -0
  55. forgecli/git/__init__.py +9 -0
  56. forgecli/git/repo.py +55 -0
  57. forgecli/git/service.py +45 -0
  58. forgecli/graph/__init__.py +56 -0
  59. forgecli/graph/backend_graphify.py +453 -0
  60. forgecli/graph/edge.py +19 -0
  61. forgecli/graph/graph.py +85 -0
  62. forgecli/graph/graphify.py +412 -0
  63. forgecli/graph/indexer.py +82 -0
  64. forgecli/graph/node.py +47 -0
  65. forgecli/graph/repository.py +164 -0
  66. forgecli/memory/__init__.py +12 -0
  67. forgecli/memory/cache.py +61 -0
  68. forgecli/memory/history.py +138 -0
  69. forgecli/memory/store.py +87 -0
  70. forgecli/optimizer/__init__.py +14 -0
  71. forgecli/optimizer/caveman/__init__.py +173 -0
  72. forgecli/optimizer/caveman/cli.py +64 -0
  73. forgecli/optimizer/caveman/decorator.py +67 -0
  74. forgecli/optimizer/caveman/factory.py +51 -0
  75. forgecli/optimizer/caveman/ruleset.py +156 -0
  76. forgecli/optimizer/caveman/state.py +52 -0
  77. forgecli/optimizer/chunker.py +85 -0
  78. forgecli/optimizer/optimizer.py +81 -0
  79. forgecli/optimizer/ponytail/__init__.py +183 -0
  80. forgecli/optimizer/ponytail/cli.py +168 -0
  81. forgecli/optimizer/ponytail/decorator.py +70 -0
  82. forgecli/optimizer/ponytail/factory.py +51 -0
  83. forgecli/optimizer/ponytail/ruleset.py +168 -0
  84. forgecli/optimizer/ponytail/state.py +53 -0
  85. forgecli/optimizer/ranker.py +37 -0
  86. forgecli/optimizer/summarizer.py +44 -0
  87. forgecli/orchestrator/__init__.py +707 -0
  88. forgecli/planner/__init__.py +56 -0
  89. forgecli/planner/agent.py +61 -0
  90. forgecli/planner/plan.py +65 -0
  91. forgecli/planner/planner.py +17 -0
  92. forgecli/planner/render.py +271 -0
  93. forgecli/planner/serialize.py +106 -0
  94. forgecli/planner/software.py +834 -0
  95. forgecli/platform/__init__.py +91 -0
  96. forgecli/platform/core.py +201 -0
  97. forgecli/platform/deps.py +354 -0
  98. forgecli/platform/paths.py +236 -0
  99. forgecli/platform/shell.py +176 -0
  100. forgecli/platform/update.py +252 -0
  101. forgecli/plugins/__init__.py +251 -0
  102. forgecli/prompts/__init__.py +11 -0
  103. forgecli/prompts/loader.py +28 -0
  104. forgecli/prompts/registry.py +32 -0
  105. forgecli/prompts/renderer.py +23 -0
  106. forgecli/providers/__init__.py +39 -0
  107. forgecli/providers/anthropic.py +206 -0
  108. forgecli/providers/base.py +206 -0
  109. forgecli/providers/builtin.py +26 -0
  110. forgecli/providers/conversation.py +78 -0
  111. forgecli/providers/google.py +295 -0
  112. forgecli/providers/http_base.py +211 -0
  113. forgecli/providers/mock.py +113 -0
  114. forgecli/providers/openai.py +202 -0
  115. forgecli/providers/openai_compatible.py +728 -0
  116. forgecli/providers/router.py +345 -0
  117. forgecli/providers/router_state.py +89 -0
  118. forgecli/review/__init__.py +45 -0
  119. forgecli/review/analyzer.py +124 -0
  120. forgecli/review/analyzers/__init__.py +1 -0
  121. forgecli/review/analyzers/architecture.py +258 -0
  122. forgecli/review/analyzers/complexity.py +167 -0
  123. forgecli/review/analyzers/dead_code.py +262 -0
  124. forgecli/review/analyzers/duplicates.py +163 -0
  125. forgecli/review/analyzers/performance.py +165 -0
  126. forgecli/review/analyzers/security.py +251 -0
  127. forgecli/review/finding.py +68 -0
  128. forgecli/review/report.py +311 -0
  129. forgecli/review/repository.py +131 -0
  130. forgecli/review/suggestions.py +98 -0
  131. forgecli/runtime/__init__.py +6 -0
  132. forgecli/runtime/cache_store.py +77 -0
  133. forgecli/runtime/prepare.py +199 -0
  134. forgecli/runtime/wrappers.py +152 -0
  135. forgecli/sdk/__init__.py +132 -0
  136. forgecli/sdk/events.py +206 -0
  137. forgecli/sdk/interfaces.py +282 -0
  138. forgecli/sdk/loader.py +243 -0
  139. forgecli/sdk/manager.py +693 -0
  140. forgecli/sdk/manifest.py +397 -0
  141. forgecli/sdk/sandbox.py +197 -0
  142. forgecli/sdk/version.py +316 -0
  143. forgecli/templates/__init__.py +9 -0
  144. forgecli/templates/engine.py +37 -0
  145. forgecli/templates/registry.py +32 -0
  146. forgecli/utils/__init__.py +19 -0
  147. forgecli/utils/fs.py +91 -0
  148. forgecli/utils/ids.py +11 -0
  149. forgecli/utils/io.py +27 -0
  150. forgecli/utils/paths.py +70 -0
  151. forgecli/utils/timing.py +25 -0
  152. forgeoptimizer-1.0.0.dist-info/METADATA +169 -0
  153. forgeoptimizer-1.0.0.dist-info/RECORD +156 -0
  154. forgeoptimizer-1.0.0.dist-info/WHEEL +4 -0
  155. forgeoptimizer-1.0.0.dist-info/entry_points.txt +2 -0
  156. 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)
@@ -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
+ }