dev-guru 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.
dev_guru/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """Dev Guru: a local-first developer product on the Ananke Plexus harness."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from dev_guru.version import __version__
8
+
9
+ if TYPE_CHECKING:
10
+ from dev_guru.sdk import GuruClient
11
+
12
+ __all__ = ["GuruClient", "__version__"]
13
+
14
+
15
+ def __getattr__(name: str) -> Any:
16
+ # Lazy, so `import dev_guru` stays cheap and submodules never import the package's SDK back (no cycle).
17
+ if name == "GuruClient":
18
+ from dev_guru.sdk import GuruClient
19
+
20
+ return GuruClient
21
+ raise AttributeError(f"module 'dev_guru' has no attribute {name!r}")
dev_guru/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from dev_guru.cli import app
2
+
3
+ app()
dev_guru/cli.py ADDED
@@ -0,0 +1,149 @@
1
+ """The ``dev`` command. CLI and SDK share one service layer (dev_guru.service)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import platform
7
+ import webbrowser
8
+ from importlib import metadata
9
+ from pathlib import Path
10
+ from typing import Annotated
11
+
12
+ import typer
13
+ from rich.console import Console
14
+
15
+ from dev_guru import errors, service
16
+ from dev_guru.client import TRANSPORTS, connect
17
+ from dev_guru.version import __version__
18
+
19
+ app = typer.Typer(
20
+ name="dev",
21
+ help="Dev Guru: understand, improve and verify your software, on the Ananke Plexus harness.",
22
+ no_args_is_help=True,
23
+ add_completion=False,
24
+ )
25
+ console = Console()
26
+ err = Console(stderr=True)
27
+ TransportOpt = Annotated[str, typer.Option(help=f"Harness transport: {', '.join(TRANSPORTS)}")]
28
+ JsonOpt = Annotated[bool, typer.Option("--json", help="Machine-readable output")]
29
+
30
+
31
+ def _version(value: bool) -> None:
32
+ if value:
33
+ console.print(f"dev-guru {__version__}")
34
+ raise typer.Exit()
35
+
36
+
37
+ @app.callback()
38
+ def main(
39
+ version: Annotated[
40
+ bool,
41
+ typer.Option(
42
+ "--version", callback=_version, is_eager=True, help="Show the version and exit"
43
+ ),
44
+ ] = False,
45
+ ) -> None:
46
+ """Dev Guru: understand, improve and verify your software, on the Ananke Plexus harness."""
47
+
48
+
49
+ def _fail(exc: errors.GuruError, as_json: bool, code: int) -> None:
50
+ if as_json:
51
+ console.print_json(json.dumps({"ok": False, "error": exc.to_dict()}))
52
+ else:
53
+ err.print(f"[red]✗ {exc.code}: {exc.message}[/red]")
54
+ if exc.hint:
55
+ err.print(f" [dim]{exc.hint}[/dim]")
56
+ raise typer.Exit(code) from exc
57
+
58
+
59
+ def _optional(distribution: str) -> str | None:
60
+ try:
61
+ return metadata.version(distribution)
62
+ except metadata.PackageNotFoundError:
63
+ return None
64
+
65
+
66
+ @app.command()
67
+ def doctor(transport: TransportOpt = "in-process", as_json: JsonOpt = False) -> None:
68
+ """Check the Ananke harness: installed version, supported range and the capabilities Dev Guru needs.
69
+
70
+ Exits 1 when the harness is missing or incompatible, 2 for an unknown transport.
71
+ """
72
+ try:
73
+ info = connect(transport).info()
74
+ except errors.GuruError as exc:
75
+ _fail(exc, as_json, 2)
76
+ return
77
+ dex = _optional("dex-trove")
78
+ report = {
79
+ "ok": info.compatible,
80
+ "dev_guru": __version__,
81
+ "python": platform.python_version(),
82
+ "harness": info.model_dump(),
83
+ "optional": {"dex-trove": dex},
84
+ }
85
+ if as_json:
86
+ console.print_json(json.dumps(report))
87
+ else:
88
+ mark = "[green]✓[/green]" if info.compatible else "[red]✗[/red]"
89
+ console.print(
90
+ f"dev-guru {__version__} · Python {report['python']} · transport {info.transport}"
91
+ )
92
+ console.print(
93
+ f"{mark} ananke-plexus {info.ananke_version or 'not installed'} (supported {info.supported_range})"
94
+ )
95
+ for cap, present in info.capabilities.items():
96
+ console.print(f" {'✓' if present else '✗'} {cap}")
97
+ for reason in info.reasons:
98
+ console.print(f" [red]{reason}[/red]")
99
+ console.print(f" [dim]dex-trove: {dex or 'not installed (optional)'}[/dim]")
100
+ if not info.compatible:
101
+ raise typer.Exit(1)
102
+
103
+
104
+ @app.command()
105
+ def explore(
106
+ workspace: Annotated[Path, typer.Argument(help="Project to explore")] = Path("."),
107
+ output: Annotated[
108
+ Path, typer.Option("--output", "-o", help="Report file (a manifest is written next to it)")
109
+ ] = Path("dev-guru-explorer.html"),
110
+ name: Annotated[str | None, typer.Option(help="Project name shown in the report")] = None,
111
+ open_browser: Annotated[
112
+ bool, typer.Option("--open", help="Open the report when it's written")
113
+ ] = False,
114
+ transport: TransportOpt = "in-process",
115
+ as_json: JsonOpt = False,
116
+ ) -> None:
117
+ """Explore a codebase: Ananke builds the graph, Dev Guru writes an interactive, offline HTML explorer.
118
+
119
+ The report is one file with no network access: architecture constellation, dependency flow, impact rings,
120
+ call graph, hotspots, a guided tour, search and a source inspector.
121
+ """
122
+ try:
123
+ result = service.explore(workspace, client=connect(transport), name=name)
124
+ page, sidecar = service.export_html(result, output)
125
+ except errors.GuruError as exc:
126
+ _fail(exc, as_json, 1)
127
+ return
128
+ project = result.model["project"]
129
+ summary = {
130
+ "ok": True,
131
+ "report": str(page),
132
+ "manifest": str(sidecar),
133
+ "input_digest": result.manifest["input_digest"],
134
+ "project": project,
135
+ "insights": {k: len(v) for k, v in result.model["insights"].items()},
136
+ }
137
+ if as_json:
138
+ console.print_json(json.dumps(summary))
139
+ else:
140
+ console.print(
141
+ f"[green]✓[/green] {project['files']} files · {project['symbols']} symbols · "
142
+ f"{project['imports']} imports · {project['calls']} calls → {page}"
143
+ )
144
+ cycles = len(result.model["insights"]["cycles"])
145
+ if cycles:
146
+ console.print(f" [yellow]{cycles} import cycle(s) found; see the Flow view[/yellow]")
147
+ console.print(f" [dim]manifest {sidecar} · {result.manifest['input_digest'][:19]}…[/dim]")
148
+ if open_browser:
149
+ webbrowser.open(page.resolve().as_uri())
@@ -0,0 +1,22 @@
1
+ """The Ananke client port. Use :func:`connect` to get a client for one explicit transport."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dev_guru import errors
6
+ from dev_guru.client.inprocess import InProcessAnanke
7
+ from dev_guru.client.port import AnankeClient, GraphData, GraphSummary, HarnessInfo
8
+
9
+ TRANSPORTS = ("in-process",) # planned: "process" (isolated executable), "gateway" (local HTTP)
10
+
11
+
12
+ def connect(transport: str = "in-process") -> AnankeClient:
13
+ """A client for exactly ``transport``. An unknown or unavailable transport is an error, not a fallback."""
14
+ if transport == "in-process":
15
+ return InProcessAnanke()
16
+ raise errors.GuruError(
17
+ errors.UNSUPPORTED_TRANSPORT,
18
+ f"transport {transport!r} is not supported; supported: {', '.join(TRANSPORTS)}",
19
+ )
20
+
21
+
22
+ __all__ = ["TRANSPORTS", "AnankeClient", "GraphData", "GraphSummary", "HarnessInfo", "connect"]
@@ -0,0 +1,122 @@
1
+ """In-process transport: calls the installed ``ananke-plexus`` package through its public facade."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+ from pathlib import Path
7
+
8
+ import dev_guru.client.negotiation as negotiation
9
+ from dev_guru import errors
10
+ from dev_guru.client.port import GraphData, GraphEdgeData, GraphNodeData, GraphSummary, HarnessInfo
11
+
12
+
13
+ class InProcessAnanke:
14
+ transport = "in-process"
15
+
16
+ def info(self) -> HarnessInfo:
17
+ version = negotiation.installed_version()
18
+ if version is None:
19
+ return HarnessInfo(
20
+ transport=self.transport,
21
+ ananke_version=None,
22
+ supported_range=negotiation.SUPPORTED_ANANKE,
23
+ compatible=False,
24
+ reasons=["ananke-plexus is not installed in this environment"],
25
+ )
26
+ caps = negotiation.probe()
27
+ missing = [c for c in negotiation.REQUIRED if not caps.get(c)]
28
+ reasons: list[str] = []
29
+ if not negotiation.supported(version):
30
+ reasons.append(
31
+ f"ananke-plexus {version} is outside the supported range {negotiation.SUPPORTED_ANANKE}"
32
+ )
33
+ if missing:
34
+ reasons.append(f"required harness capabilities are missing: {', '.join(missing)}")
35
+ return HarnessInfo(
36
+ transport=self.transport,
37
+ ananke_version=version,
38
+ supported_range=negotiation.SUPPORTED_ANANKE,
39
+ compatible=not reasons,
40
+ capabilities=caps,
41
+ missing_required=missing,
42
+ reasons=reasons,
43
+ )
44
+
45
+ def require_compatible(self) -> HarnessInfo:
46
+ info = self.info()
47
+ if info.ananke_version is None:
48
+ raise errors.GuruError(
49
+ errors.HARNESS_MISSING,
50
+ "Ananke Plexus is not installed",
51
+ hint="pip install 'ananke-plexus" + negotiation.SUPPORTED_ANANKE + "'",
52
+ )
53
+ if info.missing_required:
54
+ raise errors.GuruError(errors.CAPABILITY_MISSING, "; ".join(info.reasons))
55
+ if not info.compatible:
56
+ raise errors.GuruError(
57
+ errors.HARNESS_INCOMPATIBLE,
58
+ "; ".join(info.reasons),
59
+ hint=f"install ananke-plexus{negotiation.SUPPORTED_ANANKE}",
60
+ )
61
+ return info
62
+
63
+ def graph_data(self, workspace: Path) -> GraphData:
64
+ """Ask Ananke to build the workspace graph and return the snapshot it stored."""
65
+ info = self.require_compatible()
66
+ root = workspace.expanduser().resolve()
67
+ if not root.is_dir():
68
+ raise errors.GuruError(errors.WORKSPACE_INVALID, f"workspace {root} is not a directory")
69
+ from ananke.plexus.api import Ananke
70
+ from ananke.plexus.graph.service import load_graph
71
+
72
+ result = Ananke.open(root).graph_build()
73
+ if not result.ok:
74
+ raise errors.GuruError(
75
+ errors.HARNESS_FAILED, f"Ananke could not build the graph: {result.summary}"
76
+ )
77
+ snapshot = load_graph(root)
78
+ if (
79
+ snapshot is None
80
+ ): # a "successful" build with nothing stored is a harness fault, not an empty graph
81
+ raise errors.GuruError(
82
+ errors.HARNESS_FAILED, "Ananke reported a graph build but stored no snapshot"
83
+ )
84
+ return GraphData(
85
+ snapshot_id=snapshot.snapshot_id,
86
+ provider=snapshot.provider,
87
+ ananke_version=info.ananke_version,
88
+ nodes=[
89
+ GraphNodeData(
90
+ id=n.node_id, kind=n.kind, name=n.name, path=n.path, metadata=dict(n.metadata)
91
+ )
92
+ for n in snapshot.nodes
93
+ ],
94
+ edges=[
95
+ GraphEdgeData(
96
+ source=e.source,
97
+ target=e.target,
98
+ kind=e.kind,
99
+ origin=e.origin,
100
+ confidence=e.confidence,
101
+ location=e.source_location,
102
+ )
103
+ for e in snapshot.edges
104
+ ],
105
+ source=str(result.details.get("path", "")),
106
+ )
107
+
108
+ def graph_summary(self, workspace: Path) -> GraphSummary:
109
+ """Ask Ananke to build the workspace graph, then summarise the snapshot it stored."""
110
+ data = self.graph_data(workspace)
111
+ return GraphSummary(
112
+ snapshot_id=data.snapshot_id,
113
+ provider=data.provider,
114
+ nodes=len(data.nodes),
115
+ edges=len(data.edges),
116
+ nodes_by_kind=dict(sorted(Counter(n.kind for n in data.nodes).items())),
117
+ edges_by_kind=dict(sorted(Counter(e.kind for e in data.edges).items())),
118
+ source=data.source,
119
+ )
120
+
121
+
122
+ __all__ = ["InProcessAnanke"]
@@ -0,0 +1,69 @@
1
+ """Which Ananke Plexus this Dev Guru supports, and what the installed one can do.
2
+
3
+ Negotiation is explicit: Dev Guru reads the installed harness version, checks it against ``SUPPORTED_ANANKE`` and
4
+ probes the specific interfaces it calls. An out-of-range or incomplete harness is an error with a stable code,
5
+ never a silent downgrade.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ import re
12
+ from importlib import metadata
13
+
14
+ SUPPORTED_ANANKE = ">=0.3.12,<0.4" # keep in step with pyproject.toml (tests/test_packaging.py)
15
+ _MIN = (0, 3, 12)
16
+ _BELOW = (0, 4, 0)
17
+
18
+ # Capability id -> (module, attribute path). Required ones are what this release of Dev Guru calls.
19
+ CAPABILITIES: dict[str, tuple[str, str]] = {
20
+ "graph.build": ("ananke.plexus.api", "Ananke.graph_build"),
21
+ "graph.load": ("ananke.plexus.graph.service", "load_graph"),
22
+ "graph.impact": ("ananke.plexus.api", "Ananke.graph_impact"),
23
+ "run.status": ("ananke.plexus.api", "Ananke.run_status"),
24
+ "spec.create": ("ananke.plexus.api", "Ananke.create_spec"),
25
+ "registry.open": ("ananke.plexus.registry.registry", "Registry.open"),
26
+ }
27
+ REQUIRED = ("graph.build", "graph.load")
28
+
29
+
30
+ def parse_version(text: str) -> tuple[int, int, int] | None:
31
+ m = re.match(r"^(\d+)\.(\d+)\.(\d+)", text.strip())
32
+ return (int(m.group(1)), int(m.group(2)), int(m.group(3))) if m else None
33
+
34
+
35
+ def supported(version: str) -> bool:
36
+ parsed = parse_version(version)
37
+ return parsed is not None and _MIN <= parsed < _BELOW
38
+
39
+
40
+ def installed_version(distribution: str = "ananke-plexus") -> str | None:
41
+ try:
42
+ return metadata.version(distribution)
43
+ except metadata.PackageNotFoundError:
44
+ return None
45
+
46
+
47
+ def probe(capabilities: dict[str, tuple[str, str]] = CAPABILITIES) -> dict[str, bool]:
48
+ """Which capabilities the importable harness exposes (imports only; nothing runs)."""
49
+ found: dict[str, bool] = {}
50
+ for cap, (module, attr_path) in capabilities.items():
51
+ try:
52
+ obj: object = importlib.import_module(module)
53
+ for part in attr_path.split("."):
54
+ obj = getattr(obj, part)
55
+ found[cap] = callable(obj)
56
+ except (ImportError, AttributeError):
57
+ found[cap] = False
58
+ return found
59
+
60
+
61
+ __all__ = [
62
+ "CAPABILITIES",
63
+ "REQUIRED",
64
+ "SUPPORTED_ANANKE",
65
+ "installed_version",
66
+ "parse_version",
67
+ "probe",
68
+ "supported",
69
+ ]
@@ -0,0 +1,87 @@
1
+ """The one port between Dev Guru and the Ananke harness.
2
+
3
+ Dev Guru never analyses code itself: every domain operation goes through an :class:`AnankeClient`. Today there is
4
+ one transport (``in-process``: the installed ``ananke-plexus`` package). An isolated executable and a local HTTP
5
+ gateway are planned. Exactly one transport is chosen per run and recorded; a failing transport is reported, never
6
+ silently swapped for another (docs/decisions/0001-ananke-client-port.md).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import Protocol, runtime_checkable
13
+
14
+ from pydantic import BaseModel, Field
15
+
16
+
17
+ class HarnessInfo(BaseModel):
18
+ """What negotiation found: the transport, harness version, support verdict and capabilities."""
19
+
20
+ transport: str
21
+ ananke_version: str | None
22
+ supported_range: str
23
+ compatible: bool
24
+ capabilities: dict[str, bool] = Field(default_factory=dict)
25
+ missing_required: list[str] = Field(default_factory=list)
26
+ reasons: list[str] = Field(default_factory=list)
27
+
28
+
29
+ class GraphSummary(BaseModel):
30
+ """A bounded projection of an Ananke graph snapshot. Ananke keeps the snapshot itself."""
31
+
32
+ snapshot_id: str
33
+ provider: str
34
+ nodes: int
35
+ edges: int
36
+ nodes_by_kind: dict[str, int] = Field(default_factory=dict)
37
+ edges_by_kind: dict[str, int] = Field(default_factory=dict)
38
+ source: str # where Ananke stored the snapshot (Ananke-owned workspace state)
39
+
40
+
41
+ class GraphNodeData(BaseModel):
42
+ id: str
43
+ kind: str
44
+ name: str
45
+ path: str = ""
46
+ metadata: dict[str, object] = Field(default_factory=dict)
47
+
48
+
49
+ class GraphEdgeData(BaseModel):
50
+ source: str
51
+ target: str
52
+ kind: str
53
+ origin: str = "extracted"
54
+ confidence: float = 1.0
55
+ location: str = ""
56
+
57
+
58
+ class GraphData(BaseModel):
59
+ """The graph Ananke built, as plain data. Dev Guru projects it; it never adds analysis of its own."""
60
+
61
+ snapshot_id: str
62
+ provider: str
63
+ ananke_version: str | None
64
+ nodes: list[GraphNodeData] = Field(default_factory=list)
65
+ edges: list[GraphEdgeData] = Field(default_factory=list)
66
+ source: str = ""
67
+
68
+
69
+ @runtime_checkable
70
+ class AnankeClient(Protocol):
71
+ transport: str
72
+
73
+ def info(self) -> HarnessInfo: ...
74
+
75
+ def graph_summary(self, workspace: Path) -> GraphSummary: ...
76
+
77
+ def graph_data(self, workspace: Path) -> GraphData: ...
78
+
79
+
80
+ __all__ = [
81
+ "AnankeClient",
82
+ "GraphData",
83
+ "GraphEdgeData",
84
+ "GraphNodeData",
85
+ "GraphSummary",
86
+ "HarnessInfo",
87
+ ]
dev_guru/errors.py ADDED
@@ -0,0 +1,44 @@
1
+ """Errors with stable codes, so the CLI, the SDK and later the web UI report failures the same way."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # Stable codes: callers and tests branch on these, never on message text.
6
+ HARNESS_MISSING = "HARNESS_MISSING"
7
+ HARNESS_INCOMPATIBLE = "HARNESS_INCOMPATIBLE"
8
+ CAPABILITY_MISSING = "CAPABILITY_MISSING"
9
+ UNSUPPORTED_TRANSPORT = "UNSUPPORTED_TRANSPORT"
10
+ WORKSPACE_INVALID = "WORKSPACE_INVALID"
11
+ HARNESS_FAILED = "HARNESS_FAILED"
12
+ CODES = frozenset(
13
+ {
14
+ HARNESS_MISSING,
15
+ HARNESS_INCOMPATIBLE,
16
+ CAPABILITY_MISSING,
17
+ UNSUPPORTED_TRANSPORT,
18
+ WORKSPACE_INVALID,
19
+ HARNESS_FAILED,
20
+ }
21
+ )
22
+
23
+
24
+ class GuruError(Exception):
25
+ """A failure Dev Guru can explain: a stable ``code``, a message, an optional hint and run id."""
26
+
27
+ def __init__(
28
+ self, code: str, message: str, *, hint: str = "", run_id: str | None = None
29
+ ) -> None:
30
+ if code not in CODES:
31
+ raise ValueError(f"unknown error code {code!r}")
32
+ super().__init__(message)
33
+ self.code = code
34
+ self.message = message
35
+ self.hint = hint
36
+ self.run_id = run_id
37
+
38
+ def to_dict(self) -> dict[str, str | None]:
39
+ return {
40
+ "code": self.code,
41
+ "message": self.message,
42
+ "hint": self.hint,
43
+ "run_id": self.run_id,
44
+ }
@@ -0,0 +1 @@
1
+ """Report view models and renderers. Presentation only: the analysis comes from Ananke."""