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,412 @@
|
|
|
1
|
+
"""Thin wrapper around the Graphify CLI.
|
|
2
|
+
|
|
3
|
+
Graphify is an external tool installed as ``graphify`` on the user's PATH
|
|
4
|
+
(typically via ``uv tool install graphifyy``). This module:
|
|
5
|
+
|
|
6
|
+
* detects whether ``graphify`` is installed;
|
|
7
|
+
* invokes it as an async subprocess (never imports its Python package);
|
|
8
|
+
* parses the resulting ``graph.json`` and ``manifest.json`` into typed
|
|
9
|
+
dataclasses that satisfy the :mod:`forgecli.graph.repository` interface.
|
|
10
|
+
|
|
11
|
+
The CLI surface we use is intentionally small:
|
|
12
|
+
|
|
13
|
+
* ``graphify .`` - default full extraction (alias for ``extract``)
|
|
14
|
+
* ``graphify extract`` - same thing, with extra flags
|
|
15
|
+
* ``graphify query`` - free-form BFS/DFS question
|
|
16
|
+
* ``graphify explain`` - plain-language explanation
|
|
17
|
+
* ``graphify path`` - shortest path between two nodes
|
|
18
|
+
* ``graphify affected`` - reverse-traversal blast radius
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import shutil
|
|
27
|
+
from collections.abc import Iterable
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from forgecli.core.errors import ForgeCLIError
|
|
33
|
+
|
|
34
|
+
DEFAULT_OUTPUT_DIR = "graphify-out"
|
|
35
|
+
DEFAULT_GRAPH_FILE = "graph.json"
|
|
36
|
+
DEFAULT_MANIFEST_FILE = "manifest.json"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class GraphifyNotFoundError(ForgeCLIError):
|
|
40
|
+
"""Raised when the ``graphify`` executable is not on the user's PATH."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class GraphifyInvocationError(ForgeCLIError):
|
|
44
|
+
"""Raised when the ``graphify`` subprocess exits with a non-zero status."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class GraphifyArtifacts:
|
|
49
|
+
"""Filesystem locations of the artifacts produced by Graphify."""
|
|
50
|
+
|
|
51
|
+
root: Path
|
|
52
|
+
output_dir: Path
|
|
53
|
+
graph_json: Path
|
|
54
|
+
manifest_json: Path
|
|
55
|
+
graph_html: Path
|
|
56
|
+
graph_report: Path
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def for_root(cls, root: Path) -> GraphifyArtifacts:
|
|
60
|
+
out = root / DEFAULT_OUTPUT_DIR
|
|
61
|
+
return cls(
|
|
62
|
+
root=root,
|
|
63
|
+
output_dir=out,
|
|
64
|
+
graph_json=out / DEFAULT_GRAPH_FILE,
|
|
65
|
+
manifest_json=out / DEFAULT_MANIFEST_FILE,
|
|
66
|
+
graph_html=out / "graph.html",
|
|
67
|
+
graph_report=out / "GRAPH_REPORT.md",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class GraphifyClient:
|
|
72
|
+
"""Async subprocess wrapper around the ``graphify`` CLI.
|
|
73
|
+
|
|
74
|
+
Instances are cheap to construct; they do not touch the filesystem
|
|
75
|
+
until :meth:`detect`, :meth:`build`, :meth:`query`, etc. are called.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self,
|
|
80
|
+
*,
|
|
81
|
+
executable: str | None = None,
|
|
82
|
+
timeout: float = 600.0,
|
|
83
|
+
) -> None:
|
|
84
|
+
self._executable = executable or os.environ.get("FORGECLI_GRAPHIFY_BIN", "graphify")
|
|
85
|
+
self._timeout = timeout
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def executable(self) -> str:
|
|
89
|
+
return self._executable
|
|
90
|
+
|
|
91
|
+
# ------------------------------------------------------------------
|
|
92
|
+
# Detection
|
|
93
|
+
# ------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
async def detect(self) -> str | None:
|
|
96
|
+
"""Return the resolved path of the ``graphify`` binary, or ``None``."""
|
|
97
|
+
path = shutil.which(self._executable)
|
|
98
|
+
return path
|
|
99
|
+
|
|
100
|
+
async def is_installed(self) -> bool:
|
|
101
|
+
"""Return True if ``graphify`` is on the user's PATH."""
|
|
102
|
+
return await self.detect() is not None
|
|
103
|
+
|
|
104
|
+
async def version(self) -> str:
|
|
105
|
+
"""Return the version string reported by ``graphify --version``."""
|
|
106
|
+
binary = await self.detect()
|
|
107
|
+
if binary is None:
|
|
108
|
+
raise GraphifyNotFoundError(
|
|
109
|
+
f"Graphify executable {self._executable!r} not found on PATH"
|
|
110
|
+
)
|
|
111
|
+
proc = await asyncio.create_subprocess_exec(
|
|
112
|
+
binary,
|
|
113
|
+
"--version",
|
|
114
|
+
stdout=asyncio.subprocess.PIPE,
|
|
115
|
+
stderr=asyncio.subprocess.PIPE,
|
|
116
|
+
)
|
|
117
|
+
stdout, stderr = await proc.communicate()
|
|
118
|
+
if proc.returncode != 0: # pragma: no cover - graphify exits 0 on --version
|
|
119
|
+
raise GraphifyInvocationError(
|
|
120
|
+
f"graphify --version failed: {stderr.decode(errors='replace').strip()}"
|
|
121
|
+
)
|
|
122
|
+
return stdout.decode(errors="replace").strip() or "unknown"
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
# Build / update
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
async def build(
|
|
129
|
+
self,
|
|
130
|
+
root: Path,
|
|
131
|
+
*,
|
|
132
|
+
force: bool = False,
|
|
133
|
+
no_cluster: bool = False,
|
|
134
|
+
extra_args: Iterable[str] = (),
|
|
135
|
+
) -> GraphifyBuildOutcome:
|
|
136
|
+
"""Run ``graphify extract <root>`` and return the parsed outcome.
|
|
137
|
+
|
|
138
|
+
The default subcommand (``graphify .``) is a thin alias for
|
|
139
|
+
``extract``; we use ``extract`` explicitly so we can pass flags
|
|
140
|
+
like ``--no-cluster`` and ``--force`` deterministically.
|
|
141
|
+
"""
|
|
142
|
+
binary = await self.detect()
|
|
143
|
+
if binary is None:
|
|
144
|
+
raise GraphifyNotFoundError(
|
|
145
|
+
f"Graphify executable {self._executable!r} not found on PATH"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
root = root.resolve()
|
|
149
|
+
args: list[str] = [
|
|
150
|
+
binary,
|
|
151
|
+
"extract",
|
|
152
|
+
str(root),
|
|
153
|
+
"--out",
|
|
154
|
+
str(root),
|
|
155
|
+
]
|
|
156
|
+
if force:
|
|
157
|
+
args.append("--force")
|
|
158
|
+
if no_cluster:
|
|
159
|
+
args.append("--no-cluster")
|
|
160
|
+
args.extend(extra_args)
|
|
161
|
+
|
|
162
|
+
proc = await asyncio.create_subprocess_exec(
|
|
163
|
+
*args,
|
|
164
|
+
stdout=asyncio.subprocess.PIPE,
|
|
165
|
+
stderr=asyncio.subprocess.PIPE,
|
|
166
|
+
cwd=str(root),
|
|
167
|
+
)
|
|
168
|
+
try:
|
|
169
|
+
stdout, stderr = await asyncio.wait_for(
|
|
170
|
+
proc.communicate(), timeout=self._timeout
|
|
171
|
+
)
|
|
172
|
+
except TimeoutError as exc:
|
|
173
|
+
proc.kill()
|
|
174
|
+
raise GraphifyInvocationError(
|
|
175
|
+
f"graphify extract timed out after {self._timeout}s"
|
|
176
|
+
) from exc
|
|
177
|
+
|
|
178
|
+
if proc.returncode != 0:
|
|
179
|
+
raise GraphifyInvocationError(
|
|
180
|
+
"graphify extract failed (exit "
|
|
181
|
+
f"{proc.returncode}):\n{stderr.decode(errors='replace').strip()}"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
artifacts = GraphifyArtifacts.for_root(root)
|
|
185
|
+
if not no_cluster:
|
|
186
|
+
await self.cluster_only(root)
|
|
187
|
+
|
|
188
|
+
return GraphifyBuildOutcome(
|
|
189
|
+
root=root,
|
|
190
|
+
artifacts=artifacts,
|
|
191
|
+
stdout=stdout.decode(errors="replace"),
|
|
192
|
+
stderr=stderr.decode(errors="replace"),
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
async def update(
|
|
196
|
+
self,
|
|
197
|
+
root: Path,
|
|
198
|
+
*,
|
|
199
|
+
force: bool = False,
|
|
200
|
+
no_cluster: bool = False,
|
|
201
|
+
) -> GraphifyBuildOutcome:
|
|
202
|
+
"""Run ``graphify update <root>`` and return the parsed outcome."""
|
|
203
|
+
binary = await self.detect()
|
|
204
|
+
if binary is None:
|
|
205
|
+
raise GraphifyNotFoundError(
|
|
206
|
+
f"Graphify executable {self._executable!r} not found on PATH"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
root = root.resolve()
|
|
210
|
+
args: list[str] = [
|
|
211
|
+
binary,
|
|
212
|
+
"update",
|
|
213
|
+
str(root),
|
|
214
|
+
]
|
|
215
|
+
if force:
|
|
216
|
+
args.append("--force")
|
|
217
|
+
if no_cluster:
|
|
218
|
+
args.append("--no-cluster")
|
|
219
|
+
|
|
220
|
+
proc = await asyncio.create_subprocess_exec(
|
|
221
|
+
*args,
|
|
222
|
+
stdout=asyncio.subprocess.PIPE,
|
|
223
|
+
stderr=asyncio.subprocess.PIPE,
|
|
224
|
+
cwd=str(root),
|
|
225
|
+
)
|
|
226
|
+
try:
|
|
227
|
+
stdout, stderr = await asyncio.wait_for(
|
|
228
|
+
proc.communicate(), timeout=self._timeout
|
|
229
|
+
)
|
|
230
|
+
except TimeoutError as exc:
|
|
231
|
+
proc.kill()
|
|
232
|
+
raise GraphifyInvocationError(
|
|
233
|
+
f"graphify update timed out after {self._timeout}s"
|
|
234
|
+
) from exc
|
|
235
|
+
|
|
236
|
+
if proc.returncode != 0:
|
|
237
|
+
raise GraphifyInvocationError(
|
|
238
|
+
"graphify update failed (exit "
|
|
239
|
+
f"{proc.returncode}):\n{stderr.decode(errors='replace').strip()}"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
artifacts = GraphifyArtifacts.for_root(root)
|
|
243
|
+
return GraphifyBuildOutcome(
|
|
244
|
+
root=root,
|
|
245
|
+
artifacts=artifacts,
|
|
246
|
+
stdout=stdout.decode(errors="replace"),
|
|
247
|
+
stderr=stderr.decode(errors="replace"),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
async def cluster_only(
|
|
251
|
+
self,
|
|
252
|
+
root: Path,
|
|
253
|
+
*,
|
|
254
|
+
backend: str | None = None,
|
|
255
|
+
) -> str:
|
|
256
|
+
"""Run ``graphify cluster-only <root>`` to update HTML viz and Graph Report."""
|
|
257
|
+
args: list[str] = ["cluster-only", str(root.resolve())]
|
|
258
|
+
if backend:
|
|
259
|
+
args.append(f"--backend={backend}")
|
|
260
|
+
return await self._run_capture(root, args)
|
|
261
|
+
|
|
262
|
+
# ------------------------------------------------------------------
|
|
263
|
+
# JSON parsers
|
|
264
|
+
# ------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
@staticmethod
|
|
267
|
+
def load_graph(path: Path) -> dict[str, Any]:
|
|
268
|
+
"""Read and return the raw ``graph.json`` payload."""
|
|
269
|
+
if not path.exists():
|
|
270
|
+
raise FileNotFoundError(f"graph.json not found at {path}")
|
|
271
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
272
|
+
return json.load(fh)
|
|
273
|
+
|
|
274
|
+
@staticmethod
|
|
275
|
+
def load_manifest(path: Path) -> dict[str, Any]:
|
|
276
|
+
"""Read and return the raw ``manifest.json`` payload (may be missing)."""
|
|
277
|
+
if not path.exists():
|
|
278
|
+
return {}
|
|
279
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
280
|
+
return json.load(fh)
|
|
281
|
+
|
|
282
|
+
# ------------------------------------------------------------------
|
|
283
|
+
# Query / explain / path / affected
|
|
284
|
+
# ------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
async def _run_capture(
|
|
287
|
+
self,
|
|
288
|
+
root: Path,
|
|
289
|
+
args: list[str],
|
|
290
|
+
*,
|
|
291
|
+
timeout: float | None = None,
|
|
292
|
+
) -> str:
|
|
293
|
+
binary = await self.detect()
|
|
294
|
+
if binary is None:
|
|
295
|
+
raise GraphifyNotFoundError(
|
|
296
|
+
f"Graphify executable {self._executable!r} not found on PATH"
|
|
297
|
+
)
|
|
298
|
+
proc = await asyncio.create_subprocess_exec(
|
|
299
|
+
binary,
|
|
300
|
+
*args,
|
|
301
|
+
stdout=asyncio.subprocess.PIPE,
|
|
302
|
+
stderr=asyncio.subprocess.PIPE,
|
|
303
|
+
cwd=str(root),
|
|
304
|
+
)
|
|
305
|
+
try:
|
|
306
|
+
stdout, stderr = await asyncio.wait_for(
|
|
307
|
+
proc.communicate(), timeout=timeout or self._timeout
|
|
308
|
+
)
|
|
309
|
+
except TimeoutError as exc:
|
|
310
|
+
proc.kill()
|
|
311
|
+
raise GraphifyInvocationError(
|
|
312
|
+
f"graphify {' '.join(args[:1])} timed out"
|
|
313
|
+
) from exc
|
|
314
|
+
if proc.returncode != 0:
|
|
315
|
+
raise GraphifyInvocationError(
|
|
316
|
+
f"graphify exited with {proc.returncode}: "
|
|
317
|
+
f"{stderr.decode(errors='replace').strip()}"
|
|
318
|
+
)
|
|
319
|
+
return stdout.decode(errors="replace")
|
|
320
|
+
|
|
321
|
+
async def query(
|
|
322
|
+
self,
|
|
323
|
+
root: Path,
|
|
324
|
+
question: str,
|
|
325
|
+
*,
|
|
326
|
+
budget: int = 2000,
|
|
327
|
+
graph_path: Path | None = None,
|
|
328
|
+
dfs: bool = False,
|
|
329
|
+
) -> str:
|
|
330
|
+
"""Run ``graphify query "<question>"`` and return its stdout text."""
|
|
331
|
+
args: list[str] = ["query", question, "--budget", str(budget)]
|
|
332
|
+
if graph_path is not None:
|
|
333
|
+
args += ["--graph", str(graph_path)]
|
|
334
|
+
if dfs:
|
|
335
|
+
args.append("--dfs")
|
|
336
|
+
return await self._run_capture(root, args)
|
|
337
|
+
|
|
338
|
+
async def explain(
|
|
339
|
+
self,
|
|
340
|
+
root: Path,
|
|
341
|
+
target: str,
|
|
342
|
+
*,
|
|
343
|
+
graph_path: Path | None = None,
|
|
344
|
+
) -> str:
|
|
345
|
+
"""Run ``graphify explain "<target>"`` and return its stdout text."""
|
|
346
|
+
args: list[str] = ["explain", target]
|
|
347
|
+
if graph_path is not None:
|
|
348
|
+
args += ["--graph", str(graph_path)]
|
|
349
|
+
return await self._run_capture(root, args)
|
|
350
|
+
|
|
351
|
+
async def path(
|
|
352
|
+
self,
|
|
353
|
+
root: Path,
|
|
354
|
+
a: str,
|
|
355
|
+
b: str,
|
|
356
|
+
*,
|
|
357
|
+
graph_path: Path | None = None,
|
|
358
|
+
) -> str:
|
|
359
|
+
"""Run ``graphify path "<a>" "<b>"`` and return its stdout text."""
|
|
360
|
+
args: list[str] = ["path", a, b]
|
|
361
|
+
if graph_path is not None:
|
|
362
|
+
args += ["--graph", str(graph_path)]
|
|
363
|
+
return await self._run_capture(root, args)
|
|
364
|
+
|
|
365
|
+
async def affected(
|
|
366
|
+
self,
|
|
367
|
+
root: Path,
|
|
368
|
+
target: str,
|
|
369
|
+
*,
|
|
370
|
+
relation: Iterable[str] | None = None,
|
|
371
|
+
depth: int = 2,
|
|
372
|
+
graph_path: Path | None = None,
|
|
373
|
+
) -> str:
|
|
374
|
+
"""Run ``graphify affected "<target>"`` and return its stdout text."""
|
|
375
|
+
args: list[str] = ["affected", target, "--depth", str(depth)]
|
|
376
|
+
for rel in relation or ():
|
|
377
|
+
args += ["--relation", rel]
|
|
378
|
+
if graph_path is not None:
|
|
379
|
+
args += ["--graph", str(graph_path)]
|
|
380
|
+
return await self._run_capture(root, args)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@dataclass(frozen=True)
|
|
384
|
+
class GraphifyBuildOutcome:
|
|
385
|
+
"""The captured result of a ``graphify extract`` invocation."""
|
|
386
|
+
|
|
387
|
+
root: Path
|
|
388
|
+
artifacts: GraphifyArtifacts
|
|
389
|
+
stdout: str
|
|
390
|
+
stderr: str
|
|
391
|
+
|
|
392
|
+
@property
|
|
393
|
+
def graph_payload(self) -> dict[str, Any]:
|
|
394
|
+
"""The parsed ``graph.json`` payload (reads from disk on each call)."""
|
|
395
|
+
return GraphifyClient.load_graph(self.artifacts.graph_json)
|
|
396
|
+
|
|
397
|
+
@property
|
|
398
|
+
def manifest_payload(self) -> dict[str, Any]:
|
|
399
|
+
"""The parsed ``manifest.json`` payload (may be empty)."""
|
|
400
|
+
return GraphifyClient.load_manifest(self.artifacts.manifest_json)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
__all__ = [
|
|
404
|
+
"DEFAULT_GRAPH_FILE",
|
|
405
|
+
"DEFAULT_MANIFEST_FILE",
|
|
406
|
+
"DEFAULT_OUTPUT_DIR",
|
|
407
|
+
"GraphifyArtifacts",
|
|
408
|
+
"GraphifyBuildOutcome",
|
|
409
|
+
"GraphifyClient",
|
|
410
|
+
"GraphifyInvocationError",
|
|
411
|
+
"GraphifyNotFoundError",
|
|
412
|
+
]
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Repository indexer: walk the filesystem and populate the :class:`CodeGraph`."""
|
|
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.service import Service
|
|
10
|
+
from forgecli.graph.graph import CodeGraph
|
|
11
|
+
from forgecli.graph.node import Node, NodeKind
|
|
12
|
+
from forgecli.utils.fs import iter_files
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Indexer(Service):
|
|
16
|
+
"""Walks a project directory and adds file-level nodes to the graph.
|
|
17
|
+
|
|
18
|
+
The real implementation will plug in tree-sitter to extract symbols;
|
|
19
|
+
this scaffold only produces file/package nodes and leaves symbol
|
|
20
|
+
extraction to language-specific analyzers added later.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
name = "graph.indexer"
|
|
24
|
+
|
|
25
|
+
DEFAULT_GLOBS: tuple[str, ...] = ("**/*.py", "**/*.ts", "**/*.js", "**/*.go", "**/*.rs")
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
graph: CodeGraph,
|
|
30
|
+
*,
|
|
31
|
+
root: Path,
|
|
32
|
+
languages: Iterable[str] | None = None,
|
|
33
|
+
ignore_patterns: Iterable[str] | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
super().__init__()
|
|
36
|
+
self._graph = graph
|
|
37
|
+
self._root = Path(root).resolve()
|
|
38
|
+
self._languages = list(languages) if languages else ["python", "typescript"]
|
|
39
|
+
self._ignore = list(ignore_patterns) if ignore_patterns else []
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def root(self) -> Path:
|
|
43
|
+
return self._root
|
|
44
|
+
|
|
45
|
+
def index(self) -> dict[str, Any]:
|
|
46
|
+
"""Walk ``root`` and add file nodes to the graph.
|
|
47
|
+
|
|
48
|
+
Returns a small summary useful for logging and tests.
|
|
49
|
+
"""
|
|
50
|
+
files = list(self._discover_files())
|
|
51
|
+
for path in files:
|
|
52
|
+
rel = str(path.relative_to(self._root))
|
|
53
|
+
node = Node(
|
|
54
|
+
id=f"file:{rel}",
|
|
55
|
+
kind=NodeKind.FILE,
|
|
56
|
+
name=path.name,
|
|
57
|
+
path=rel,
|
|
58
|
+
language=self._infer_language(path),
|
|
59
|
+
)
|
|
60
|
+
self._graph.add_node(node)
|
|
61
|
+
return {"files_indexed": len(files)}
|
|
62
|
+
|
|
63
|
+
def _discover_files(self) -> Iterable[Path]:
|
|
64
|
+
for path in iter_files(self._root, list(self.DEFAULT_GLOBS)):
|
|
65
|
+
if not path.is_file():
|
|
66
|
+
continue
|
|
67
|
+
if any(part in self._ignore for part in path.parts):
|
|
68
|
+
continue
|
|
69
|
+
yield path
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def _infer_language(path: Path) -> str | None:
|
|
73
|
+
suffix = path.suffix.lower()
|
|
74
|
+
return {
|
|
75
|
+
".py": "python",
|
|
76
|
+
".ts": "typescript",
|
|
77
|
+
".tsx": "typescript",
|
|
78
|
+
".js": "javascript",
|
|
79
|
+
".jsx": "javascript",
|
|
80
|
+
".go": "go",
|
|
81
|
+
".rs": "rust",
|
|
82
|
+
}.get(suffix)
|
forgecli/graph/node.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Node and edge value types for the code graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NodeKind(str, Enum):
|
|
11
|
+
"""The kind of entity represented by a graph node."""
|
|
12
|
+
|
|
13
|
+
FILE = "file"
|
|
14
|
+
MODULE = "module"
|
|
15
|
+
PACKAGE = "package"
|
|
16
|
+
CLASS = "class"
|
|
17
|
+
FUNCTION = "function"
|
|
18
|
+
METHOD = "method"
|
|
19
|
+
VARIABLE = "variable"
|
|
20
|
+
IMPORT = "import"
|
|
21
|
+
SYMBOL = "symbol"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class EdgeKind(str, Enum):
|
|
25
|
+
"""The kind of relationship represented by a graph edge."""
|
|
26
|
+
|
|
27
|
+
CONTAINS = "contains"
|
|
28
|
+
IMPORTS = "imports"
|
|
29
|
+
CALLS = "calls"
|
|
30
|
+
INHERITS = "inherits"
|
|
31
|
+
REFERENCES = "references"
|
|
32
|
+
DEFINED_IN = "defined_in"
|
|
33
|
+
OVERRIDES = "overrides"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Node:
|
|
38
|
+
"""A node in the code graph."""
|
|
39
|
+
|
|
40
|
+
id: str
|
|
41
|
+
kind: NodeKind
|
|
42
|
+
name: str
|
|
43
|
+
path: str | None = None
|
|
44
|
+
start_line: int | None = None
|
|
45
|
+
end_line: int | None = None
|
|
46
|
+
language: str | None = None
|
|
47
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Abstract interface for a repository knowledge graph.
|
|
2
|
+
|
|
3
|
+
This module is the boundary between ForgeCLI's graph-aware features and any
|
|
4
|
+
concrete back-end (Graphify today; custom AST-based backends tomorrow). The
|
|
5
|
+
interface is intentionally small: build, query, explain, path, affected.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class GraphNode:
|
|
18
|
+
"""A typed view of a single node from the knowledge graph."""
|
|
19
|
+
|
|
20
|
+
id: str
|
|
21
|
+
label: str
|
|
22
|
+
file_type: str | None = None
|
|
23
|
+
source_file: str | None = None
|
|
24
|
+
source_location: str | None = None
|
|
25
|
+
community: int | None = None
|
|
26
|
+
norm_label: str | None = None
|
|
27
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class GraphEdge:
|
|
32
|
+
"""A typed view of a directed/undirected edge."""
|
|
33
|
+
|
|
34
|
+
source: str
|
|
35
|
+
target: str
|
|
36
|
+
relation: str
|
|
37
|
+
confidence: str | None = None
|
|
38
|
+
confidence_score: float | None = None
|
|
39
|
+
source_file: str | None = None
|
|
40
|
+
source_location: str | None = None
|
|
41
|
+
weight: float = 1.0
|
|
42
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class GraphCommunity:
|
|
47
|
+
"""A Leiden-detected community of nodes."""
|
|
48
|
+
|
|
49
|
+
id: int
|
|
50
|
+
size: int
|
|
51
|
+
label: str | None = None
|
|
52
|
+
members: tuple[str, ...] = ()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class GraphSnapshot:
|
|
57
|
+
"""An immutable view of a fully-built knowledge graph."""
|
|
58
|
+
|
|
59
|
+
root: str
|
|
60
|
+
nodes: tuple[GraphNode, ...]
|
|
61
|
+
edges: tuple[GraphEdge, ...]
|
|
62
|
+
communities: tuple[GraphCommunity, ...] = ()
|
|
63
|
+
directed: bool = False
|
|
64
|
+
multigraph: bool = False
|
|
65
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
66
|
+
|
|
67
|
+
def node(self, node_id: str) -> GraphNode | None:
|
|
68
|
+
"""Return the node with ``node_id`` or ``None``."""
|
|
69
|
+
for node in self.nodes:
|
|
70
|
+
if node.id == node_id:
|
|
71
|
+
return node
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
def neighbors(self, node_id: str) -> list[GraphEdge]:
|
|
75
|
+
"""Return all edges incident to ``node_id`` (in or out)."""
|
|
76
|
+
return [e for e in self.edges if e.source == node_id or e.target == node_id]
|
|
77
|
+
|
|
78
|
+
def search(self, query: str, *, limit: int = 20) -> list[GraphNode]:
|
|
79
|
+
"""Return nodes whose ``label`` or ``norm_label`` contain ``query``."""
|
|
80
|
+
needle = query.lower()
|
|
81
|
+
hits: list[GraphNode] = []
|
|
82
|
+
for node in self.nodes:
|
|
83
|
+
if needle in (node.label or "").lower() or needle in (node.norm_label or "").lower():
|
|
84
|
+
hits.append(node)
|
|
85
|
+
if len(hits) >= limit:
|
|
86
|
+
break
|
|
87
|
+
return hits
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class BuildResult:
|
|
92
|
+
"""The outcome of :meth:`RepositoryGraph.build`."""
|
|
93
|
+
|
|
94
|
+
snapshot: GraphSnapshot
|
|
95
|
+
artifacts: dict[str, str] = field(default_factory=dict)
|
|
96
|
+
raw_output: str = ""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class QueryResult:
|
|
101
|
+
"""A natural-language answer produced by :meth:`RepositoryGraph.query`."""
|
|
102
|
+
|
|
103
|
+
question: str
|
|
104
|
+
answer: str
|
|
105
|
+
cited_nodes: tuple[str, ...] = ()
|
|
106
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class ExplainResult:
|
|
111
|
+
"""A plain-language explanation of a node and its neighbors."""
|
|
112
|
+
|
|
113
|
+
target: str
|
|
114
|
+
explanation: str
|
|
115
|
+
related: tuple[GraphNode, ...] = ()
|
|
116
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class RepositoryGraph(ABC):
|
|
120
|
+
"""Strategy interface for any repository knowledge graph backend."""
|
|
121
|
+
|
|
122
|
+
name: str = "abstract"
|
|
123
|
+
|
|
124
|
+
@abstractmethod
|
|
125
|
+
async def is_available(self) -> bool:
|
|
126
|
+
"""Return True if the back-end is installed and runnable."""
|
|
127
|
+
|
|
128
|
+
@abstractmethod
|
|
129
|
+
async def build(self, *, force: bool = False) -> BuildResult:
|
|
130
|
+
"""Build (or rebuild) the graph rooted at the configured path."""
|
|
131
|
+
|
|
132
|
+
@abstractmethod
|
|
133
|
+
async def load(self) -> GraphSnapshot:
|
|
134
|
+
"""Load a previously-built graph from disk without rebuilding."""
|
|
135
|
+
|
|
136
|
+
@abstractmethod
|
|
137
|
+
async def query(self, question: str, *, budget: int = 2000) -> QueryResult:
|
|
138
|
+
"""Ask a free-form question; the back-end traverses the graph."""
|
|
139
|
+
|
|
140
|
+
@abstractmethod
|
|
141
|
+
async def explain(self, target: str) -> ExplainResult:
|
|
142
|
+
"""Return a plain-language explanation of ``target`` and its neighbors."""
|
|
143
|
+
|
|
144
|
+
@abstractmethod
|
|
145
|
+
async def shortest_path(self, a: str, b: str) -> list[GraphEdge]:
|
|
146
|
+
"""Return the shortest edge path between two node labels/ids."""
|
|
147
|
+
|
|
148
|
+
@abstractmethod
|
|
149
|
+
async def affected(
|
|
150
|
+
self, target: str, *, relation: Iterable[str] | None = None, depth: int = 2
|
|
151
|
+
) -> list[GraphEdge]:
|
|
152
|
+
"""Return the reverse-traversal edges from ``target``."""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
__all__ = [
|
|
156
|
+
"BuildResult",
|
|
157
|
+
"ExplainResult",
|
|
158
|
+
"GraphCommunity",
|
|
159
|
+
"GraphEdge",
|
|
160
|
+
"GraphNode",
|
|
161
|
+
"GraphSnapshot",
|
|
162
|
+
"QueryResult",
|
|
163
|
+
"RepositoryGraph",
|
|
164
|
+
]
|