sembl-stack 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 (45) hide show
  1. sembl_stack/__init__.py +3 -0
  2. sembl_stack/adapters/__init__.py +0 -0
  3. sembl_stack/adapters/_redact.py +19 -0
  4. sembl_stack/adapters/base.py +179 -0
  5. sembl_stack/adapters/codegraph_cbm.py +95 -0
  6. sembl_stack/adapters/deploy_vercel.py +215 -0
  7. sembl_stack/adapters/execute_aider.py +115 -0
  8. sembl_stack/adapters/execute_claude.py +114 -0
  9. sembl_stack/adapters/execute_mock.py +53 -0
  10. sembl_stack/adapters/execute_opencode.py +114 -0
  11. sembl_stack/adapters/merge_git.py +107 -0
  12. sembl_stack/adapters/postdeploy_http.py +82 -0
  13. sembl_stack/adapters/review_coderabbit.py +215 -0
  14. sembl_stack/adapters/review_llm.py +142 -0
  15. sembl_stack/adapters/review_mock.py +42 -0
  16. sembl_stack/adapters/sandbox_worktree.py +79 -0
  17. sembl_stack/adapters/spec_sembl.py +91 -0
  18. sembl_stack/adapters/verify_sembl.py +77 -0
  19. sembl_stack/artifacts.py +207 -0
  20. sembl_stack/cli.py +759 -0
  21. sembl_stack/config.py +87 -0
  22. sembl_stack/contextgraph.py +154 -0
  23. sembl_stack/doctor.py +111 -0
  24. sembl_stack/loop.py +380 -0
  25. sembl_stack/onboarding.py +272 -0
  26. sembl_stack/presets.py +114 -0
  27. sembl_stack/profile.py +193 -0
  28. sembl_stack/reconciliation.py +138 -0
  29. sembl_stack/registry.py +91 -0
  30. sembl_stack/rsi.py +188 -0
  31. sembl_stack/runner.py +134 -0
  32. sembl_stack/session.py +86 -0
  33. sembl_stack/specgraph.py +146 -0
  34. sembl_stack/store.py +112 -0
  35. sembl_stack/tracing.py +51 -0
  36. sembl_stack/transport/__init__.py +0 -0
  37. sembl_stack/transport/mcp_client.py +58 -0
  38. sembl_stack/tui.py +86 -0
  39. sembl_stack/views.py +74 -0
  40. sembl_stack/wizard.py +233 -0
  41. sembl_stack-0.1.0.dist-info/METADATA +165 -0
  42. sembl_stack-0.1.0.dist-info/RECORD +45 -0
  43. sembl_stack-0.1.0.dist-info/WHEEL +4 -0
  44. sembl_stack-0.1.0.dist-info/entry_points.txt +2 -0
  45. sembl_stack-0.1.0.dist-info/licenses/LICENSE +201 -0
