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.
- sembl_stack/__init__.py +3 -0
- sembl_stack/adapters/__init__.py +0 -0
- sembl_stack/adapters/_redact.py +19 -0
- sembl_stack/adapters/base.py +179 -0
- sembl_stack/adapters/codegraph_cbm.py +95 -0
- sembl_stack/adapters/deploy_vercel.py +215 -0
- sembl_stack/adapters/execute_aider.py +115 -0
- sembl_stack/adapters/execute_claude.py +114 -0
- sembl_stack/adapters/execute_mock.py +53 -0
- sembl_stack/adapters/execute_opencode.py +114 -0
- sembl_stack/adapters/merge_git.py +107 -0
- sembl_stack/adapters/postdeploy_http.py +82 -0
- sembl_stack/adapters/review_coderabbit.py +215 -0
- sembl_stack/adapters/review_llm.py +142 -0
- sembl_stack/adapters/review_mock.py +42 -0
- sembl_stack/adapters/sandbox_worktree.py +79 -0
- sembl_stack/adapters/spec_sembl.py +91 -0
- sembl_stack/adapters/verify_sembl.py +77 -0
- sembl_stack/artifacts.py +207 -0
- sembl_stack/cli.py +759 -0
- sembl_stack/config.py +87 -0
- sembl_stack/contextgraph.py +154 -0
- sembl_stack/doctor.py +111 -0
- sembl_stack/loop.py +380 -0
- sembl_stack/onboarding.py +272 -0
- sembl_stack/presets.py +114 -0
- sembl_stack/profile.py +193 -0
- sembl_stack/reconciliation.py +138 -0
- sembl_stack/registry.py +91 -0
- sembl_stack/rsi.py +188 -0
- sembl_stack/runner.py +134 -0
- sembl_stack/session.py +86 -0
- sembl_stack/specgraph.py +146 -0
- sembl_stack/store.py +112 -0
- sembl_stack/tracing.py +51 -0
- sembl_stack/transport/__init__.py +0 -0
- sembl_stack/transport/mcp_client.py +58 -0
- sembl_stack/tui.py +86 -0
- sembl_stack/views.py +74 -0
- sembl_stack/wizard.py +233 -0
- sembl_stack-0.1.0.dist-info/METADATA +165 -0
- sembl_stack-0.1.0.dist-info/RECORD +45 -0
- sembl_stack-0.1.0.dist-info/WHEEL +4 -0
- sembl_stack-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|