forgeoptimizer 0.1.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 (159) 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 +149 -0
  19. forgecli/cli/commands_wrappers.py +80 -0
  20. forgecli/cli/daemon.py +744 -0
  21. forgecli/cli/main.py +234 -0
  22. forgecli/cli/ui.py +56 -0
  23. forgecli/config/__init__.py +28 -0
  24. forgecli/config/loader.py +85 -0
  25. forgecli/config/settings.py +206 -0
  26. forgecli/config/writer.py +90 -0
  27. forgecli/core/__init__.py +35 -0
  28. forgecli/core/container.py +68 -0
  29. forgecli/core/context.py +54 -0
  30. forgecli/core/credentials.py +114 -0
  31. forgecli/core/errors.py +27 -0
  32. forgecli/core/events.py +67 -0
  33. forgecli/core/logging.py +47 -0
  34. forgecli/core/models.py +214 -0
  35. forgecli/core/plugins.py +54 -0
  36. forgecli/core/service.py +22 -0
  37. forgecli/docs/__init__.py +5 -0
  38. forgecli/docs/generator.py +115 -0
  39. forgecli/engine/__init__.py +105 -0
  40. forgecli/engine/context.py +163 -0
  41. forgecli/engine/defaults.py +89 -0
  42. forgecli/engine/events.py +185 -0
  43. forgecli/engine/execution.py +519 -0
  44. forgecli/engine/plugins.py +145 -0
  45. forgecli/engine/runner.py +158 -0
  46. forgecli/engine/stages/__init__.py +33 -0
  47. forgecli/engine/stages/caveman_optimizer.py +47 -0
  48. forgecli/engine/stages/context_optimizer.py +44 -0
  49. forgecli/engine/stages/execution_engine_stage.py +102 -0
  50. forgecli/engine/stages/git_engine.py +30 -0
  51. forgecli/engine/stages/intent_analyzer.py +38 -0
  52. forgecli/engine/stages/model_router.py +65 -0
  53. forgecli/engine/stages/planning_engine.py +45 -0
  54. forgecli/engine/stages/repository_analyzer.py +54 -0
  55. forgecli/engine/stages/validation_engine.py +87 -0
  56. forgecli/git/__init__.py +9 -0
  57. forgecli/git/repo.py +55 -0
  58. forgecli/git/service.py +45 -0
  59. forgecli/graph/__init__.py +56 -0
  60. forgecli/graph/backend_graphify.py +448 -0
  61. forgecli/graph/edge.py +19 -0
  62. forgecli/graph/graph.py +85 -0
  63. forgecli/graph/graphify.py +405 -0
  64. forgecli/graph/indexer.py +82 -0
  65. forgecli/graph/node.py +47 -0
  66. forgecli/graph/repository.py +164 -0
  67. forgecli/memory/__init__.py +12 -0
  68. forgecli/memory/cache.py +61 -0
  69. forgecli/memory/history.py +136 -0
  70. forgecli/memory/store.py +85 -0
  71. forgecli/optimizer/__init__.py +13 -0
  72. forgecli/optimizer/caveman/__init__.py +169 -0
  73. forgecli/optimizer/caveman/cli.py +62 -0
  74. forgecli/optimizer/caveman/decorator.py +67 -0
  75. forgecli/optimizer/caveman/factory.py +51 -0
  76. forgecli/optimizer/caveman/ruleset.py +156 -0
  77. forgecli/optimizer/caveman/state.py +50 -0
  78. forgecli/optimizer/chunker.py +85 -0
  79. forgecli/optimizer/optimizer.py +81 -0
  80. forgecli/optimizer/ponytail/__init__.py +181 -0
  81. forgecli/optimizer/ponytail/cli.py +159 -0
  82. forgecli/optimizer/ponytail/decorator.py +70 -0
  83. forgecli/optimizer/ponytail/factory.py +51 -0
  84. forgecli/optimizer/ponytail/ruleset.py +168 -0
  85. forgecli/optimizer/ponytail/state.py +51 -0
  86. forgecli/optimizer/ranker.py +37 -0
  87. forgecli/optimizer/summarizer.py +44 -0
  88. forgecli/orchestrator/__init__.py +706 -0
  89. forgecli/planner/__init__.py +56 -0
  90. forgecli/planner/agent.py +61 -0
  91. forgecli/planner/plan.py +65 -0
  92. forgecli/planner/planner.py +17 -0
  93. forgecli/planner/render.py +267 -0
  94. forgecli/planner/serialize.py +104 -0
  95. forgecli/planner/software.py +832 -0
  96. forgecli/platform/__init__.py +91 -0
  97. forgecli/platform/core.py +201 -0
  98. forgecli/platform/deps.py +361 -0
  99. forgecli/platform/paths.py +234 -0
  100. forgecli/platform/shell.py +176 -0
  101. forgecli/platform/update.py +253 -0
  102. forgecli/plugins/__init__.py +249 -0
  103. forgecli/prompts/__init__.py +11 -0
  104. forgecli/prompts/loader.py +28 -0
  105. forgecli/prompts/registry.py +32 -0
  106. forgecli/prompts/renderer.py +23 -0
  107. forgecli/providers/__init__.py +39 -0
  108. forgecli/providers/anthropic.py +207 -0
  109. forgecli/providers/base.py +204 -0
  110. forgecli/providers/builtin.py +26 -0
  111. forgecli/providers/conversation.py +74 -0
  112. forgecli/providers/google.py +290 -0
  113. forgecli/providers/http_base.py +207 -0
  114. forgecli/providers/mock.py +111 -0
  115. forgecli/providers/openai.py +206 -0
  116. forgecli/providers/openai_compatible.py +787 -0
  117. forgecli/providers/router.py +340 -0
  118. forgecli/providers/router_state.py +89 -0
  119. forgecli/review/__init__.py +45 -0
  120. forgecli/review/analyzer.py +124 -0
  121. forgecli/review/analyzers/__init__.py +1 -0
  122. forgecli/review/analyzers/architecture.py +255 -0
  123. forgecli/review/analyzers/complexity.py +162 -0
  124. forgecli/review/analyzers/dead_code.py +255 -0
  125. forgecli/review/analyzers/duplicates.py +161 -0
  126. forgecli/review/analyzers/performance.py +161 -0
  127. forgecli/review/analyzers/security.py +244 -0
  128. forgecli/review/finding.py +68 -0
  129. forgecli/review/report.py +321 -0
  130. forgecli/review/repository.py +130 -0
  131. forgecli/review/suggestions.py +98 -0
  132. forgecli/runtime/__init__.py +6 -0
  133. forgecli/runtime/cache_store.py +75 -0
  134. forgecli/runtime/mcp_config.py +118 -0
  135. forgecli/runtime/prepare.py +203 -0
  136. forgecli/runtime/wrappers.py +150 -0
  137. forgecli/sdk/__init__.py +132 -0
  138. forgecli/sdk/events.py +206 -0
  139. forgecli/sdk/interfaces.py +282 -0
  140. forgecli/sdk/loader.py +239 -0
  141. forgecli/sdk/manager.py +678 -0
  142. forgecli/sdk/manifest.py +395 -0
  143. forgecli/sdk/sandbox.py +247 -0
  144. forgecli/sdk/version.py +313 -0
  145. forgecli/templates/__init__.py +9 -0
  146. forgecli/templates/engine.py +37 -0
  147. forgecli/templates/registry.py +32 -0
  148. forgecli/utils/__init__.py +19 -0
  149. forgecli/utils/fs.py +121 -0
  150. forgecli/utils/ids.py +11 -0
  151. forgecli/utils/io.py +27 -0
  152. forgecli/utils/paths.py +78 -0
  153. forgecli/utils/stats.py +179 -0
  154. forgecli/utils/timing.py +25 -0
  155. forgeoptimizer-0.1.0.dist-info/METADATA +177 -0
  156. forgeoptimizer-0.1.0.dist-info/RECORD +159 -0
  157. forgeoptimizer-0.1.0.dist-info/WHEEL +4 -0
  158. forgeoptimizer-0.1.0.dist-info/entry_points.txt +2 -0
  159. forgeoptimizer-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,87 @@
