substratum-cli 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.
- substratum_cli/README.md +51 -0
- substratum_cli/__init__.py +6 -0
- substratum_cli/__main__.py +7 -0
- substratum_cli/_vendor/__init__.py +0 -0
- substratum_cli/_vendor/_pro_test_derive.py +169 -0
- substratum_cli/account.py +62 -0
- substratum_cli/cli.py +185 -0
- substratum_cli/codes.py +114 -0
- substratum_cli/commands/__init__.py +0 -0
- substratum_cli/commands/apply.py +49 -0
- substratum_cli/commands/auth.py +37 -0
- substratum_cli/commands/commit.py +40 -0
- substratum_cli/commands/explain.py +63 -0
- substratum_cli/commands/failing.py +80 -0
- substratum_cli/commands/fix.py +48 -0
- substratum_cli/commands/history.py +15 -0
- substratum_cli/commands/init_cmd.py +155 -0
- substratum_cli/commands/replay.py +68 -0
- substratum_cli/commands/scaffold.py +71 -0
- substratum_cli/commands/show.py +18 -0
- substratum_cli/commands/undo.py +28 -0
- substratum_cli/commands/verify.py +193 -0
- substratum_cli/commands/write.py +42 -0
- substratum_cli/config.py +49 -0
- substratum_cli/diffs.py +106 -0
- substratum_cli/engine.py +154 -0
- substratum_cli/gitio.py +102 -0
- substratum_cli/langscan.py +110 -0
- substratum_cli/product_ops.py +639 -0
- substratum_cli/refusals.py +127 -0
- substratum_cli/remote.py +140 -0
- substratum_cli/render.py +332 -0
- substratum_cli/result.py +185 -0
- substratum_cli/run_store.py +162 -0
- substratum_cli/runid.py +69 -0
- substratum_cli/session.py +558 -0
- substratum_cli/style.py +128 -0
- substratum_cli-0.1.0.dist-info/METADATA +69 -0
- substratum_cli-0.1.0.dist-info/RECORD +42 -0
- substratum_cli-0.1.0.dist-info/WHEEL +5 -0
- substratum_cli-0.1.0.dist-info/entry_points.txt +2 -0
- substratum_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
"""The persistent session: `substratum` with no args. Two problems, one build.
|
|
2
|
+
|
|
3
|
+
1. Warm library. The session is ONE process. It loads the v15 library once (in a background
|
|
4
|
+
thread at startup, via engine.warm()) and every command in the loop reuses it, so the second
|
|
5
|
+
fix and every fix after is fast. No per-invocation reload.
|
|
6
|
+
2. Claude-Code-style loop. You cd into a repo, run `substratum`, and drive it interactively:
|
|
7
|
+
`fix <test>`, `verify`, `apply`, `show`, `scaffold-test`, etc., with history and streaming.
|
|
8
|
+
|
|
9
|
+
The loop dispatches to the SAME command functions the one-shot CLI uses, so behavior, the object
|
|
10
|
+
model, the run store, and the gate are all identical. The session adds no new engine path.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import shlex
|
|
16
|
+
import sys
|
|
17
|
+
import threading
|
|
18
|
+
import types
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
from . import codes
|
|
22
|
+
from . import config as _config
|
|
23
|
+
from . import engine
|
|
24
|
+
from . import gitio
|
|
25
|
+
from . import render as _render
|
|
26
|
+
from . import result as _result
|
|
27
|
+
|
|
28
|
+
_GREEN = "\033[32m"; _CYAN = "\033[36m"; _DIM = "\033[2m"; _BOLD = "\033[1m"; _RESET = "\033[0m"
|
|
29
|
+
|
|
30
|
+
_COMMANDS = ["fix", "failing", "write", "verify", "apply", "undo", "commit", "runs", "history",
|
|
31
|
+
"show", "explain", "replay", "attest", "scaffold-test", "summarize", "init", "doctor",
|
|
32
|
+
"coverage", "help", "exit", "quit"]
|
|
33
|
+
|
|
34
|
+
_ALL_COMMANDS = set(_COMMANDS) | {"summary", "scaffold", "log", "hist", "tests",
|
|
35
|
+
"login", "logout", "whoami", "?", "h", "q", "e", "bye"}
|
|
36
|
+
|
|
37
|
+
# commands whose argument is a run-id (for tab-completion + defaulting to the last run)
|
|
38
|
+
_RUNID_CMDS = {"apply", "undo", "commit", "show", "explain", "replay", "attest"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Session:
|
|
42
|
+
def __init__(self, repo: str, color: bool = True):
|
|
43
|
+
self.repo = os.path.abspath(repo)
|
|
44
|
+
self.color = color and sys.stdout.isatty()
|
|
45
|
+
self.last_run: Optional[str] = None
|
|
46
|
+
self.last_applied: Optional[str] = None
|
|
47
|
+
self._history: list = [] # session-scoped command history (bash-style !N / !! recall)
|
|
48
|
+
self._warm_done = threading.Event()
|
|
49
|
+
self._warm_ok = False
|
|
50
|
+
|
|
51
|
+
# ---- warm library (background) --------------------------------------
|
|
52
|
+
def start_warm(self) -> None:
|
|
53
|
+
def _w():
|
|
54
|
+
try:
|
|
55
|
+
self._warm_ok = engine.warm()
|
|
56
|
+
except Exception:
|
|
57
|
+
self._warm_ok = False
|
|
58
|
+
finally:
|
|
59
|
+
self._warm_done.set()
|
|
60
|
+
self._announce_warm() # ALWAYS resolve: ready OR "loads on first use", never silent
|
|
61
|
+
threading.Thread(target=_w, daemon=True).start()
|
|
62
|
+
|
|
63
|
+
def _announce_warm(self) -> None:
|
|
64
|
+
"""Print a one-line completion notice (on its own line) and redraw the prompt, so the banner's
|
|
65
|
+
background 'loading' state always resolves visibly instead of appearing stuck."""
|
|
66
|
+
from . import style as S
|
|
67
|
+
if self._warm_ok:
|
|
68
|
+
line = self._paint(" " + S.g("lamp") + " library ready", S.PROVEN)
|
|
69
|
+
else:
|
|
70
|
+
line = self._paint(" " + S.g("dot") + " library will load on first use", S.CHROME)
|
|
71
|
+
try:
|
|
72
|
+
sys.stdout.write("\n" + line + "\n")
|
|
73
|
+
sys.stdout.flush()
|
|
74
|
+
import readline
|
|
75
|
+
readline.redisplay()
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
def _await_warm(self) -> None:
|
|
80
|
+
"""fix needs the library; block on the warm if it is still loading, with a note."""
|
|
81
|
+
if not self._warm_done.is_set():
|
|
82
|
+
self._say(" loading the library (once per session)...", _DIM)
|
|
83
|
+
self._warm_done.wait()
|
|
84
|
+
|
|
85
|
+
# ---- rendering helpers ----------------------------------------------
|
|
86
|
+
def _paint(self, s: str, c: str) -> str:
|
|
87
|
+
return f"{c}{s}{_RESET}" if self.color else s
|
|
88
|
+
|
|
89
|
+
def _say(self, s: str, c: str = "") -> None:
|
|
90
|
+
sys.stdout.write((self._paint(s, c) if c else s) + "\n")
|
|
91
|
+
sys.stdout.flush()
|
|
92
|
+
|
|
93
|
+
def _render(self, res: "_result.SubstratumResult") -> None:
|
|
94
|
+
sys.stdout.write(_render.render_human(res, color="always" if self.color else "never"))
|
|
95
|
+
if res.verdict in (codes.VERIFIED, codes.PASS) and res.run_id.startswith("sub_"):
|
|
96
|
+
self.last_run = res.run_id
|
|
97
|
+
|
|
98
|
+
# ---- banner (the Sovereign Seal letterhead) --------------------------
|
|
99
|
+
def banner(self) -> None:
|
|
100
|
+
from . import style as S
|
|
101
|
+
c = self.color
|
|
102
|
+
pn = S.Painter(c)
|
|
103
|
+
name = os.path.basename(self.repo)
|
|
104
|
+
langs, files = self._repo_summary()
|
|
105
|
+
lib = engine.lib_roots.py_library_root()
|
|
106
|
+
libname = os.path.basename(lib) if lib else "none"
|
|
107
|
+
warm_lit = engine.is_warm()
|
|
108
|
+
dot = pn(S.g("dot"), S.CHROME)
|
|
109
|
+
# honest, self-resolving state: a live "warming" label the banner can never update reads as
|
|
110
|
+
# stuck; instead say it loads in the background (a "library ready" line prints when done).
|
|
111
|
+
if warm_lit:
|
|
112
|
+
state = pn(S.g("lamp") + " ready", S.PROVEN)
|
|
113
|
+
else:
|
|
114
|
+
state = pn("loading in background", S.CHROME)
|
|
115
|
+
self._say(pn(S.g("brand") + " SUBSTRATUM", S.BOLD) +
|
|
116
|
+
pn(f" proof-carrying code engine", S.LABEL))
|
|
117
|
+
self._say(pn(S.g("bar") * S.width(), S.CHROME))
|
|
118
|
+
self._say(f" {pn('REPO', S.LABEL)} {name} {pn(f'{files} files {dot} ' + (langs or ''), S.LABEL)}")
|
|
119
|
+
self._say(f" {pn('LIBRARY', S.LABEL)} {libname} {state}")
|
|
120
|
+
self._say(pn(S.g("bar") * S.width(), S.CHROME))
|
|
121
|
+
self._say(pn(" fix " + dot + " write " + dot + " verify " + dot + " summarize " + dot
|
|
122
|
+
+ " apply " + dot + " undo " + dot + " commit " + dot + " runs " + dot
|
|
123
|
+
+ " help " + dot + " /exit", S.LABEL))
|
|
124
|
+
self._say(pn(" new here? ", S.CHROME) + pn("just ask", S.GOLD)
|
|
125
|
+
+ pn(' — ', S.CHROME) + pn('"what does this codebase do?"', S.GOLD)
|
|
126
|
+
+ pn(" or type ", S.CHROME) + pn("summarize", S.GOLD))
|
|
127
|
+
|
|
128
|
+
def _repo_summary(self):
|
|
129
|
+
# SAME census as `summarize` (langscan.LANGS, prune dist/build) so the banner headline
|
|
130
|
+
# never contradicts the summarize CODEBASE line. Returns ("294 ts · 8 py · 2 js", total).
|
|
131
|
+
from . import langscan
|
|
132
|
+
counts: dict = {}
|
|
133
|
+
for root, dirs, fs in os.walk(self.repo):
|
|
134
|
+
dirs[:] = [d for d in dirs if d not in
|
|
135
|
+
(".git", "node_modules", ".venv", "venv", "__pycache__", ".substratum",
|
|
136
|
+
"dist", "build")]
|
|
137
|
+
for f in fs:
|
|
138
|
+
lang = langscan.LANGS.get(os.path.splitext(f)[1])
|
|
139
|
+
if lang:
|
|
140
|
+
counts[lang] = counts.get(lang, 0) + 1
|
|
141
|
+
total = sum(counts.values())
|
|
142
|
+
brk = " ".join(f"{n} {k}" for k, n in sorted(counts.items(), key=lambda x: -x[1]))
|
|
143
|
+
return brk, total
|
|
144
|
+
|
|
145
|
+
# ---- the dispatcher (pure; the REPL and tests both call this) --------
|
|
146
|
+
def handle(self, line: str):
|
|
147
|
+
"""Return (result_or_None, keep_going). Streams stages for fix."""
|
|
148
|
+
line = line.strip()
|
|
149
|
+
if not line:
|
|
150
|
+
return None, True
|
|
151
|
+
# bash-style session-history recall: !! (last), !N (by index), !<prefix> (last match)
|
|
152
|
+
if line.startswith("!") and line != "!":
|
|
153
|
+
recalled = self._recall(line[1:])
|
|
154
|
+
if recalled is None:
|
|
155
|
+
self._say(f" no history match for {line}", _CYAN)
|
|
156
|
+
return None, True
|
|
157
|
+
self._say(" " + self._paint(recalled, _DIM)) # echo what is being re-run
|
|
158
|
+
line = recalled
|
|
159
|
+
try:
|
|
160
|
+
parts = shlex.split(line)
|
|
161
|
+
except ValueError:
|
|
162
|
+
self._say(" parse error", _CYAN)
|
|
163
|
+
return None, True
|
|
164
|
+
cmd, args = parts[0], parts[1:]
|
|
165
|
+
if cmd and cmd[0] in "/:\\": # Claude-Code-style slash/colon commands: /exit, :q, ...
|
|
166
|
+
cmd = cmd[1:]
|
|
167
|
+
# record every real command in the session history (not history/exit itself)
|
|
168
|
+
if cmd.lower() not in ("history", "hist", "exit", "quit", "q", "e", "bye"):
|
|
169
|
+
self._history.append(line)
|
|
170
|
+
|
|
171
|
+
# natural-language prompt ("can you summarize this codebase?") -> route to a command,
|
|
172
|
+
# deterministically (closed-class, no LLM), echoing the interpretation. Structured commands
|
|
173
|
+
# (a known leading verb) bypass this entirely.
|
|
174
|
+
if cmd.lower() not in _ALL_COMMANDS:
|
|
175
|
+
routed = self._route_nl(line)
|
|
176
|
+
if routed is None:
|
|
177
|
+
self._say(" not a command. try: summarize " + "·" +
|
|
178
|
+
" fix <test> · verify · write \"...\" · help", _CYAN)
|
|
179
|
+
return None, True
|
|
180
|
+
cmd, args = routed
|
|
181
|
+
else:
|
|
182
|
+
cmd = cmd.lower()
|
|
183
|
+
|
|
184
|
+
if cmd in ("exit", "quit", "q", "e", "bye"):
|
|
185
|
+
return None, False
|
|
186
|
+
if cmd in ("help", "?", "h"):
|
|
187
|
+
self._help()
|
|
188
|
+
return None, True
|
|
189
|
+
if cmd == "coverage" or cmd == "init":
|
|
190
|
+
from .commands.init_cmd import cmd_init
|
|
191
|
+
return cmd_init(self.repo, run_flake=(cmd == "init")), True
|
|
192
|
+
if cmd == "doctor":
|
|
193
|
+
from .commands.init_cmd import cmd_doctor
|
|
194
|
+
return cmd_doctor(self.repo), True
|
|
195
|
+
if cmd in ("failing", "tests"):
|
|
196
|
+
return self._failing(args), True
|
|
197
|
+
if cmd == "fix":
|
|
198
|
+
return self._fix(args), True
|
|
199
|
+
if cmd == "write":
|
|
200
|
+
return self._write(args), True
|
|
201
|
+
if cmd in ("summarize", "summary"):
|
|
202
|
+
from . import product_ops
|
|
203
|
+
self._say(" reading the codebase...", _DIM)
|
|
204
|
+
return product_ops.op_summarize_repo(self.repo), True
|
|
205
|
+
if cmd == "verify":
|
|
206
|
+
return self._verify(args), True
|
|
207
|
+
if cmd in ("login", "logout", "whoami"):
|
|
208
|
+
from .commands import auth
|
|
209
|
+
{"login": auth.cmd_login, "logout": auth.cmd_logout, "whoami": auth.cmd_whoami}[cmd]()
|
|
210
|
+
return None, True
|
|
211
|
+
if cmd in ("history", "hist"):
|
|
212
|
+
self._history_cmd()
|
|
213
|
+
return None, True
|
|
214
|
+
if cmd in ("runs", "log"):
|
|
215
|
+
from .commands.history import cmd_runs
|
|
216
|
+
return cmd_runs(self.repo), True
|
|
217
|
+
if cmd == "apply":
|
|
218
|
+
return self._run_id_cmd("apply", args), True
|
|
219
|
+
if cmd == "undo":
|
|
220
|
+
return self._run_id_cmd("undo", args), True
|
|
221
|
+
if cmd == "commit":
|
|
222
|
+
return self._run_id_cmd("commit", args), True
|
|
223
|
+
if cmd == "show":
|
|
224
|
+
return self._run_id_cmd("show", args), True
|
|
225
|
+
if cmd == "explain":
|
|
226
|
+
return self._run_id_cmd("explain", args), True
|
|
227
|
+
if cmd == "replay":
|
|
228
|
+
return self._run_id_cmd("replay", args), True
|
|
229
|
+
if cmd == "attest":
|
|
230
|
+
return self._run_id_cmd("attest", args), True
|
|
231
|
+
if cmd in ("scaffold-test", "scaffold"):
|
|
232
|
+
from .commands.scaffold import cmd_scaffold_test
|
|
233
|
+
if not args:
|
|
234
|
+
self._say(" usage: scaffold-test <file.py::fn>", _CYAN)
|
|
235
|
+
return None, True
|
|
236
|
+
return cmd_scaffold_test(self.repo, args[0]), True
|
|
237
|
+
self._say(f" unknown command: {cmd} (try `help`)", _CYAN)
|
|
238
|
+
return None, True
|
|
239
|
+
|
|
240
|
+
# ---- session-scoped command history (bash-style recall) -------------
|
|
241
|
+
def _recall(self, token: str):
|
|
242
|
+
h = self._history
|
|
243
|
+
if not h:
|
|
244
|
+
return None
|
|
245
|
+
token = token.strip()
|
|
246
|
+
if token in ("", "!"): # !! -> the last command
|
|
247
|
+
return h[-1]
|
|
248
|
+
if token.lstrip("-").isdigit(): # !N (1-indexed) or !-N (from the end)
|
|
249
|
+
i = int(token)
|
|
250
|
+
idx = len(h) + i if i < 0 else i - 1
|
|
251
|
+
return h[idx] if 0 <= idx < len(h) else None
|
|
252
|
+
for c in reversed(h): # !<prefix> -> most recent match
|
|
253
|
+
if c.startswith(token):
|
|
254
|
+
return c
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
def _history_cmd(self):
|
|
258
|
+
if not self._history:
|
|
259
|
+
self._say(" no commands yet this session", _DIM)
|
|
260
|
+
return
|
|
261
|
+
self._say(self._paint(" session history", _BOLD))
|
|
262
|
+
start = max(1, len(self._history) - 29)
|
|
263
|
+
for i, c in enumerate(self._history[-30:], start):
|
|
264
|
+
self._say(f" {self._paint(str(i).rjust(3), _DIM)} {c}")
|
|
265
|
+
self._say(self._paint(" re-run: !<n> · !! · !<prefix>", _DIM))
|
|
266
|
+
|
|
267
|
+
# ---- natural-language router (closed-class, deterministic, no LLM) ----
|
|
268
|
+
def _route_nl(self, line: str):
|
|
269
|
+
"""Map a plain prompt to (command, args). Regex/keyword only; echoes the INTERPRETATION
|
|
270
|
+
(the CSF parser discipline). Returns None if no confident route."""
|
|
271
|
+
import re
|
|
272
|
+
low = line.lower().strip().rstrip("?.! ")
|
|
273
|
+
|
|
274
|
+
def interp(text):
|
|
275
|
+
self._say(f" interpreted as: {text}", _DIM)
|
|
276
|
+
|
|
277
|
+
# explicit `fix <id>` wins first, so a named test is never swallowed by summarize/verify.
|
|
278
|
+
# id = pytest nodeid / a .py path / a real test_* token (word-bounded, not any 'test' substring)
|
|
279
|
+
ids = [w for w in line.split()
|
|
280
|
+
if ("::" in w or w.endswith(".py") or re.search(r"(^|[/_])test_", w)
|
|
281
|
+
or re.search(r"_test($|[.:])", w)) and not w.startswith("-")]
|
|
282
|
+
if re.search(r"\bfix\b", low) and ids:
|
|
283
|
+
interp("fix " + " ".join(ids))
|
|
284
|
+
return ("fix", ids)
|
|
285
|
+
# discover failing tests (an observation) -- distinct from `fix <id>` and from `verify`
|
|
286
|
+
if "fix" not in low and (
|
|
287
|
+
re.search(r"\bfailing tests?\b", low)
|
|
288
|
+
or re.search(r"\b(find|show|list|what|which|any|are there|run)\b"
|
|
289
|
+
r".{0,24}\b(failing|broken|red|the)?\s*(tests?|suite)\b", low)
|
|
290
|
+
or re.search(r"\bwhat(?:'s| is| are)\b.{0,20}\b(failing|broken)\b", low)):
|
|
291
|
+
interp("find failing tests")
|
|
292
|
+
return ("failing", [])
|
|
293
|
+
# summarize the codebase -- bare describe/overview/what MUST name a codebase object
|
|
294
|
+
# (so "describe the algorithm" does NOT route); a literal 'summarize'/'tldr' is enough alone.
|
|
295
|
+
if re.search(r"\b(summar\w+|tl;?dr)\b", low) or \
|
|
296
|
+
re.search(r"\b(describe|overview|walk me through|what(?:'s| is| does| do| are)?)\b"
|
|
297
|
+
r".{0,40}\b(code|codebase|repo|repository|project|module|files?)\b", low):
|
|
298
|
+
interp("summarize the codebase")
|
|
299
|
+
return ("summarize", [])
|
|
300
|
+
# verify current changes -- 'verify' is unambiguous; gate/check/review/validate need a change object
|
|
301
|
+
if re.search(r"\bverif(?:y|ies)\b", low) or \
|
|
302
|
+
re.search(r"\b(gate|check|review|validate)\b.{0,30}"
|
|
303
|
+
r"\b(change|changes|diff|edit|edits|patch|work|code)\b", low):
|
|
304
|
+
interp("verify the current changes")
|
|
305
|
+
return ("verify", [])
|
|
306
|
+
# fix, no id named yet -- only on an explicit fix+failure phrase, not a bare 'fix'
|
|
307
|
+
if re.search(r"\bfix\b.{0,30}\b(test|tests|failing|failure|broken|red)\b", low) or \
|
|
308
|
+
re.search(r"\b(failing|broken)\b.{0,20}\btest", low):
|
|
309
|
+
interp("fix (name the failing test)")
|
|
310
|
+
return ("fix", [])
|
|
311
|
+
# run history / the proof ledger
|
|
312
|
+
if low in ("runs", "log") or \
|
|
313
|
+
re.search(r"\b(run history|recent runs|show (me )?(the )?runs|history of runs|"
|
|
314
|
+
r"the ledger|what have you (done|run|fixed|verified))\b", low):
|
|
315
|
+
interp("show run history")
|
|
316
|
+
return ("runs", [])
|
|
317
|
+
# undo / revert the last applied run
|
|
318
|
+
if re.search(r"\b(undo|revert|roll\s?back|take (it|that) back)\b", low):
|
|
319
|
+
interp("undo the last applied run")
|
|
320
|
+
return ("undo", [])
|
|
321
|
+
# commit the last verified run
|
|
322
|
+
if re.search(r"\bcommit\b", low):
|
|
323
|
+
interp("commit the last verified run")
|
|
324
|
+
return ("commit", [])
|
|
325
|
+
# coverage / capabilities
|
|
326
|
+
if re.search(r"\b(coverage|what can you (do|gate|handle)|what do you (support|cover))\b", low):
|
|
327
|
+
interp("show the coverage contract")
|
|
328
|
+
return ("coverage", [])
|
|
329
|
+
if re.search(r"\b(^help$|how do i|what commands|list commands)\b", low) or low == "help":
|
|
330
|
+
interp("help")
|
|
331
|
+
return ("help", [])
|
|
332
|
+
# write intent needs a signature -> guide rather than guess it
|
|
333
|
+
if re.search(r"\b(write|create|build|make|generate|implement)\b.{0,40}"
|
|
334
|
+
r"\b(function|method|class|code|helper)\b", low):
|
|
335
|
+
self._say(' to write from intent, name the shape: '
|
|
336
|
+
'write "<what it should do>" --sig "name(args)"', _CYAN)
|
|
337
|
+
return None
|
|
338
|
+
return None
|
|
339
|
+
|
|
340
|
+
# ---- command bodies --------------------------------------------------
|
|
341
|
+
def _failing(self, args):
|
|
342
|
+
"""Discover failing tests by running the repo's suite, then offer to fix one/all inline."""
|
|
343
|
+
from .commands.failing import cmd_failing
|
|
344
|
+
path = args[0] if args else None
|
|
345
|
+
self._say(" running the test suite (read-only)...", _DIM)
|
|
346
|
+
res = cmd_failing(self.repo, path=path)
|
|
347
|
+
f = (res.data or {}).get("failing") or []
|
|
348
|
+
if not f:
|
|
349
|
+
return res # all-pass / could-not-run -> just render it
|
|
350
|
+
self._render(res) # show the failing list
|
|
351
|
+
n = len(f)
|
|
352
|
+
try:
|
|
353
|
+
pick = input(self._paint(f" fix which? [1-{n} / a=all / enter to skip] ",
|
|
354
|
+
_CYAN)).strip().lower()
|
|
355
|
+
except (EOFError, KeyboardInterrupt):
|
|
356
|
+
sys.stdout.write("\n")
|
|
357
|
+
return None
|
|
358
|
+
if pick in ("a", "all"):
|
|
359
|
+
self._await_warm()
|
|
360
|
+
from . import product_ops
|
|
361
|
+
|
|
362
|
+
def emit(stage, text):
|
|
363
|
+
self._say(f" {stage:7} {text}", _DIM)
|
|
364
|
+
return product_ops.op_fix_from_failing_test(self.repo, f, emit=emit)
|
|
365
|
+
if pick.isdigit() and 1 <= int(pick) <= n:
|
|
366
|
+
return self._fix([f[int(pick) - 1]])
|
|
367
|
+
return None # skipped: the list was already shown
|
|
368
|
+
|
|
369
|
+
def _fix(self, args):
|
|
370
|
+
from . import product_ops
|
|
371
|
+
if not args:
|
|
372
|
+
self._say(" usage: fix <test-id> [<test-id> ...]", _CYAN)
|
|
373
|
+
return None
|
|
374
|
+
self._await_warm()
|
|
375
|
+
|
|
376
|
+
def emit(stage, text):
|
|
377
|
+
self._say(f" {stage:7} {text}", _DIM)
|
|
378
|
+
return product_ops.op_fix_from_failing_test(self.repo, list(args), emit=emit)
|
|
379
|
+
|
|
380
|
+
def _write(self, args):
|
|
381
|
+
from . import product_ops
|
|
382
|
+
# write <intent...> --sig "name(args)" [--into file]
|
|
383
|
+
sig, into, words = "", None, []
|
|
384
|
+
i = 0
|
|
385
|
+
while i < len(args):
|
|
386
|
+
if args[i] == "--sig" and i + 1 < len(args):
|
|
387
|
+
sig = args[i + 1]; i += 2; continue
|
|
388
|
+
if args[i] == "--into" and i + 1 < len(args):
|
|
389
|
+
into = args[i + 1]; i += 2; continue
|
|
390
|
+
words.append(args[i]); i += 1
|
|
391
|
+
intent = " ".join(words).strip().strip('"').strip("'")
|
|
392
|
+
if not intent or not sig:
|
|
393
|
+
self._say(' usage: write "<intent>" --sig "name(args)" [--into <file>]', _CYAN)
|
|
394
|
+
return None
|
|
395
|
+
self._await_warm()
|
|
396
|
+
|
|
397
|
+
def emit(stage, text):
|
|
398
|
+
self._say(f" {stage:7} {text}", _DIM)
|
|
399
|
+
res = product_ops.op_write_from_intent(self.repo, intent, sig, into=into, emit=emit)
|
|
400
|
+
|
|
401
|
+
# interactive clarification: show the plain-English options, take a pick, re-resolve
|
|
402
|
+
cl = res.data.get("clarify") if res.data else None
|
|
403
|
+
if cl and cl.get("options"):
|
|
404
|
+
self._render(res)
|
|
405
|
+
n = len(cl["options"])
|
|
406
|
+
try:
|
|
407
|
+
pick = input(self._paint(f" which? [1-{n}] (enter to skip) ", _CYAN)).strip()
|
|
408
|
+
except (EOFError, KeyboardInterrupt):
|
|
409
|
+
sys.stdout.write("\n")
|
|
410
|
+
return None
|
|
411
|
+
if pick.isdigit() and 1 <= int(pick) <= n:
|
|
412
|
+
chosen = cl["options"][int(pick) - 1]
|
|
413
|
+
self._say(f" resolving: {chosen['text']}", _DIM)
|
|
414
|
+
return product_ops.op_write_from_intent(
|
|
415
|
+
self.repo, intent, sig, into=into, emit=emit,
|
|
416
|
+
prefer_purpose=chosen.get("purpose"))
|
|
417
|
+
return None # skipped: the ASK was already shown
|
|
418
|
+
return res
|
|
419
|
+
|
|
420
|
+
def _verify(self, args):
|
|
421
|
+
from .commands.verify import run_verify
|
|
422
|
+
# flags: --staged, --test <t> (repeatable). default = working-tree diff.
|
|
423
|
+
staged = "--staged" in args
|
|
424
|
+
tests = [args[i + 1] for i, a in enumerate(args) if a == "--test" and i + 1 < len(args)]
|
|
425
|
+
diff = gitio.staged_diff(self.repo) if staged else gitio.diff_head(self.repo)
|
|
426
|
+
if not diff.strip():
|
|
427
|
+
self._say(" no changes to verify (working tree clean)", _DIM)
|
|
428
|
+
return None
|
|
429
|
+
return run_verify(self.repo, diff, tuple(tests))
|
|
430
|
+
|
|
431
|
+
def _run_id_cmd(self, name, args):
|
|
432
|
+
rid = args[0] if args else (self.last_applied or self.last_run)
|
|
433
|
+
if not rid:
|
|
434
|
+
self._say(f" usage: {name} <run-id> (or run a fix first)", _CYAN)
|
|
435
|
+
return None
|
|
436
|
+
if name == "apply":
|
|
437
|
+
from .commands.apply import cmd_apply
|
|
438
|
+
res = cmd_apply(self.repo, rid)
|
|
439
|
+
if res.verdict in (codes.VERIFIED, codes.PASS):
|
|
440
|
+
self.last_applied = rid # undo/commit default to the last applied run
|
|
441
|
+
return res
|
|
442
|
+
if name == "undo":
|
|
443
|
+
from .commands.undo import cmd_undo
|
|
444
|
+
res = cmd_undo(self.repo, rid)
|
|
445
|
+
if res.verdict in (codes.VERIFIED, codes.PASS):
|
|
446
|
+
self.last_applied = None
|
|
447
|
+
return res
|
|
448
|
+
if name == "commit":
|
|
449
|
+
from .commands.commit import cmd_commit
|
|
450
|
+
return cmd_commit(self.repo, rid)
|
|
451
|
+
if name == "show":
|
|
452
|
+
from .commands.show import cmd_show
|
|
453
|
+
return cmd_show(self.repo, rid)
|
|
454
|
+
if name == "replay":
|
|
455
|
+
from .commands.replay import cmd_replay
|
|
456
|
+
return cmd_replay(self.repo, rid)
|
|
457
|
+
from .commands.explain import cmd_explain, cmd_attest
|
|
458
|
+
return cmd_attest(self.repo, rid) if name == "attest" else cmd_explain(self.repo, rid)
|
|
459
|
+
|
|
460
|
+
def _help(self):
|
|
461
|
+
self._say(self._paint(" commands:", _BOLD))
|
|
462
|
+
for line in [
|
|
463
|
+
" failing run the suite, list failing tests, offer to fix one/all",
|
|
464
|
+
" fix <test> produce a verified fix for a failing test (or a named refusal)",
|
|
465
|
+
" write \"<intent>\" --sig \"name(args)\" derive code from intent (asks if ambiguous)",
|
|
466
|
+
" summarize describe this codebase in plain English",
|
|
467
|
+
" verify [--staged] gate your current changes (add --test <id> to name the warrant)",
|
|
468
|
+
" apply [<run>] apply a verified run (defaults to the last fix)",
|
|
469
|
+
" undo / commit [<run>] revert an applied run, or commit it with a proof message",
|
|
470
|
+
" runs the proof store: run history + refusal->verified ledger",
|
|
471
|
+
" history commands run this session (re-run with !<n> / !! / !<prefix>)",
|
|
472
|
+
" show / explain / replay / attest [<run>]",
|
|
473
|
+
" scaffold-test <t> emit a runnable failing test that pins intent",
|
|
474
|
+
" coverage | init what this repo can gate vs refuse; init also flake-scans",
|
|
475
|
+
" doctor harness / env / air-gap checks",
|
|
476
|
+
" help | /exit",
|
|
477
|
+
"",
|
|
478
|
+
" or just ask: \"can you summarize this codebase?\" \"verify my changes\" \"fix <test>\"",
|
|
479
|
+
]:
|
|
480
|
+
self._say(self._paint(line, _DIM))
|
|
481
|
+
|
|
482
|
+
# ---- the loop --------------------------------------------------------
|
|
483
|
+
def repl(self) -> int:
|
|
484
|
+
global _COMPLETER_REPO
|
|
485
|
+
_COMPLETER_REPO = self.repo # so tab-completion can offer this repo's run ids
|
|
486
|
+
try:
|
|
487
|
+
import readline # noqa: F401 (history + arrow keys, tab-complete below)
|
|
488
|
+
readline.set_completer(_completer)
|
|
489
|
+
readline.set_completer_delims(" \t")
|
|
490
|
+
readline.parse_and_bind("tab: complete")
|
|
491
|
+
# slash-preview: a single tab reveals + narrows the command menu as you type
|
|
492
|
+
readline.parse_and_bind("set show-all-if-ambiguous on")
|
|
493
|
+
readline.parse_and_bind("set show-all-if-unmodified on")
|
|
494
|
+
readline.parse_and_bind("set completion-ignore-case on")
|
|
495
|
+
except Exception:
|
|
496
|
+
pass
|
|
497
|
+
self.start_warm()
|
|
498
|
+
self.banner()
|
|
499
|
+
from . import style as S
|
|
500
|
+
prompt = (S.GOLD + S.g("brand") + " " + S.RESET) if self.color else (S.g("brand") + " ")
|
|
501
|
+
while True:
|
|
502
|
+
try:
|
|
503
|
+
line = input(prompt)
|
|
504
|
+
except EOFError:
|
|
505
|
+
sys.stdout.write("\n")
|
|
506
|
+
return 0
|
|
507
|
+
except KeyboardInterrupt:
|
|
508
|
+
sys.stdout.write("\n")
|
|
509
|
+
continue
|
|
510
|
+
try:
|
|
511
|
+
res, keep = self.handle(line)
|
|
512
|
+
if res is not None:
|
|
513
|
+
self._render(res)
|
|
514
|
+
except KeyboardInterrupt:
|
|
515
|
+
# Ctrl-C cancels the CURRENT action and drops back to the prompt; the warm library
|
|
516
|
+
# (loaded once, the whole point of the session) survives. Never a traceback.
|
|
517
|
+
sys.stdout.write("\n")
|
|
518
|
+
self._say(" cancelled (session still warm)", _CYAN)
|
|
519
|
+
continue
|
|
520
|
+
except Exception as e: # a command fault must not kill the session
|
|
521
|
+
self._say(f" error: {e}", _CYAN)
|
|
522
|
+
continue
|
|
523
|
+
if not keep:
|
|
524
|
+
return 0
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
_COMPLETER_REPO = None # set by repl() so the completer can offer this repo's run ids
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _completer(text, state):
|
|
531
|
+
# completing the ARGUMENT of a run-id command -> offer stored run ids
|
|
532
|
+
try:
|
|
533
|
+
import readline
|
|
534
|
+
line = readline.get_line_buffer()
|
|
535
|
+
except Exception:
|
|
536
|
+
line = text
|
|
537
|
+
parts = line.split()
|
|
538
|
+
first = parts[0].lstrip("/:\\").lower() if parts else ""
|
|
539
|
+
completing_arg = bool(parts) and (len(parts) >= 2 or line.endswith(" "))
|
|
540
|
+
if first in _RUNID_CMDS and completing_arg and _COMPLETER_REPO:
|
|
541
|
+
try:
|
|
542
|
+
from . import run_store
|
|
543
|
+
ids = [r for r in run_store.run_ids(_COMPLETER_REPO) if r.startswith(text)]
|
|
544
|
+
except Exception:
|
|
545
|
+
ids = []
|
|
546
|
+
return ids[state] if state < len(ids) else None
|
|
547
|
+
# otherwise complete the command verb (slash/colon-prefixed too: '/f' -> '/fix')
|
|
548
|
+
pfx = text[:1] if text[:1] in "/:" else ""
|
|
549
|
+
body = text[len(pfx):].lower()
|
|
550
|
+
opts = [pfx + c for c in _COMMANDS if c.startswith(body)]
|
|
551
|
+
return opts[state] if state < len(opts) else None
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def run_session(repo: str, color: bool = True) -> int:
|
|
555
|
+
if not gitio.is_git_repo(os.path.abspath(repo)):
|
|
556
|
+
sys.stderr.write("substratum: not a git repository. cd into a repo first.\n")
|
|
557
|
+
return codes.EXIT_USAGE
|
|
558
|
+
return Session(repo, color=color).repl()
|