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,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
+ ]