1
+ """Stage 7 — Validation Engine.
2
+
3
+ Applies the extracted diff to disk, runs tests, and produces a
4
+ summary of the results. Combines :func:`forgecli.build.apply.apply_diff`,
5
+ :func:`forgecli.build.test_run.run_tests`, and
6
+ :func:`forgecli.build.summarize.summarize` into a single stage.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ from forgecli.build import BuildContext
14
+ from forgecli.build.apply import apply_diff
15
+ from forgecli.build.summarize import summarize
16
+ from forgecli.build.test_run import run_tests
17
+ from forgecli.engine.execution import StageContext, StageResult, StageStatus
18
+
19
+
20
+ class ValidationEngineStage:
21
+ """Apply the diff, run tests, and produce a summary."""
22
+
23
+ name = "validation-engine"
24
+
25
+ def __init__(self, test_command: str | None = None) -> None:
26
+ self._test_command = test_command
27
+
28
+ async def __call__(self, context: StageContext) -> StageResult:
29
+ decision = context.engine.extras.get("decision")
30
+ build_ctx = BuildContext(
31
+ prompt=context.engine.prompt,
32
+ root=Path(context.engine.cwd),
33
+ decision=decision,
34
+ )
35
+ build_ctx.diff_text = context.engine.diff_text
36
+ if context.engine.retrieval is not None:
37
+ build_ctx.retrieval = context.engine.retrieval.context_text
38
+
39
+ test_command = self._test_command or context.engine.extras.get("test_command")
40
+ if test_command:
41
+ build_ctx.extras["test_command"] = test_command
42
+ test_timeout = context.engine.extras.get("test_timeout")
43
+ if test_timeout:
44
+ build_ctx.extras["test_timeout"] = test_timeout
45
+
46
+ build_ctx = await apply_diff(build_ctx)
47
+ build_ctx = await run_tests(build_ctx)
48
+ build_ctx = await summarize(build_ctx)
49
+
50
+ context.engine.applied_files = build_ctx.applied_files
51
+ context.engine.diff_text = build_ctx.diff_text
52
+ context.engine.test_stdout = build_ctx.test_stdout
53
+ context.engine.test_stderr = build_ctx.test_stderr
54
+ context.engine.test_returncode = build_ctx.test_returncode
55
+ context.engine.extras["summary"] = build_ctx.summary
56
+
57
+ notes: list[str] = []
58
+ if build_ctx.applied_files:
59
+ notes.append(f"applied {len(build_ctx.applied_files)} files")
60
+ else:
61
+ notes.append("no files applied")
62
+
63
+ if build_ctx.test_returncode is None:
64
+ notes.append("tests skipped")
65
+ elif build_ctx.test_returncode == 0:
66
+ notes.append("tests passed")
67
+ else:
68
+ notes.append(f"tests FAILED (exit {build_ctx.test_returncode})")
69
+ return StageResult(
70
+ status=StageStatus.FAILED,
71
+ data={
72
+ "applied_files": [str(p) for p in build_ctx.applied_files],
73
+ "test_returncode": build_ctx.test_returncode,
74
+ "test_stderr": build_ctx.test_stderr[-500:],
75
+ },
76
+ notes=tuple(notes),
77
+ error=f"tests failed with exit code {build_ctx.test_returncode}",
78
+ )
79
+
80
+ return StageResult(
81
+ status=StageStatus.SUCCEEDED,
82
+ data={
83
+ "applied_files": [str(p) for p in build_ctx.applied_files],
84
+ "test_returncode": build_ctx.test_returncode,
85
+ },
86
+ notes=tuple(notes),
87
+ )
@@ -0,0 +1,9 @@
1
+ """Git automation layer built on top of GitPython."""
2
+
3
+ from forgecli.git.repo import GitRepo
4
+ from forgecli.git.service import GitService
5
+
6
+ __all__ = [
7
+ "GitRepo",
8
+ "GitService",
9
+ ]
forgecli/git/repo.py ADDED
@@ -0,0 +1,55 @@
1
+ """Thin wrapper around GitPython's :class:`Repo`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from git import Repo as _PyRepo
9
+ from git.exc import GitError as _PyGitError
10
+
11
+ from forgecli.core.errors import GitError
12
+
13
+
14
+ class GitRepo:
15
+ """Wrapper around GitPython's :class:`Repo` with typed errors."""
16
+
17
+ def __init__(self, path: str | Path) -> None:
18
+ self._path = Path(path)
19
+ try:
20
+ self._repo = _PyRepo(str(self._path))
21
+ except _PyGitError as exc:
22
+ raise GitError(f"Not a git repository: {self._path}") from exc
23
+ except Exception as exc:
24
+ raise GitError(f"Failed to open repository at {self._path}: {exc}") from exc
25
+
26
+ @property
27
+ def path(self) -> Path:
28
+ return self._path
29
+
30
+ @property
31
+ def raw(self) -> _PyRepo:
32
+ return self._repo
33
+
34
+ @property
35
+ def head(self) -> str:
36
+ try:
37
+ return str(self._repo.head.commit)
38
+ except _PyGitError as exc:
39
+ raise GitError(f"Unable to read HEAD: {exc}") from exc
40
+
41
+ def is_clean(self) -> bool:
42
+ return not self._repo.is_dirty(untracked_files=True)
43
+
44
+ def status(self) -> dict[str, Any]:
45
+ """Return a small status summary; placeholder for richer UX."""
46
+ return {
47
+ "branch": self._safe_branch(),
48
+ "clean": self.is_clean(),
49
+ }
50
+
51
+ def _safe_branch(self) -> str:
52
+ try:
53
+ return self._repo.active_branch.name
54
+ except Exception:
55
+ return "detached"
@@ -0,0 +1,45 @@
1
+ """High-level git operations: stage, branch, push, diff."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from forgecli.core.errors import GitError
10
+ from forgecli.core.service import Service
11
+ from forgecli.git.repo import GitRepo
12
+
13
+
14
+ class GitService(Service):
15
+ """Convenience operations built on top of :class:`GitRepo`."""
16
+
17
+ name = "git.service"
18
+
19
+ def __init__(
20
+ self,
21
+ repo: GitRepo,
22
+ *,
23
+ default_branch: str = "main",
24
+ ) -> None:
25
+ super().__init__()
26
+ self._repo = repo
27
+ self._default_branch = default_branch
28
+
29
+ @property
30
+ def repo(self) -> GitRepo:
31
+ return self._repo
32
+
33
+ def stage(self, paths: Iterable[str | Path]) -> None:
34
+ """Add ``paths`` to the index."""
35
+ try:
36
+ self._repo.raw.index.add([str(p) for p in paths])
37
+ except Exception as exc:
38
+ raise GitError(f"Failed to stage files: {exc}") from exc
39
+
40
+ def current_branch(self) -> str:
41
+ return self._repo._safe_branch()
42
+
43
+ def diff(self, *args: Any) -> str:
44
+ """Return ``git diff`` output; placeholder."""
45
+ raise NotImplementedError("GitService.diff() is a scaffold placeholder")
@@ -0,0 +1,56 @@
1
+ """Repository graph: indexing, parsing, code intelligence.
2
+
3
+ The package exposes two layers:
4
+
5
+ * A small in-memory graph (:class:`CodeGraph`, :class:`Node`, :class:`Edge`)
6
+ used by lightweight callers and tests.
7
+ * A back-end abstraction (:class:`RepositoryGraph`) implemented by
8
+ :class:`GraphifyRepositoryGraph`, which delegates to the external
9
+ Graphify CLI.
10
+ """
11
+
12
+ from forgecli.graph.backend_graphify import GraphifyRepositoryGraph
13
+ from forgecli.graph.edge import Edge, EdgeKind
14
+ from forgecli.graph.graph import CodeGraph
15
+ from forgecli.graph.graphify import (
16
+ GraphifyArtifacts,
17
+ GraphifyBuildOutcome,
18
+ GraphifyClient,
19
+ GraphifyInvocationError,
20
+ GraphifyNotFoundError,
21
+ )
22
+ from forgecli.graph.indexer import Indexer
23
+ from forgecli.graph.node import Node, NodeKind
24
+ from forgecli.graph.repository import (
25
+ BuildResult,
26
+ ExplainResult,
27
+ GraphCommunity,
28
+ GraphEdge,
29
+ GraphNode,
30
+ GraphSnapshot,
31
+ QueryResult,
32
+ RepositoryGraph,
33
+ )
34
+
35
+ __all__ = [
36
+ "BuildResult",
37
+ "CodeGraph",
38
+ "Edge",
39
+ "EdgeKind",
40
+ "ExplainResult",
41
+ "GraphCommunity",
42
+ "GraphEdge",
43
+ "GraphNode",
44
+ "GraphSnapshot",
45
+ "GraphifyArtifacts",
46
+ "GraphifyBuildOutcome",
47
+ "GraphifyClient",
48
+ "GraphifyInvocationError",
49
+ "GraphifyNotFoundError",
50
+ "GraphifyRepositoryGraph",
51
+ "Indexer",
52
+ "Node",
53
+ "NodeKind",
54
+ "QueryResult",
55
+ "RepositoryGraph",
56
+ ]
@@ -0,0 +1,448 @@
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(self._root, a, b, graph_path=self._artifacts.graph_json)
202
+ return edges
203
+
204
+ async def affected(
205
+ self,
206
+ target: str,
207
+ *,
208
+ relation: Iterable[str] | None = None,
209
+ depth: int = 2,
210
+ ) -> list[GraphEdge]:
211
+ snapshot = await self._ensure_loaded()
212
+ ids = _resolve_label(target, snapshot)
213
+ if not ids:
214
+ return []
215
+ # Always call Graphify for the authoritative traversal text.
216
+ await self._client.affected(
217
+ self._root,
218
+ target,
219
+ relation=relation,
220
+ depth=depth,
221
+ graph_path=self._artifacts.graph_json,
222
+ )
223
+ return _reverse_reachable(snapshot, ids[0], depth=depth, relation=relation)
224
+
225
+ # ------------------------------------------------------------------
226
+ # Snapshot construction
227
+ # ------------------------------------------------------------------
228
+
229
+ async def _ensure_loaded(self) -> GraphSnapshot:
230
+ if self._cached is not None:
231
+ return self._cached
232
+ if self._artifacts.graph_json.exists():
233
+ payload = self._client.load_graph(self._artifacts.graph_json)
234
+ snapshot = self._snapshot_from_payload(payload)
235
+ self._cached = snapshot
236
+ return snapshot
237
+ raise ConfigError(
238
+ f"No graph.json found at {self._artifacts.graph_json}. Run `forge graph build` first."
239
+ )
240
+
241
+ def _snapshot_from_payload(self, payload: dict[str, Any]) -> GraphSnapshot:
242
+ nodes: list[GraphNode] = []
243
+ for raw in payload.get("nodes", []):
244
+ nodes.append(
245
+ GraphNode(
246
+ id=str(raw.get("id", raw.get("label", ""))),
247
+ label=str(raw.get("label", "")),
248
+ file_type=raw.get("file_type"),
249
+ source_file=raw.get("source_file"),
250
+ source_location=raw.get("source_location"),
251
+ community=raw.get("community"),
252
+ norm_label=raw.get("norm_label"),
253
+ extra={
254
+ k: v
255
+ for k, v in raw.items()
256
+ if k
257
+ not in {
258
+ "id",
259
+ "label",
260
+ "file_type",
261
+ "source_file",
262
+ "source_location",
263
+ "community",
264
+ "norm_label",
265
+ }
266
+ },
267
+ )
268
+ )
269
+
270
+ edges: list[GraphEdge] = []
271
+ for raw in payload.get("links", []):
272
+ edges.append(
273
+ GraphEdge(
274
+ source=str(raw.get("source", "")),
275
+ target=str(raw.get("target", "")),
276
+ relation=str(raw.get("relation", "")),
277
+ confidence=raw.get("confidence"),
278
+ confidence_score=raw.get("confidence_score"),
279
+ source_file=raw.get("source_file"),
280
+ source_location=raw.get("source_location"),
281
+ weight=float(raw.get("weight", 1.0) or 1.0),
282
+ extra={
283
+ k: v
284
+ for k, v in raw.items()
285
+ if k
286
+ not in {
287
+ "source",
288
+ "target",
289
+ "relation",
290
+ "confidence",
291
+ "confidence_score",
292
+ "source_file",
293
+ "source_location",
294
+ "weight",
295
+ }
296
+ },
297
+ )
298
+ )
299
+
300
+ communities = _build_communities(nodes, edges, payload)
301
+
302
+ return GraphSnapshot(
303
+ root=str(self._root),
304
+ nodes=tuple(nodes),
305
+ edges=tuple(edges),
306
+ communities=tuple(communities),
307
+ directed=bool(payload.get("directed", False)),
308
+ multigraph=bool(payload.get("multigraph", False)),
309
+ metadata={"hyperedges": payload.get("hyperedges", [])},
310
+ )
311
+
312
+
313
+ # ---------------------------------------------------------------------------
314
+ # Helpers
315
+ # ---------------------------------------------------------------------------
316
+
317
+
318
+ def _build_communities(
319
+ nodes: list[GraphNode],
320
+ edges: list[GraphEdge],
321
+ payload: dict[str, Any],
322
+ ) -> list[GraphCommunity]:
323
+ """Group nodes by their ``community`` id; preserve numeric ordering."""
324
+ if not nodes:
325
+ return []
326
+ counter: Counter[int] = Counter()
327
+ members: dict[int, list[str]] = defaultdict(list)
328
+ for node in nodes:
329
+ if node.community is None:
330
+ continue
331
+ counter[node.community] += 1
332
+ members[node.community].append(node.id)
333
+ labels = payload.get("community_labels") or {}
334
+ communities: list[GraphCommunity] = []
335
+ for cid in sorted(counter):
336
+ communities.append(
337
+ GraphCommunity(
338
+ id=cid,
339
+ size=counter[cid],
340
+ label=labels.get(str(cid)) or labels.get(cid),
341
+ members=tuple(members[cid]),
342
+ )
343
+ )
344
+ return communities
345
+
346
+
347
+ def _resolve_label(label_or_id: str, snapshot: GraphSnapshot) -> list[str]:
348
+ """Return node ids matching ``label_or_id`` by id, label, or norm_label."""
349
+ needle = label_or_id.strip()
350
+ if not needle:
351
+ return []
352
+ ids: list[str] = []
353
+ for node in snapshot.nodes:
354
+ if needle == node.id or needle == node.label or needle == (node.norm_label or ""):
355
+ ids.append(node.id)
356
+ return ids
357
+
358
+
359
+ def _bfs_shortest_path(snapshot: GraphSnapshot, src: str, dst: str) -> list[GraphEdge]:
360
+ """Breadth-first shortest path over undirected edges."""
361
+ if src == dst:
362
+ return []
363
+ adj: dict[str, list[tuple[str, GraphEdge]]] = defaultdict(list)
364
+ for edge in snapshot.edges:
365
+ adj[edge.source].append((edge.target, edge))
366
+ adj[edge.target].append((edge.source, edge))
367
+ visited = {src}
368
+ queue: list[tuple[str, list[GraphEdge]]] = [(src, [])]
369
+ while queue:
370
+ node, path = queue.pop(0)
371
+ for nxt, edge in adj.get(node, ()):
372
+ if nxt in visited:
373
+ continue
374
+ new_path = [*path, edge]
375
+ if nxt == dst:
376
+ return new_path
377
+ visited.add(nxt)
378
+ queue.append((nxt, new_path))
379
+ return []
380
+
381
+
382
+ def _reverse_reachable(
383
+ snapshot: GraphSnapshot,
384
+ start: str,
385
+ *,
386
+ depth: int,
387
+ relation: Iterable[str] | None,
388
+ ) -> list[GraphEdge]:
389
+ """Return edges traversed when walking *into* ``start`` up to ``depth``."""
390
+ rels = set(relation) if relation else None
391
+ out: list[GraphEdge] = []
392
+ seen: set[tuple[str, str, str]] = set()
393
+ current = {start}
394
+ for _ in range(max(depth, 0)):
395
+ next_layer: set[str] = set()
396
+ for edge in snapshot.edges:
397
+ if edge.target in current and (rels is None or edge.relation in rels):
398
+ key = (edge.source, edge.target, edge.relation)
399
+ if key in seen:
400
+ continue
401
+ seen.add(key)
402
+ out.append(edge)
403
+ next_layer.add(edge.source)
404
+ current = next_layer
405
+ if not current:
406
+ break
407
+ return out
408
+
409
+
410
+ def _related_nodes(target: str, snapshot: GraphSnapshot) -> list[GraphNode]:
411
+ """Return nodes within one hop of any id matching ``target``."""
412
+ ids = set(_resolve_label(target, snapshot))
413
+ if not ids:
414
+ return []
415
+ related: list[GraphNode] = []
416
+ seen: set[str] = set()
417
+ for edge in snapshot.edges:
418
+ if edge.source in ids and edge.target not in seen:
419
+ node = snapshot.node(edge.target)
420
+ if node is not None:
421
+ related.append(node)
422
+ seen.add(node.id)
423
+ elif edge.target in ids and edge.source not in seen:
424
+ node = snapshot.node(edge.source)
425
+ if node is not None:
426
+ related.append(node)
427
+ seen.add(node.id)
428
+ return related
429
+
430
+
431
+ def _extract_cited_nodes(answer: str, snapshot: GraphSnapshot) -> list[str]:
432
+ """Pull node ids that look like ``name.py`` or symbol names from ``answer``."""
433
+ cited: list[str] = []
434
+ seen: set[str] = set()
435
+ if not answer:
436
+ return cited
437
+ labels = {node.label for node in snapshot.nodes}
438
+ ids = {node.id for node in snapshot.nodes}
439
+ for token in _CITED_NODE_RE.findall(answer):
440
+ if token in seen:
441
+ continue
442
+ if token in labels or token in ids:
443
+ cited.append(token)
444
+ seen.add(token)
445
+ return cited
446
+
447
+
448
+ __all__ = ["GraphifyNotFoundError", "GraphifyRepositoryGraph"]