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.
- 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 +149 -0
- forgecli/cli/commands_wrappers.py +80 -0
- forgecli/cli/daemon.py +744 -0
- forgecli/cli/main.py +234 -0
- forgecli/cli/ui.py +56 -0
- forgecli/config/__init__.py +28 -0
- forgecli/config/loader.py +85 -0
- forgecli/config/settings.py +206 -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 +214 -0
- forgecli/core/plugins.py +54 -0
- forgecli/core/service.py +22 -0
- forgecli/docs/__init__.py +5 -0
- forgecli/docs/generator.py +115 -0
- forgecli/engine/__init__.py +105 -0
- forgecli/engine/context.py +163 -0
- forgecli/engine/defaults.py +89 -0
- forgecli/engine/events.py +185 -0
- forgecli/engine/execution.py +519 -0
- forgecli/engine/plugins.py +145 -0
- forgecli/engine/runner.py +158 -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 +102 -0
- forgecli/engine/stages/git_engine.py +30 -0
- forgecli/engine/stages/intent_analyzer.py +38 -0
- forgecli/engine/stages/model_router.py +65 -0
- forgecli/engine/stages/planning_engine.py +45 -0
- forgecli/engine/stages/repository_analyzer.py +54 -0
- forgecli/engine/stages/validation_engine.py +87 -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 +448 -0
- forgecli/graph/edge.py +19 -0
- forgecli/graph/graph.py +85 -0
- forgecli/graph/graphify.py +405 -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 +136 -0
- forgecli/memory/store.py +85 -0
- forgecli/optimizer/__init__.py +13 -0
- forgecli/optimizer/caveman/__init__.py +169 -0
- forgecli/optimizer/caveman/cli.py +62 -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 +50 -0
- forgecli/optimizer/chunker.py +85 -0
- forgecli/optimizer/optimizer.py +81 -0
- forgecli/optimizer/ponytail/__init__.py +181 -0
- forgecli/optimizer/ponytail/cli.py +159 -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 +51 -0
- forgecli/optimizer/ranker.py +37 -0
- forgecli/optimizer/summarizer.py +44 -0
- forgecli/orchestrator/__init__.py +706 -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 +267 -0
- forgecli/planner/serialize.py +104 -0
- forgecli/planner/software.py +832 -0
- forgecli/platform/__init__.py +91 -0
- forgecli/platform/core.py +201 -0
- forgecli/platform/deps.py +361 -0
- forgecli/platform/paths.py +234 -0
- forgecli/platform/shell.py +176 -0
- forgecli/platform/update.py +253 -0
- forgecli/plugins/__init__.py +249 -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 +207 -0
- forgecli/providers/base.py +204 -0
- forgecli/providers/builtin.py +26 -0
- forgecli/providers/conversation.py +74 -0
- forgecli/providers/google.py +290 -0
- forgecli/providers/http_base.py +207 -0
- forgecli/providers/mock.py +111 -0
- forgecli/providers/openai.py +206 -0
- forgecli/providers/openai_compatible.py +787 -0
- forgecli/providers/router.py +340 -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 +255 -0
- forgecli/review/analyzers/complexity.py +162 -0
- forgecli/review/analyzers/dead_code.py +255 -0
- forgecli/review/analyzers/duplicates.py +161 -0
- forgecli/review/analyzers/performance.py +161 -0
- forgecli/review/analyzers/security.py +244 -0
- forgecli/review/finding.py +68 -0
- forgecli/review/report.py +321 -0
- forgecli/review/repository.py +130 -0
- forgecli/review/suggestions.py +98 -0
- forgecli/runtime/__init__.py +6 -0
- forgecli/runtime/cache_store.py +75 -0
- forgecli/runtime/mcp_config.py +118 -0
- forgecli/runtime/prepare.py +203 -0
- forgecli/runtime/wrappers.py +150 -0
- forgecli/sdk/__init__.py +132 -0
- forgecli/sdk/events.py +206 -0
- forgecli/sdk/interfaces.py +282 -0
- forgecli/sdk/loader.py +239 -0
- forgecli/sdk/manager.py +678 -0
- forgecli/sdk/manifest.py +395 -0
- forgecli/sdk/sandbox.py +247 -0
- forgecli/sdk/version.py +313 -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 +121 -0
- forgecli/utils/ids.py +11 -0
- forgecli/utils/io.py +27 -0
- forgecli/utils/paths.py +78 -0
- forgecli/utils/stats.py +179 -0
- forgecli/utils/timing.py +25 -0
- forgeoptimizer-0.1.0.dist-info/METADATA +177 -0
- forgeoptimizer-0.1.0.dist-info/RECORD +159 -0
- forgeoptimizer-0.1.0.dist-info/WHEEL +4 -0
- forgeoptimizer-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|
forgecli/git/__init__.py
ADDED
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"
|
forgecli/git/service.py
ADDED
|
@@ -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"]
|