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
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
+ }
@@ -0,0 +1,405 @@
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(proc.communicate(), timeout=self._timeout)
170
+ except TimeoutError as exc:
171
+ proc.kill()
172
+ raise GraphifyInvocationError(
173
+ f"graphify extract timed out after {self._timeout}s"
174
+ ) from exc
175
+
176
+ if proc.returncode != 0:
177
+ raise GraphifyInvocationError(
178
+ "graphify extract failed (exit "
179
+ f"{proc.returncode}):\n{stderr.decode(errors='replace').strip()}"
180
+ )
181
+
182
+ artifacts = GraphifyArtifacts.for_root(root)
183
+ if not no_cluster:
184
+ await self.cluster_only(root)
185
+
186
+ return GraphifyBuildOutcome(
187
+ root=root,
188
+ artifacts=artifacts,
189
+ stdout=stdout.decode(errors="replace"),
190
+ stderr=stderr.decode(errors="replace"),
191
+ )
192
+
193
+ async def update(
194
+ self,
195
+ root: Path,
196
+ *,
197
+ force: bool = False,
198
+ no_cluster: bool = False,
199
+ ) -> GraphifyBuildOutcome:
200
+ """Run ``graphify update <root>`` and return the parsed outcome."""
201
+ binary = await self.detect()
202
+ if binary is None:
203
+ raise GraphifyNotFoundError(
204
+ f"Graphify executable {self._executable!r} not found on PATH"
205
+ )
206
+
207
+ root = root.resolve()
208
+ args: list[str] = [
209
+ binary,
210
+ "update",
211
+ str(root),
212
+ ]
213
+ if force:
214
+ args.append("--force")
215
+ if no_cluster:
216
+ args.append("--no-cluster")
217
+
218
+ proc = await asyncio.create_subprocess_exec(
219
+ *args,
220
+ stdout=asyncio.subprocess.PIPE,
221
+ stderr=asyncio.subprocess.PIPE,
222
+ cwd=str(root),
223
+ )
224
+ try:
225
+ stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=self._timeout)
226
+ except TimeoutError as exc:
227
+ proc.kill()
228
+ raise GraphifyInvocationError(
229
+ f"graphify update timed out after {self._timeout}s"
230
+ ) from exc
231
+
232
+ if proc.returncode != 0:
233
+ raise GraphifyInvocationError(
234
+ "graphify update failed (exit "
235
+ f"{proc.returncode}):\n{stderr.decode(errors='replace').strip()}"
236
+ )
237
+
238
+ artifacts = GraphifyArtifacts.for_root(root)
239
+ return GraphifyBuildOutcome(
240
+ root=root,
241
+ artifacts=artifacts,
242
+ stdout=stdout.decode(errors="replace"),
243
+ stderr=stderr.decode(errors="replace"),
244
+ )
245
+
246
+ async def cluster_only(
247
+ self,
248
+ root: Path,
249
+ *,
250
+ backend: str | None = None,
251
+ ) -> str:
252
+ """Run ``graphify cluster-only <root>`` to update HTML viz and Graph Report."""
253
+ args: list[str] = ["cluster-only", str(root.resolve())]
254
+ if backend:
255
+ args.append(f"--backend={backend}")
256
+ return await self._run_capture(root, args)
257
+
258
+ # ------------------------------------------------------------------
259
+ # JSON parsers
260
+ # ------------------------------------------------------------------
261
+
262
+ @staticmethod
263
+ def load_graph(path: Path) -> dict[str, Any]:
264
+ """Read and return the raw ``graph.json`` payload."""
265
+ if not path.exists():
266
+ raise FileNotFoundError(f"graph.json not found at {path}")
267
+ with path.open("r", encoding="utf-8") as fh:
268
+ return json.load(fh)
269
+
270
+ @staticmethod
271
+ def load_manifest(path: Path) -> dict[str, Any]:
272
+ """Read and return the raw ``manifest.json`` payload (may be missing)."""
273
+ if not path.exists():
274
+ return {}
275
+ with path.open("r", encoding="utf-8") as fh:
276
+ return json.load(fh)
277
+
278
+ # ------------------------------------------------------------------
279
+ # Query / explain / path / affected
280
+ # ------------------------------------------------------------------
281
+
282
+ async def _run_capture(
283
+ self,
284
+ root: Path,
285
+ args: list[str],
286
+ *,
287
+ timeout: float | None = None,
288
+ ) -> str:
289
+ binary = await self.detect()
290
+ if binary is None:
291
+ raise GraphifyNotFoundError(
292
+ f"Graphify executable {self._executable!r} not found on PATH"
293
+ )
294
+ proc = await asyncio.create_subprocess_exec(
295
+ binary,
296
+ *args,
297
+ stdout=asyncio.subprocess.PIPE,
298
+ stderr=asyncio.subprocess.PIPE,
299
+ cwd=str(root),
300
+ )
301
+ try:
302
+ stdout, stderr = await asyncio.wait_for(
303
+ proc.communicate(), timeout=timeout or self._timeout
304
+ )
305
+ except TimeoutError as exc:
306
+ proc.kill()
307
+ raise GraphifyInvocationError(f"graphify {' '.join(args[:1])} timed out") from exc
308
+ if proc.returncode != 0:
309
+ raise GraphifyInvocationError(
310
+ f"graphify exited with {proc.returncode}: {stderr.decode(errors='replace').strip()}"
311
+ )
312
+ return stdout.decode(errors="replace")
313
+
314
+ async def query(
315
+ self,
316
+ root: Path,
317
+ question: str,
318
+ *,
319
+ budget: int = 2000,
320
+ graph_path: Path | None = None,
321
+ dfs: bool = False,
322
+ ) -> str:
323
+ """Run ``graphify query "<question>"`` and return its stdout text."""
324
+ args: list[str] = ["query", question, "--budget", str(budget)]
325
+ if graph_path is not None:
326
+ args += ["--graph", str(graph_path)]
327
+ if dfs:
328
+ args.append("--dfs")
329
+ return await self._run_capture(root, args)
330
+
331
+ async def explain(
332
+ self,
333
+ root: Path,
334
+ target: str,
335
+ *,
336
+ graph_path: Path | None = None,
337
+ ) -> str:
338
+ """Run ``graphify explain "<target>"`` and return its stdout text."""
339
+ args: list[str] = ["explain", target]
340
+ if graph_path is not None:
341
+ args += ["--graph", str(graph_path)]
342
+ return await self._run_capture(root, args)
343
+
344
+ async def path(
345
+ self,
346
+ root: Path,
347
+ a: str,
348
+ b: str,
349
+ *,
350
+ graph_path: Path | None = None,
351
+ ) -> str:
352
+ """Run ``graphify path "<a>" "<b>"`` and return its stdout text."""
353
+ args: list[str] = ["path", a, b]
354
+ if graph_path is not None:
355
+ args += ["--graph", str(graph_path)]
356
+ return await self._run_capture(root, args)
357
+
358
+ async def affected(
359
+ self,
360
+ root: Path,
361
+ target: str,
362
+ *,
363
+ relation: Iterable[str] | None = None,
364
+ depth: int = 2,
365
+ graph_path: Path | None = None,
366
+ ) -> str:
367
+ """Run ``graphify affected "<target>"`` and return its stdout text."""
368
+ args: list[str] = ["affected", target, "--depth", str(depth)]
369
+ for rel in relation or ():
370
+ args += ["--relation", rel]
371
+ if graph_path is not None:
372
+ args += ["--graph", str(graph_path)]
373
+ return await self._run_capture(root, args)
374
+
375
+
376
+ @dataclass(frozen=True)
377
+ class GraphifyBuildOutcome:
378
+ """The captured result of a ``graphify extract`` invocation."""
379
+
380
+ root: Path
381
+ artifacts: GraphifyArtifacts
382
+ stdout: str
383
+ stderr: str
384
+
385
+ @property
386
+ def graph_payload(self) -> dict[str, Any]:
387
+ """The parsed ``graph.json`` payload (reads from disk on each call)."""
388
+ return GraphifyClient.load_graph(self.artifacts.graph_json)
389
+
390
+ @property
391
+ def manifest_payload(self) -> dict[str, Any]:
392
+ """The parsed ``manifest.json`` payload (may be empty)."""
393
+ return GraphifyClient.load_manifest(self.artifacts.manifest_json)
394
+
395
+
396
+ __all__ = [
397
+ "DEFAULT_GRAPH_FILE",
398
+ "DEFAULT_MANIFEST_FILE",
399
+ "DEFAULT_OUTPUT_DIR",
400
+ "GraphifyArtifacts",
401
+ "GraphifyBuildOutcome",
402
+ "GraphifyClient",
403
+ "GraphifyInvocationError",
404
+ "GraphifyNotFoundError",
405
+ ]
@@ -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)