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/presets.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Onboarding presets (C4) — a one-command path to a working config.
|
|
2
|
+
|
|
3
|
+
Three presets cover the adoption ramp, lightest first:
|
|
4
|
+
|
|
5
|
+
* just-gate — the wedge: gate a diff/PR with NO model and NO infra (CLI transport,
|
|
6
|
+
so it shells the installed `sembl` — no uvx/MCP required).
|
|
7
|
+
* gate+sandbox — see the whole loop with a deterministic mock executor (no keys): plan
|
|
8
|
+
-> sandbox -> execute -> gate -> retry-on-BLOCK.
|
|
9
|
+
* full-loop — a real agent (Claude Code on the operator's OAuth session) writes, the
|
|
10
|
+
sandbox contains, Sembl gates. Swap `execute` for aider/opencode.
|
|
11
|
+
|
|
12
|
+
Each preset is stored as ANNOTATED YAML (not a dumped dict) so the file a stranger lands on
|
|
13
|
+
explains itself. `render()` returns that text; `config_dict()` parses it back for validation.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import yaml
|
|
18
|
+
|
|
19
|
+
_JUST_GATE = """\
|
|
20
|
+
# sembl-stack config — preset: just-gate
|
|
21
|
+
# The adoption wedge: gate any diff/PR with zero model and zero infra.
|
|
22
|
+
# sembl-stack verify --diff change.patch --bounds bounds.json
|
|
23
|
+
layers:
|
|
24
|
+
spec: sembl # L2 bounds engine (ours)
|
|
25
|
+
execute: mock # not used by `verify`; kept so `loop` still boots if you try it
|
|
26
|
+
sandbox: worktree # L4 disposable sandbox
|
|
27
|
+
verify: sembl # L5 gate (ours)
|
|
28
|
+
transport:
|
|
29
|
+
spec: cli # shell the installed `sembl` — no uvx/MCP needed
|
|
30
|
+
verify: cli
|
|
31
|
+
loop:
|
|
32
|
+
max_attempts: 1
|
|
33
|
+
strict: true # out-of-scope edits BLOCK
|
|
34
|
+
tracing:
|
|
35
|
+
langfuse: false
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
_GATE_SANDBOX = """\
|
|
39
|
+
# sembl-stack config — preset: gate+sandbox
|
|
40
|
+
# See the full loop with a deterministic mock executor (no API keys):
|
|
41
|
+
# plan -> sandbox -> execute -> gate -> retry-on-BLOCK
|
|
42
|
+
# sembl-stack loop task.yaml
|
|
43
|
+
layers:
|
|
44
|
+
spec: sembl
|
|
45
|
+
execute: mock # deterministic: misbehaves once (BLOCK), then complies (PASS)
|
|
46
|
+
sandbox: clone # disposable local git clone — the source repo is never touched
|
|
47
|
+
verify: sembl
|
|
48
|
+
transport:
|
|
49
|
+
spec: cli
|
|
50
|
+
verify: cli
|
|
51
|
+
loop:
|
|
52
|
+
max_attempts: 3
|
|
53
|
+
strict: true
|
|
54
|
+
tracing:
|
|
55
|
+
langfuse: false
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
_FULL_LOOP = """\
|
|
59
|
+
# sembl-stack config — preset: full-loop
|
|
60
|
+
# A real agent writes, the sandbox contains, Sembl gates.
|
|
61
|
+
# requires `claude` on PATH (Claude Code, the operator's own login — no token handled).
|
|
62
|
+
# swap execute: aider | opencode to drive a different agent.
|
|
63
|
+
# sembl-stack loop task.yaml
|
|
64
|
+
layers:
|
|
65
|
+
spec: sembl
|
|
66
|
+
execute: claude
|
|
67
|
+
sandbox: clone
|
|
68
|
+
verify: sembl
|
|
69
|
+
transport:
|
|
70
|
+
spec: cli
|
|
71
|
+
verify: cli
|
|
72
|
+
options:
|
|
73
|
+
execute:
|
|
74
|
+
model: # blank = the operator's default model
|
|
75
|
+
timeout: 900 # seconds before the executor is treated as a failed attempt
|
|
76
|
+
loop:
|
|
77
|
+
max_attempts: 3
|
|
78
|
+
strict: true
|
|
79
|
+
tracing:
|
|
80
|
+
langfuse: false
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
PRESETS: dict[str, str] = {
|
|
84
|
+
"just-gate": _JUST_GATE,
|
|
85
|
+
"gate+sandbox": _GATE_SANDBOX,
|
|
86
|
+
"full-loop": _FULL_LOOP,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
DEFAULT_PRESET = "gate+sandbox"
|
|
90
|
+
|
|
91
|
+
_STARTER_TASK = """\
|
|
92
|
+
# A task for the short loop. Paths resolve relative to this file.
|
|
93
|
+
text: "Add a VALUE constant to the app module, in scope, without touching infra."
|
|
94
|
+
repo: "."
|
|
95
|
+
# spec_path: "./specs/001-feature" # optional: a Spec Kit feature dir / tasks.md
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def names() -> list[str]:
|
|
100
|
+
return list(PRESETS)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def render(preset: str) -> str:
|
|
104
|
+
"""The annotated YAML text for a preset (raises KeyError on an unknown name)."""
|
|
105
|
+
return PRESETS[preset]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def config_dict(preset: str) -> dict:
|
|
109
|
+
"""The preset parsed to a dict — used to validate it loads and wires."""
|
|
110
|
+
return yaml.safe_load(PRESETS[preset])
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def starter_task() -> str:
|
|
114
|
+
return _STARTER_TASK
|
sembl_stack/profile.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Phase-1 onboarding core — the BYO-credentials profile (pure, headless).
|
|
2
|
+
|
|
3
|
+
sembl-stack provides orchestration, not inference: the user brings their own way of paying
|
|
4
|
+
for model calls (their Claude Code login, their API key, a local model — or the no-AI mock
|
|
5
|
+
preview). This module is the deterministic heart of that onboarding: a `Profile` dataclass
|
|
6
|
+
persisted at `~/.sembl/profile.json` (user-level; distinct from the per-repo
|
|
7
|
+
`.sembl/session.json`), auto-detection of what's already on the machine, a doctor-style
|
|
8
|
+
preflight per runner, and the mapping onto the existing `StackConfig` layers.
|
|
9
|
+
|
|
10
|
+
Security invariant (launch-credibility, do not weaken): **no key value is ever stored** —
|
|
11
|
+
`key_source` holds only a pointer like `"env:ANTHROPIC_API_KEY"`, validated on save; the
|
|
12
|
+
actual secret stays in the environment and is read only by the executor at runtime.
|
|
13
|
+
No Textual imports here; fully unit-testable.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import shutil
|
|
21
|
+
from dataclasses import asdict, dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from .doctor import Check
|
|
25
|
+
|
|
26
|
+
# How the user pays for model calls. Order = preference during auto-detection.
|
|
27
|
+
STRATEGIES = ["claude-login", "api-key", "local", "mock"]
|
|
28
|
+
|
|
29
|
+
# runner -> default L3 executor adapter (user may override in Advanced).
|
|
30
|
+
_RUNNER_EXECUTOR = {
|
|
31
|
+
"claude-login": "claude",
|
|
32
|
+
"api-key": "claude",
|
|
33
|
+
"local": "opencode",
|
|
34
|
+
"mock": "mock",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Env vars we auto-detect for the api-key runner, preference order. The var's *presence*
|
|
38
|
+
# is all we ever look at — the value is never read into a Profile or an artifact.
|
|
39
|
+
_KEY_ENV_VARS = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY"]
|
|
40
|
+
|
|
41
|
+
# key_source may only ever be a pointer: an env-var name or (post-launch) the keyring.
|
|
42
|
+
_SAFE_KEY_SOURCE = re.compile(r"^(env:[A-Za-z_][A-Za-z0-9_]*|keyring)$")
|
|
43
|
+
|
|
44
|
+
# model must look like a model id ("claude-opus-4-8", "tokenrouter/MiniMax-M3",
|
|
45
|
+
# "ollama/llama3:8b") — short, from a tight charset, and never key-prefixed. This is the
|
|
46
|
+
# second half of the security invariant: the free-form Model input is the one field a
|
|
47
|
+
# user could paste an API key into, and a key there would otherwise reach profile.json,
|
|
48
|
+
# argv (`--model`, visible in the process list), and run reports.
|
|
49
|
+
_SAFE_MODEL = re.compile(r"^(?!sk-)[A-Za-z0-9][A-Za-z0-9._:/\-]{0,63}$")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Profile:
|
|
54
|
+
runner: str = "mock" # one of STRATEGIES
|
|
55
|
+
executor: str = "mock" # L3 adapter name (claude|opencode|aider|mock)
|
|
56
|
+
model: str | None = None # e.g. "claude-opus-4-8", "tokenrouter/MiniMax-M3"
|
|
57
|
+
key_source: str | None = None # "env:ANTHROPIC_API_KEY" | "keyring" — NEVER the value
|
|
58
|
+
strict: bool = True
|
|
59
|
+
preset: str | None = None # presets.py name, if the user picked one
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def path() -> Path:
|
|
63
|
+
return Path.home() / ".sembl" / "profile.json"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def save(profile: Profile, p: Path | None = None) -> Path:
|
|
67
|
+
"""Persist the profile. Refuses anything secret-shaped in `key_source` or `model`."""
|
|
68
|
+
if profile.key_source is not None and not _SAFE_KEY_SOURCE.match(profile.key_source):
|
|
69
|
+
raise ValueError(
|
|
70
|
+
"key_source must be a pointer ('env:VAR_NAME' or 'keyring'), never a key value")
|
|
71
|
+
if profile.model is not None and not _SAFE_MODEL.match(profile.model):
|
|
72
|
+
raise ValueError(
|
|
73
|
+
"model must be a model id (e.g. 'claude-opus-4-8'), never an API key value")
|
|
74
|
+
p = p or path()
|
|
75
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
p.write_text(json.dumps(asdict(profile), indent=2), encoding="utf-8")
|
|
77
|
+
return p
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load(p: Path | None = None) -> Profile | None:
|
|
81
|
+
"""Read the saved profile, or None if missing OR unusable.
|
|
82
|
+
|
|
83
|
+
A corrupt/old/hand-edited file must never brick the entrypoint — unusable is treated
|
|
84
|
+
exactly like absent (re-onboard), mirroring `session.load`. A profile whose stored
|
|
85
|
+
key_source fails the pointer rule is also unusable: we never trust a secret-shaped
|
|
86
|
+
value back into memory.
|
|
87
|
+
"""
|
|
88
|
+
p = p or path()
|
|
89
|
+
if not p.is_file():
|
|
90
|
+
return None
|
|
91
|
+
try:
|
|
92
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
93
|
+
if not isinstance(data, dict):
|
|
94
|
+
return None
|
|
95
|
+
prof = Profile(**{k: v for k, v in data.items() if k in Profile.__dataclass_fields__})
|
|
96
|
+
except (OSError, ValueError, TypeError):
|
|
97
|
+
return None
|
|
98
|
+
if prof.runner not in STRATEGIES:
|
|
99
|
+
return None
|
|
100
|
+
if prof.key_source is not None and (
|
|
101
|
+
not isinstance(prof.key_source, str) or not _SAFE_KEY_SOURCE.match(prof.key_source)):
|
|
102
|
+
return None
|
|
103
|
+
# The remaining fields flow straight into config/registry/argv — a hand-edited file
|
|
104
|
+
# with the wrong types (or a secret-shaped model) is unusable, same as corrupt.
|
|
105
|
+
if not isinstance(prof.executor, str) or not prof.executor:
|
|
106
|
+
return None
|
|
107
|
+
if prof.model is not None and (
|
|
108
|
+
not isinstance(prof.model, str) or not _SAFE_MODEL.match(prof.model)):
|
|
109
|
+
return None
|
|
110
|
+
if not isinstance(prof.strict, bool):
|
|
111
|
+
return None
|
|
112
|
+
if prof.preset is not None and not isinstance(prof.preset, str):
|
|
113
|
+
return None
|
|
114
|
+
return prof
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def to_stack_overrides(profile: Profile) -> dict:
|
|
118
|
+
"""The dict the wizard merges into the resolved stack config.
|
|
119
|
+
|
|
120
|
+
Maps the BYO choice onto layers the loop already reads — zero new core wiring:
|
|
121
|
+
`layers.execute` (which adapter), `options.execute.model`, `loop.strict`.
|
|
122
|
+
"""
|
|
123
|
+
over: dict = {"layers": {"execute": profile.executor},
|
|
124
|
+
"loop": {"strict": profile.strict}}
|
|
125
|
+
if profile.model:
|
|
126
|
+
over["options"] = {"execute": {"model": profile.model}}
|
|
127
|
+
return over
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def detect() -> Profile:
|
|
131
|
+
"""Auto-detect the strongest BYO option present; onboarding preselects the result.
|
|
132
|
+
|
|
133
|
+
Preference: an existing `claude` install (their Claude Code login — token-free) >
|
|
134
|
+
a known API key in env > a local-model CLI (`opencode`) > the mock preview.
|
|
135
|
+
Detection only checks binary/env *presence* — never reads a key value.
|
|
136
|
+
"""
|
|
137
|
+
if shutil.which("claude"):
|
|
138
|
+
return Profile(runner="claude-login", executor="claude")
|
|
139
|
+
for var in _KEY_ENV_VARS:
|
|
140
|
+
if os.environ.get(var):
|
|
141
|
+
executor = "claude" if var == "ANTHROPIC_API_KEY" else "opencode"
|
|
142
|
+
return Profile(runner="api-key", executor=executor, key_source=f"env:{var}")
|
|
143
|
+
if shutil.which("opencode"):
|
|
144
|
+
return Profile(runner="local", executor="opencode")
|
|
145
|
+
return Profile() # mock — the keyless mechanics preview, never the hero path
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def preflight(profile: Profile) -> list[Check]:
|
|
149
|
+
"""Doctor-style checks that this runner can actually run — before onboarding proceeds.
|
|
150
|
+
|
|
151
|
+
On failure the wizard shows `hint` (the one concrete fix) and stays on the screen;
|
|
152
|
+
a runner that can't run is never persisted as the profile.
|
|
153
|
+
"""
|
|
154
|
+
checks: list[Check] = []
|
|
155
|
+
if profile.executor == "mock":
|
|
156
|
+
checks.append(Check("executor: mock", True, "no binary needed", required=False))
|
|
157
|
+
else:
|
|
158
|
+
# Checked even when runner == "mock": an Advanced executor override means a real
|
|
159
|
+
# binary will run, and a profile that can't run must never be persisted.
|
|
160
|
+
from .doctor import _EXECUTOR_BINARY # single source of binary names + install hints
|
|
161
|
+
binary, hint = _EXECUTOR_BINARY.get(profile.executor, (profile.executor, ""))
|
|
162
|
+
found = shutil.which(binary)
|
|
163
|
+
checks.append(Check(f"executor: {profile.executor}", found is not None,
|
|
164
|
+
found or "not found", hint))
|
|
165
|
+
|
|
166
|
+
if profile.runner == "mock":
|
|
167
|
+
checks.append(Check("runner: mock", True, "no credentials needed", required=False))
|
|
168
|
+
elif profile.runner == "api-key":
|
|
169
|
+
var = (profile.key_source or "")[len("env:"):] if (
|
|
170
|
+
profile.key_source or "").startswith("env:") else ""
|
|
171
|
+
ok = bool(var) and bool(os.environ.get(var))
|
|
172
|
+
checks.append(Check(
|
|
173
|
+
f"api key ({var or 'no env var chosen'})", ok,
|
|
174
|
+
"set in env" if ok else "not set",
|
|
175
|
+
f"set {var or 'your provider API key'} in your environment — sembl only ever "
|
|
176
|
+
"reads it from there at runtime, never stores it"))
|
|
177
|
+
elif profile.runner == "claude-login":
|
|
178
|
+
# Binary presence is the cheap proxy; an un-logged-in `claude` fails loudly on
|
|
179
|
+
# first use with its own login prompt, which is the right UX anyway. Checked
|
|
180
|
+
# against `claude` itself — the executor may be a different binary.
|
|
181
|
+
checks.append(Check(
|
|
182
|
+
"claude login", shutil.which("claude") is not None,
|
|
183
|
+
"uses your existing Claude Code session (token-free)",
|
|
184
|
+
"run `claude` once and log in", required=False))
|
|
185
|
+
return checks
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def ready(checks: list[Check]) -> tuple[bool, str]:
|
|
189
|
+
"""(ok, first concrete fix) — the wizard's proceed/stay decision."""
|
|
190
|
+
for c in checks:
|
|
191
|
+
if c.required and not c.ok:
|
|
192
|
+
return False, c.hint or f"{c.name}: {c.detail}"
|
|
193
|
+
return True, ""
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Advisory SpecGraph-to-code-graph reconciliation.
|
|
2
|
+
|
|
3
|
+
This is not a gate. It gives a human a compact drift report from two already
|
|
4
|
+
materialized graphs. The code graph is supplied as JSON so the current stage can
|
|
5
|
+
consume codebase-memory-mcp output without making that MCP a package dependency.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from pathlib import PurePosixPath
|
|
11
|
+
|
|
12
|
+
from .artifacts import ReconciliationReport, SpecGraph
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def reconcile_spec_code(spec_graph: SpecGraph, code_graph: dict) -> ReconciliationReport:
|
|
16
|
+
"""Compare declared spec concepts against a supplied code graph JSON payload."""
|
|
17
|
+
spec_concepts = _spec_concepts(spec_graph)
|
|
18
|
+
code_terms, code_files = _code_terms(code_graph)
|
|
19
|
+
|
|
20
|
+
findings: list[dict] = []
|
|
21
|
+
if not code_terms and not code_files:
|
|
22
|
+
return ReconciliationReport(
|
|
23
|
+
status="UNKNOWN",
|
|
24
|
+
summary="code graph had no comparable nodes or files",
|
|
25
|
+
findings=[{
|
|
26
|
+
"severity": "info",
|
|
27
|
+
"kind": "missing_code_graph",
|
|
28
|
+
"message": "No code graph concepts were supplied for reconciliation.",
|
|
29
|
+
}],
|
|
30
|
+
data=_counts(spec_graph, code_graph),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
for concept in spec_concepts:
|
|
34
|
+
if concept["type"] in ("editable_path", "forbidden_area"):
|
|
35
|
+
if concept["type"] == "editable_path" and not _path_covered(
|
|
36
|
+
concept["name"], code_files):
|
|
37
|
+
findings.append({
|
|
38
|
+
"severity": "info",
|
|
39
|
+
"kind": "scope_without_code_match",
|
|
40
|
+
"spec_node": concept["id"],
|
|
41
|
+
"message": f"Editable path not present in code graph: {concept['name']}",
|
|
42
|
+
})
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
if not _term_covered(concept["name"], code_terms):
|
|
46
|
+
findings.append({
|
|
47
|
+
"severity": "warn",
|
|
48
|
+
"kind": "spec_concept_without_code_match",
|
|
49
|
+
"spec_node": concept["id"],
|
|
50
|
+
"concept_type": concept["type"],
|
|
51
|
+
"message": f"Spec concept not found in code graph: {concept['name']}",
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
status = "DIVERGENT" if any(f["severity"] == "warn" for f in findings) else "ALIGNED"
|
|
55
|
+
summary = (
|
|
56
|
+
"spec/code divergence found"
|
|
57
|
+
if status == "DIVERGENT"
|
|
58
|
+
else "spec concepts are represented in the supplied code graph"
|
|
59
|
+
)
|
|
60
|
+
return ReconciliationReport(
|
|
61
|
+
status=status,
|
|
62
|
+
summary=summary,
|
|
63
|
+
findings=findings,
|
|
64
|
+
data=_counts(spec_graph, code_graph),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _spec_concepts(spec_graph: SpecGraph) -> list[dict]:
|
|
69
|
+
keep = {"route", "entity", "data_rule", "editable_path", "forbidden_area"}
|
|
70
|
+
return [
|
|
71
|
+
{"id": n.get("id", ""), "type": n.get("type", ""), "name": n.get("name", "")}
|
|
72
|
+
for n in spec_graph.nodes
|
|
73
|
+
if n.get("type") in keep and n.get("name")
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _code_terms(code_graph: dict) -> tuple[set[str], set[str]]:
|
|
78
|
+
nodes = _nodes_from_payload(code_graph)
|
|
79
|
+
terms: set[str] = set()
|
|
80
|
+
files: set[str] = set()
|
|
81
|
+
for node in nodes:
|
|
82
|
+
for key in ("name", "qualified_name", "label", "route", "path"):
|
|
83
|
+
value = node.get(key)
|
|
84
|
+
if isinstance(value, str):
|
|
85
|
+
terms.update(_tokens(value))
|
|
86
|
+
for key in ("file", "file_path", "path"):
|
|
87
|
+
value = node.get(key)
|
|
88
|
+
if isinstance(value, str):
|
|
89
|
+
files.add(_norm_path(value))
|
|
90
|
+
terms.update(_tokens(value))
|
|
91
|
+
return terms, files
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _nodes_from_payload(payload) -> list[dict]:
|
|
95
|
+
if isinstance(payload, list):
|
|
96
|
+
return [n for n in payload if isinstance(n, dict)]
|
|
97
|
+
if not isinstance(payload, dict):
|
|
98
|
+
return []
|
|
99
|
+
if isinstance(payload.get("nodes"), list):
|
|
100
|
+
return [n for n in payload["nodes"] if isinstance(n, dict)]
|
|
101
|
+
if isinstance(payload.get("results"), list):
|
|
102
|
+
return [n for n in payload["results"] if isinstance(n, dict)]
|
|
103
|
+
return []
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _term_covered(name: str, code_terms: set[str]) -> bool:
|
|
107
|
+
tokens = _tokens(name)
|
|
108
|
+
return bool(tokens) and all(token in code_terms for token in tokens)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _path_covered(path: str, code_files: set[str]) -> bool:
|
|
112
|
+
needle = _norm_path(path)
|
|
113
|
+
if not needle:
|
|
114
|
+
return True
|
|
115
|
+
if needle.endswith("/"):
|
|
116
|
+
return any(p.startswith(needle) for p in code_files)
|
|
117
|
+
return needle in code_files or any(PurePosixPath(p).match(needle) for p in code_files)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _tokens(value: str) -> set[str]:
|
|
121
|
+
value = re.sub(r"([a-z0-9])([A-Z])", r"\1 \2", value)
|
|
122
|
+
return {
|
|
123
|
+
token
|
|
124
|
+
for token in re.split(r"[^a-z0-9]+", value.lower())
|
|
125
|
+
if token and token not in {"api", "src", "app", "py", "ts", "tsx", "js", "jsx"}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _norm_path(path: str) -> str:
|
|
130
|
+
return path.replace("\\", "/").strip().lstrip("./")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _counts(spec_graph: SpecGraph, code_graph: dict) -> dict:
|
|
134
|
+
return {
|
|
135
|
+
"spec_nodes": len(spec_graph.nodes),
|
|
136
|
+
"spec_edges": len(spec_graph.edges),
|
|
137
|
+
"code_nodes": len(_nodes_from_payload(code_graph)),
|
|
138
|
+
}
|
sembl_stack/registry.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Adapter registry — the swap mechanism.
|
|
2
|
+
|
|
3
|
+
`sembl.stack.yaml` names an adapter per layer; the registry resolves the name to a
|
|
4
|
+
class. Register a new implementation here (or via entry points later) and it becomes
|
|
5
|
+
swappable with a one-line config change.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .adapters.execute_aider import AiderExecutor
|
|
10
|
+
from .adapters.execute_claude import ClaudeCodeExecutor
|
|
11
|
+
from .adapters.execute_mock import MockExecutor
|
|
12
|
+
from .adapters.execute_opencode import OpenCodeExecutor
|
|
13
|
+
from .adapters.deploy_vercel import VercelDeployAdapter
|
|
14
|
+
from .adapters.merge_git import GitMergeAdapter
|
|
15
|
+
from .adapters.postdeploy_http import HttpPostDeployGate
|
|
16
|
+
from .adapters.sandbox_worktree import WorktreeSandbox
|
|
17
|
+
from .adapters.spec_sembl import SemblSpecAdapter
|
|
18
|
+
from .adapters.verify_sembl import SemblVerifyAdapter
|
|
19
|
+
from .adapters.codegraph_cbm import CbmCodeGraph
|
|
20
|
+
from .adapters.review_mock import MockReviewAdapter
|
|
21
|
+
from .adapters.review_coderabbit import CodeRabbitReviewAdapter
|
|
22
|
+
from .adapters.review_llm import LLMReviewAdapter
|
|
23
|
+
from .contextgraph import SymgraphGraph
|
|
24
|
+
|
|
25
|
+
# layer -> { adapter name -> factory(transport, mcp_server, opts) }
|
|
26
|
+
# opts is the per-layer `options:` block from sembl.stack.yaml (adapter-specific knobs
|
|
27
|
+
# like which model to drive) — keeps tuning a config change, not a code change.
|
|
28
|
+
_REGISTRY: dict[str, dict[str, object]] = {
|
|
29
|
+
"spec": {
|
|
30
|
+
"sembl": lambda t, s, o: SemblSpecAdapter(transport=t, mcp_server=s),
|
|
31
|
+
},
|
|
32
|
+
"execute": {
|
|
33
|
+
"mock": lambda t, s, o: MockExecutor(),
|
|
34
|
+
"opencode": lambda t, s, o: OpenCodeExecutor(
|
|
35
|
+
model=o.get("model"), timeout=o.get("timeout", 900)),
|
|
36
|
+
"claude": lambda t, s, o: ClaudeCodeExecutor(
|
|
37
|
+
model=o.get("model"), timeout=o.get("timeout", 900)),
|
|
38
|
+
"aider": lambda t, s, o: AiderExecutor(
|
|
39
|
+
model=o.get("model"), timeout=o.get("timeout", 900)),
|
|
40
|
+
},
|
|
41
|
+
"sandbox": {
|
|
42
|
+
"worktree": lambda t, s, o: WorktreeSandbox(), # back-compat name
|
|
43
|
+
"clone": lambda t, s, o: WorktreeSandbox(), # disposable local clone
|
|
44
|
+
},
|
|
45
|
+
"verify": {
|
|
46
|
+
"sembl": lambda t, s, o: SemblVerifyAdapter(transport=t, mcp_server=s),
|
|
47
|
+
},
|
|
48
|
+
"context": { # L1 semantic graph (optional)
|
|
49
|
+
"symgraph": lambda t, s, o: SymgraphGraph(timeout=o.get("timeout", 300)),
|
|
50
|
+
"none": lambda t, s, o: None,
|
|
51
|
+
},
|
|
52
|
+
"codegraph": { # L5.5 code graph for reconcile
|
|
53
|
+
"cbm": lambda t, s, o: CbmCodeGraph(
|
|
54
|
+
binary=o.get("binary", "codebase-memory-mcp"),
|
|
55
|
+
timeout=o.get("timeout", 600), limit=o.get("limit", 5000)),
|
|
56
|
+
"none": lambda t, s, o: None,
|
|
57
|
+
},
|
|
58
|
+
"review": {
|
|
59
|
+
"mock": lambda t, s, o: MockReviewAdapter(),
|
|
60
|
+
"coderabbit": lambda t, s, o: CodeRabbitReviewAdapter(
|
|
61
|
+
binary=o.get("binary", "coderabbit"), timeout=o.get("timeout", 600)),
|
|
62
|
+
"llm": lambda t, s, o: LLMReviewAdapter( # BYO agent CLI (claude/opencode)
|
|
63
|
+
binary=o.get("binary", "claude"), model=o.get("model"),
|
|
64
|
+
timeout=o.get("timeout", 600)),
|
|
65
|
+
},
|
|
66
|
+
"merge": {
|
|
67
|
+
"git": lambda t, s, o: GitMergeAdapter(timeout=o.get("timeout", 300)),
|
|
68
|
+
},
|
|
69
|
+
"deploy": {
|
|
70
|
+
"vercel": lambda t, s, o: VercelDeployAdapter(timeout=o.get("timeout", 1800)),
|
|
71
|
+
},
|
|
72
|
+
"postdeploy": {
|
|
73
|
+
"http": lambda t, s, o: HttpPostDeployGate(
|
|
74
|
+
health_path=o.get("health_path", "/"), expect_json=o.get("expect_json")),
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def build(layer: str, name: str, transport: str, mcp_server: list[str],
|
|
80
|
+
opts: dict | None = None):
|
|
81
|
+
try:
|
|
82
|
+
factory = _REGISTRY[layer][name]
|
|
83
|
+
except KeyError:
|
|
84
|
+
avail = ", ".join(_REGISTRY.get(layer, {})) or "(none)"
|
|
85
|
+
raise SystemExit(
|
|
86
|
+
f"Unknown {layer} adapter '{name}'. Available: {avail}")
|
|
87
|
+
return factory(transport, mcp_server, opts or {})
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def names(layer: str) -> list[str]:
|
|
91
|
+
return list(_REGISTRY.get(layer, {}))
|