sembl_stack/config.py ADDED
@@ -0,0 +1,87 @@
1
+ """Load sembl.stack.yaml and resolve it into wired-up adapters."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+
7
+ import yaml
8
+
9
+ from . import registry
10
+
11
+ DEFAULTS = {
12
+ "layers": {"spec": "sembl", "execute": "mock",
13
+ "sandbox": "worktree", "verify": "sembl", "context": "none",
14
+ "codegraph": "cbm", "review": "mock",
15
+ "merge": "git", "deploy": "vercel", "postdeploy": "http"},
16
+ "transport": {"spec": "mcp", "verify": "mcp",
17
+ "mcp_server": ["uvx", "--from", "sembl[mcp]", "sembl-mcp"]},
18
+ "loop": {"max_attempts": 3, "strict": True},
19
+ "tracing": {"langfuse": False},
20
+ }
21
+
22
+
23
+ @dataclass
24
+ class StackConfig:
25
+ spec: object
26
+ execute: object
27
+ sandbox: object
28
+ verify: object
29
+ context: object = None
30
+ codegraph: object = None
31
+ review: object = None
32
+ merge: object = None
33
+ deploy: object = None
34
+ postdeploy: object = None
35
+ max_attempts: int = 3
36
+ strict: bool = True
37
+ langfuse: bool = False
38
+ raw: dict = field(default_factory=dict)
39
+
40
+
41
+ def _merge(base: dict, over: dict) -> dict:
42
+ out = dict(base)
43
+ for k, v in (over or {}).items():
44
+ out[k] = _merge(base[k], v) if isinstance(v, dict) and isinstance(base.get(k), dict) else v
45
+ return out
46
+
47
+
48
+ def load(path: str | None, overrides: dict | None = None) -> StackConfig:
49
+ """Resolve DEFAULTS < overrides < file. `overrides` is where the onboarding profile
50
+ plugs in (profile.to_stack_overrides) — passed explicitly by the caller, never read
51
+ from global state here, so resolution stays deterministic and testable. An explicit
52
+ sembl.stack.yaml always wins over a profile."""
53
+ cfg = _merge(DEFAULTS, overrides or {})
54
+ if path and Path(path).is_file():
55
+ cfg = _merge(cfg, yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {})
56
+
57
+ layers = cfg["layers"]
58
+ tr = cfg["transport"]
59
+ server = tr.get("mcp_server", DEFAULTS["transport"]["mcp_server"])
60
+ opts = cfg.get("options", {}) # per-layer adapter knobs (e.g. execute.model)
61
+
62
+ return StackConfig(
63
+ spec=registry.build("spec", layers["spec"], tr.get("spec", "mcp"), server,
64
+ opts.get("spec")),
65
+ execute=registry.build("execute", layers["execute"], "cli", server,
66
+ opts.get("execute")),
67
+ sandbox=registry.build("sandbox", layers["sandbox"], "cli", server,
68
+ opts.get("sandbox")),
69
+ verify=registry.build("verify", layers["verify"], tr.get("verify", "mcp"), server,
70
+ opts.get("verify")),
71
+ context=registry.build("context", layers.get("context", "none"), "cli", server,
72
+ opts.get("context")),
73
+ codegraph=registry.build("codegraph", layers.get("codegraph", "cbm"), "cli", server,
74
+ opts.get("codegraph")),
75
+ review=registry.build("review", layers.get("review", "mock"), "cli", server,
76
+ opts.get("review")),
77
+ merge=registry.build("merge", layers.get("merge", "git"), "cli", server,
78
+ opts.get("merge")),
79
+ deploy=registry.build("deploy", layers.get("deploy", "vercel"), "cli", server,
80
+ opts.get("deploy")),
81
+ postdeploy=registry.build("postdeploy", layers.get("postdeploy", "http"), "cli",
82
+ server, opts.get("postdeploy")),
83
+ max_attempts=cfg["loop"]["max_attempts"],
84
+ strict=cfg["loop"]["strict"],
85
+ langfuse=cfg["tracing"]["langfuse"],
86
+ raw=cfg,
87
+ )
@@ -0,0 +1,154 @@
1
+ """L1 Context: a swappable semantic code graph + graph-based bounds expansion.
2
+
3
+ Two contender tools (both local, deterministic, MIT) sit behind one protocol:
4
+ * symgraph (Rust; tree-sitter + SQLite; `--format json`; has a file-level module
5
+ dependency graph) — the default.
6
+ * codegraph (Node; tree-sitter + SQLite/FTS5) — a second adapter (TODO).
7
+
8
+ The capability the gate actually needs is **bounds expansion**: given the seed paths a
9
+ spec/issue names, add the files they are genuinely coupled to (the dependency closure),
10
+ so a legitimate multi-file change does not false-alarm the scope check. EXP-04 showed the
11
+ scope check over-fires precisely because a real fix touches sibling modules the spec never
12
+ named; the coupling graph recovers exactly those. (It does NOT recover changelog/docs/test
13
+ files — those are not code edges and are handled by the separate docs-auto-in-scope rule.)
14
+
15
+ `expand_paths()` is a pure function over a file graph, so it is unit-testable without the
16
+ binary; the adapter only has to produce the graph.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import shutil
22
+ import subprocess
23
+ from dataclasses import dataclass, field
24
+ from typing import Protocol
25
+
26
+
27
+ @dataclass
28
+ class FileGraph:
29
+ """A directed file-dependency graph: edge from -> to means `from` uses `to`."""
30
+ nodes: list[str] = field(default_factory=list)
31
+ edges: list[dict] = field(default_factory=list) # {"from","to","strength",...}
32
+
33
+ def neighbors(self, path: str, min_strength: int = 0) -> set[str]:
34
+ """Files directly coupled to `path` in EITHER direction (callers + callees)."""
35
+ out: set[str] = set()
36
+ for e in self.edges:
37
+ if e.get("strength", 1) < min_strength:
38
+ continue
39
+ if e["from"] == path:
40
+ out.add(e["to"])
41
+ elif e["to"] == path:
42
+ out.add(e["from"])
43
+ return out
44
+
45
+
46
+ class ContextGraph(Protocol):
47
+ def available(self) -> bool: ...
48
+ def index(self, repo: str) -> None: ...
49
+ def file_graph(self, repo: str) -> FileGraph: ...
50
+
51
+
52
+ # --- helpers ------------------------------------------------------------------
53
+
54
+ def _norm(p: str) -> str:
55
+ return p.replace("\\", "/").strip().lstrip("./")
56
+
57
+
58
+ def expand_paths(seed: list[str], graph: FileGraph, *, hops: int = 1,
59
+ min_strength: int = 0, max_fraction: float = 0.4) -> list[str]:
60
+ """Grow a seed set of files along the coupling graph, up to `hops` away.
61
+
62
+ Returns the union of the seed and every file reachable within `hops` edges of any seed
63
+ file (whose coupling strength clears `min_strength`). Deterministic; the seed is always
64
+ included even if it has no edges.
65
+
66
+ Defaults are deliberately conservative (EXP-05): `hops=1` keeps the closure tight
67
+ (~10-22% of a real repo) while still recovering a third to a half of the legitimate
68
+ sibling files; two hops balloons to ~the whole repo and destroys the gate. The
69
+ `max_fraction` cap is the safety net for small, densely-cyclic repos (e.g. flask) where
70
+ even one hop engulfs the codebase: if the closure would exceed `max_fraction` of the
71
+ indexed files, expansion is **abandoned and the bare seed is returned** — better a
72
+ noisier scope check than a gate that silently whitelists everything.
73
+ """
74
+ seed_n = {_norm(p) for p in seed if p}
75
+ g = FileGraph(
76
+ nodes=[_norm(n) for n in graph.nodes],
77
+ edges=[{**e, "from": _norm(e["from"]), "to": _norm(e["to"])} for e in graph.edges],
78
+ )
79
+ frontier = set(seed_n)
80
+ seen = set(seed_n)
81
+ for _ in range(max(0, hops)):
82
+ nxt: set[str] = set()
83
+ for f in frontier:
84
+ nxt |= g.neighbors(f, min_strength)
85
+ nxt -= seen
86
+ if not nxt:
87
+ break
88
+ seen |= nxt
89
+ frontier = nxt
90
+ if g.nodes and len(seen) > max_fraction * len(g.nodes):
91
+ return sorted(seed_n) # too dense to be informative — don't whitelist the repo
92
+ return sorted(seen)
93
+
94
+
95
+ def _under(path: str, prefix: str) -> bool:
96
+ path, prefix = _norm(path), _norm(prefix).rstrip("/")
97
+ return path == prefix or path.startswith(prefix + "/")
98
+
99
+
100
+ def expand_bounds(editable_paths: list[str], graph: FileGraph, *, hops: int = 1,
101
+ min_strength: int = 0, max_fraction: float = 0.4) -> list[str]:
102
+ """Expand a bounds `editable_paths` list along the coupling graph (EXP-05).
103
+
104
+ Each declared path (file or directory prefix) seeds the indexed files it covers; those
105
+ are grown one hop (by default) into their coupling closure. The ORIGINAL entries are
106
+ always preserved (so directory bounds keep working even if no file under them is
107
+ indexed); the recovered sibling FILES are added. Returns a de-duplicated, sorted list.
108
+ """
109
+ seed_files = [n for n in graph.nodes
110
+ if any(_under(n, p) for p in editable_paths)]
111
+ if not seed_files:
112
+ return sorted({_norm(p) for p in editable_paths})
113
+ grown = expand_paths(seed_files, graph, hops=hops, min_strength=min_strength,
114
+ max_fraction=max_fraction)
115
+ return sorted({_norm(p) for p in editable_paths} | set(grown))
116
+
117
+
118
+ # --- symgraph adapter ---------------------------------------------------------
119
+
120
+ class SymgraphGraph:
121
+ """Drives the `symgraph` Rust binary. Index once, then read the file module-graph."""
122
+
123
+ def __init__(self, binary: str = "symgraph", timeout: int = 300):
124
+ self.binary = binary
125
+ self.timeout = timeout
126
+
127
+ def _exe(self) -> str | None:
128
+ return shutil.which(self.binary)
129
+
130
+ def available(self) -> bool:
131
+ return self._exe() is not None
132
+
133
+ def _run(self, args: list[str], repo: str) -> subprocess.CompletedProcess:
134
+ exe = self._exe()
135
+ if not exe:
136
+ raise RuntimeError(
137
+ f"L1: `{self.binary}` not found on PATH. Install it, or disable graph expansion.")
138
+ return subprocess.run([exe, *args], cwd=repo, capture_output=True,
139
+ text=True, timeout=self.timeout)
140
+
141
+ def index(self, repo: str) -> None:
142
+ self._run(["index", "."], repo)
143
+
144
+ def file_graph(self, repo: str) -> FileGraph:
145
+ proc = self._run(
146
+ ["module-graph", "--granularity", "file", "--format", "json"], repo)
147
+ try:
148
+ data = json.loads(proc.stdout)
149
+ except json.JSONDecodeError:
150
+ return FileGraph()
151
+ return FileGraph(
152
+ nodes=[n["id"] for n in data.get("nodes", [])],
153
+ edges=data.get("edges", []),
154
+ )
sembl_stack/doctor.py ADDED
@@ -0,0 +1,111 @@
1
+ """`doctor` (C4) — a config-aware environment preflight.
2
+
3
+ A stranger's first failure should be a clear diagnosis, not a stack trace. `run_checks`
4
+ inspects only what the (optional) config actually uses — it won't fail on a missing `claude`
5
+ when `execute: mock` — and returns structured `Check`s with actionable hints. The checks are
6
+ pure (no side effects) and don't import the heavy optionals, so they're unit-testable by
7
+ monkeypatching `shutil.which` / `find_spec`.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import importlib.util
12
+ import shutil
13
+ import sys
14
+ from dataclasses import dataclass
15
+
16
+
17
+ @dataclass
18
+ class Check:
19
+ name: str
20
+ ok: bool
21
+ detail: str
22
+ hint: str = ""
23
+ required: bool = True # an optional check that's missing -> WARN, not failure
24
+
25
+
26
+ def _have_module(name: str) -> bool:
27
+ try:
28
+ return importlib.util.find_spec(name) is not None
29
+ except (ImportError, ValueError):
30
+ return False
31
+
32
+
33
+ # executor layer name -> (binary on PATH, install hint)
34
+ _EXECUTOR_BINARY = {
35
+ "claude": ("claude", "install Claude Code and log in (`claude`)"),
36
+ "aider": ("aider", "`pip install aider-chat` and set your model env (OPENAI_API_KEY…)"),
37
+ "opencode": ("opencode", "install OpenCode and ensure `opencode` is on PATH"),
38
+ }
39
+
40
+
41
+ def run_checks(cfg=None) -> list[Check]:
42
+ """Structured preflight. `cfg` is an optional loaded StackConfig (config-aware checks)."""
43
+ layers = (getattr(cfg, "raw", {}) or {}).get("layers", {}) if cfg else {}
44
+ transport = (getattr(cfg, "raw", {}) or {}).get("transport", {}) if cfg else {}
45
+ checks: list[Check] = []
46
+
47
+ # --- always required ---
48
+ py_ok = sys.version_info >= (3, 10)
49
+ checks.append(Check(
50
+ "python", py_ok, f"{sys.version_info.major}.{sys.version_info.minor}",
51
+ "" if py_ok else "sembl-stack needs Python >= 3.10"))
52
+ checks.append(Check(
53
+ "git", shutil.which("git") is not None,
54
+ shutil.which("git") or "not found", "install git (the sandbox clones the repo)"))
55
+
56
+ # The gate (sembl) is the heart of the factory — required whenever verify=sembl.
57
+ sembl_ok = _have_module("sembl")
58
+ checks.append(Check(
59
+ "sembl (gate)", sembl_ok, "importable" if sembl_ok else "missing",
60
+ "" if sembl_ok else "`pip install sembl` into this environment", required=True))
61
+
62
+ # --- transport-dependent (only when MCP transport is selected) ---
63
+ uses_mcp = "mcp" in (transport.get("spec"), transport.get("verify"))
64
+ if uses_mcp or cfg is None:
65
+ mcp_ok = _have_module("mcp")
66
+ checks.append(Check(
67
+ "mcp (transport)", mcp_ok, "importable" if mcp_ok else "missing",
68
+ "`pip install \"sembl[mcp]\"` — or use transport: cli (no MCP needed)",
69
+ required=uses_mcp))
70
+ server = transport.get("mcp_server", []) if cfg else ["uvx"]
71
+ if server and server[0] == "uvx":
72
+ uvx_ok = shutil.which("uvx") is not None
73
+ checks.append(Check(
74
+ "uvx (mcp launcher)", uvx_ok, shutil.which("uvx") or "not found",
75
+ "install uv (`pip install uv`) — or point mcp_server at a local `sembl-mcp`",
76
+ required=uses_mcp))
77
+
78
+ # --- orchestration (optional: the loop has a zero-dep fallback) ---
79
+ lg_ok = _have_module("langgraph")
80
+ checks.append(Check(
81
+ "langgraph (orchestration)", lg_ok, "importable" if lg_ok else "missing",
82
+ "optional — the loop falls back to a built-in runner; `pip install langgraph` for "
83
+ "the real retry graph", required=False))
84
+
85
+ # --- executor binary (only the one the config actually selects) ---
86
+ execute = layers.get("execute", "mock")
87
+ if execute in _EXECUTOR_BINARY:
88
+ binary, hint = _EXECUTOR_BINARY[execute]
89
+ present = shutil.which(binary) is not None
90
+ checks.append(Check(
91
+ f"executor: {execute}", present, shutil.which(binary) or "not found",
92
+ "" if present else hint, required=True))
93
+ elif execute == "mock":
94
+ checks.append(Check("executor: mock", True, "no binary needed", required=False))
95
+
96
+ # --- context graph (only when context: symgraph) ---
97
+ if layers.get("context") == "symgraph":
98
+ sg_ok = shutil.which("symgraph") is not None
99
+ checks.append(Check(
100
+ "context: symgraph", sg_ok, shutil.which("symgraph") or "not found",
101
+ "optional — `cargo install symgraph`; only needed for bounds --expand",
102
+ required=False))
103
+
104
+ return checks
105
+
106
+
107
+ def summarize(checks: list[Check]) -> tuple[bool, list[Check], list[Check]]:
108
+ """Return (ready, blocking_failures, optional_warnings)."""
109
+ blocking = [c for c in checks if c.required and not c.ok]
110
+ warnings = [c for c in checks if not c.required and not c.ok]
111
+ return (not blocking), blocking, warnings