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,193 @@
|
|
|
1
|
+
"""`substratum verify` -- gate ANY diff (Cursor/Copilot output, a paste, a commit). The wedge.
|
|
2
|
+
|
|
3
|
+
Reads a unified diff from stdin/-/--diff/--staged/--commit, materializes it against the base,
|
|
4
|
+
runs the differential client gate (fail-at-base -> pass-with-patch), and for a multi-file diff
|
|
5
|
+
attributes a per-hunk verdict. The gate proves BEHAVIOR, never authorship -- the `note:` line
|
|
6
|
+
keeps that honest. This command leaf-loads client_gate; it never pays the numpy chain.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from .. import codes
|
|
15
|
+
from .. import diffs
|
|
16
|
+
from .. import engine
|
|
17
|
+
from .. import gitio
|
|
18
|
+
from .. import refusals
|
|
19
|
+
from .. import result as _result
|
|
20
|
+
from .. import run_store
|
|
21
|
+
from .. import runid
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _read_diff(args) -> str:
|
|
25
|
+
if args.staged:
|
|
26
|
+
return gitio.staged_diff(os.path.abspath(args.repo))
|
|
27
|
+
if args.commit:
|
|
28
|
+
return gitio.commit_diff(os.path.abspath(args.repo), args.commit)
|
|
29
|
+
src = getattr(args, "target", "-") or "-"
|
|
30
|
+
if args.diff or (src != "-" and os.path.isfile(src)):
|
|
31
|
+
path = src if src != "-" else None
|
|
32
|
+
if path:
|
|
33
|
+
return open(path, encoding="utf-8", errors="replace").read()
|
|
34
|
+
return sys.stdin.read()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _base_content(repo: str, base: str, path: str) -> str:
|
|
38
|
+
return gitio.show(repo, base, path) or ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def cmd_verify(args) -> "_result.SubstratumResult":
|
|
42
|
+
repo = os.path.abspath(args.repo)
|
|
43
|
+
if not gitio.is_git_repo(repo):
|
|
44
|
+
rid, _ = runid.compute(repo, "verify", {"err": "not_git"})
|
|
45
|
+
return _result.make(codes.REFUSED, rid, "verify", code=codes.GATE_PRECONDITION,
|
|
46
|
+
refusal=refusals.build(codes.GATE_PRECONDITION,
|
|
47
|
+
ctx={"target": repo, "run_id": rid}))
|
|
48
|
+
base = args.commit + "~1" if args.commit else gitio.head_sha(repo)
|
|
49
|
+
diff_text = _read_diff(args)
|
|
50
|
+
return run_verify(repo, diff_text, tuple(args.test or ()), base=base,
|
|
51
|
+
test_command=args.test_command)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def run_verify(repo, diff_text, tests, base=None, test_command=None):
|
|
55
|
+
"""The verify core, callable directly (the session gates the working diff with this)."""
|
|
56
|
+
repo = os.path.abspath(repo)
|
|
57
|
+
tests = tuple(tests or ())
|
|
58
|
+
if base is None:
|
|
59
|
+
base = gitio.head_sha(repo)
|
|
60
|
+
rid, key = runid.compute(repo, "verify",
|
|
61
|
+
{"diff_sha": _sha(diff_text), "tests": sorted(tests),
|
|
62
|
+
"base": base, "test_cmd": test_command})
|
|
63
|
+
|
|
64
|
+
files = diffs.parse(diff_text)
|
|
65
|
+
if not files:
|
|
66
|
+
return _store(repo, _result.make(codes.REFUSED, rid, "verify",
|
|
67
|
+
code=codes.NO_WARRANT_TESTS,
|
|
68
|
+
refusal=refusals.build(codes.NO_WARRANT_TESTS,
|
|
69
|
+
ctx={"target": "the diff", "run_id": rid})), key)
|
|
70
|
+
if not tests:
|
|
71
|
+
return _store(repo, _result.make(codes.UNVERIFIABLE, rid, "verify",
|
|
72
|
+
code=codes.NO_WARRANT_TESTS,
|
|
73
|
+
refusal=refusals.build(codes.NO_WARRANT_TESTS,
|
|
74
|
+
ctx={"target": files[0].path, "run_id": rid})), key)
|
|
75
|
+
|
|
76
|
+
base_by_path = {f.path: _base_content(repo, base, f.path) for f in files}
|
|
77
|
+
edits, stale = diffs.materialize(base_by_path, files)
|
|
78
|
+
if not edits:
|
|
79
|
+
return _store(repo, _result.make(codes.UNVERIFIABLE, rid, "verify",
|
|
80
|
+
code=codes.HUNK_CONTEXT_STALE,
|
|
81
|
+
refusal=refusals.build(codes.HUNK_CONTEXT_STALE,
|
|
82
|
+
ctx={"target": ", ".join(stale), "run_id": rid})), key)
|
|
83
|
+
|
|
84
|
+
# ---- headline gate: the whole diff, differential ----
|
|
85
|
+
sink: dict = {}
|
|
86
|
+
cg = engine.client_gate.run_client_gate(repo, base, edits, test_paths=list(tests),
|
|
87
|
+
test_cmd=test_command, differential=True,
|
|
88
|
+
transcript_sink=sink)
|
|
89
|
+
gate = _gate_transcript(sink, tests, cg)
|
|
90
|
+
|
|
91
|
+
if cg.code == "GATE_TESTS_PASS_AT_BASE":
|
|
92
|
+
return _store(repo, _result.make(codes.REFUSED, rid, "verify",
|
|
93
|
+
code=codes.FLAKY_OR_VACUOUS_WARRANT, gate=gate,
|
|
94
|
+
refusal=refusals.build(codes.FLAKY_OR_VACUOUS_WARRANT,
|
|
95
|
+
ctx={"target": ", ".join(tests), "run_id": rid},
|
|
96
|
+
verified_before=(f"base run: {gate.base_status}",))), key)
|
|
97
|
+
if not cg.passed and cg.code != "TEST_FAILED":
|
|
98
|
+
ref = refusals.from_engine(cg.code, ctx={"target": ", ".join(tests), "run_id": rid})
|
|
99
|
+
return _store(repo, _result.make(codes.UNVERIFIABLE, rid, "verify",
|
|
100
|
+
code=ref.code, gate=gate, refusal=ref), key)
|
|
101
|
+
|
|
102
|
+
# ---- per-hunk attribution (multi-file / multi-hunk) ----
|
|
103
|
+
hunks_verdicts = _attribute(repo, base, base_by_path, files, edits, tests,
|
|
104
|
+
test_command, whole_passed=cg.passed)
|
|
105
|
+
note = "this verdict gates the DIFF, not its provenance (external code)."
|
|
106
|
+
if cg.passed:
|
|
107
|
+
verdict, code = codes.PASS, None
|
|
108
|
+
else:
|
|
109
|
+
verdict, code = codes.FAIL, None # TEST_FAILED -> diff proven wrong
|
|
110
|
+
res = _result.make(verdict, rid, "verify", code=code, gate=gate,
|
|
111
|
+
files=tuple(sorted(edits)), hunks=tuple(hunks_verdicts),
|
|
112
|
+
data={"note": note, "stale": stale})
|
|
113
|
+
return _store(repo, res, key)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _attribute(repo, base, base_by_path, files, edits, tests, test_cmd, whole_passed):
|
|
117
|
+
"""Per-file leave-one-out (whole passed) or singleton (whole failed) attribution, budgeted."""
|
|
118
|
+
out = []
|
|
119
|
+
multi = sum(len(f.hunks) for f in files) > 1
|
|
120
|
+
if not multi:
|
|
121
|
+
f = files[0]
|
|
122
|
+
out.append(_result.HunkVerdict(f.path, 0, f.hunks[0].header,
|
|
123
|
+
codes.PASS if whole_passed else codes.FAIL))
|
|
124
|
+
return out
|
|
125
|
+
budget = 8
|
|
126
|
+
runs = 0
|
|
127
|
+
for f in files:
|
|
128
|
+
if f.path not in edits:
|
|
129
|
+
out.append(_result.HunkVerdict(f.path, 0, "stale", codes.UNVERIFIABLE,
|
|
130
|
+
code=codes.HUNK_CONTEXT_STALE,
|
|
131
|
+
next_command=f"substratum scaffold-test {f.path}"))
|
|
132
|
+
continue
|
|
133
|
+
if runs >= budget:
|
|
134
|
+
out.append(_result.HunkVerdict(f.path, 0, f.hunks[0].header,
|
|
135
|
+
codes.PASS if whole_passed else codes.UNVERIFIABLE,
|
|
136
|
+
code=None if whole_passed else codes.UNDERDETERMINED_INTENT))
|
|
137
|
+
continue
|
|
138
|
+
if whole_passed:
|
|
139
|
+
minus = {p: c for p, c in edits.items() if p != f.path}
|
|
140
|
+
runs += 1
|
|
141
|
+
cg = engine.client_gate.run_client_gate(repo, base, minus, test_paths=list(tests),
|
|
142
|
+
test_cmd=test_cmd, differential=True)
|
|
143
|
+
# ablation FAILS tests -> this file is load-bearing -> PASS
|
|
144
|
+
load_bearing = (not cg.passed and cg.code == "TEST_FAILED")
|
|
145
|
+
out.append(_result.HunkVerdict(
|
|
146
|
+
f.path, 0, f.hunks[0].header,
|
|
147
|
+
codes.PASS if load_bearing else codes.UNVERIFIABLE,
|
|
148
|
+
code=None if load_bearing else codes.UNDERDETERMINED_INTENT,
|
|
149
|
+
next_command=None if load_bearing else f"substratum scaffold-test {f.path}"))
|
|
150
|
+
else:
|
|
151
|
+
only = {f.path: edits[f.path]}
|
|
152
|
+
runs += 1
|
|
153
|
+
cg = engine.client_gate.run_client_gate(repo, base, only, test_paths=list(tests),
|
|
154
|
+
test_cmd=test_cmd, differential=True)
|
|
155
|
+
if cg.passed:
|
|
156
|
+
out.append(_result.HunkVerdict(f.path, 0, f.hunks[0].header, codes.PASS))
|
|
157
|
+
elif cg.code == "TEST_FAILED":
|
|
158
|
+
out.append(_result.HunkVerdict(f.path, 0, f.hunks[0].header, codes.FAIL))
|
|
159
|
+
else:
|
|
160
|
+
out.append(_result.HunkVerdict(f.path, 0, f.hunks[0].header, codes.UNVERIFIABLE,
|
|
161
|
+
code=codes.map_engine_code(cg.code)))
|
|
162
|
+
return out
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _gate_transcript(sink: dict, tests, cg) -> "_result.GateTranscript":
|
|
166
|
+
base_rc = sink.get("base_rc")
|
|
167
|
+
patched_rc = sink.get("patched_rc")
|
|
168
|
+
return _result.GateTranscript(
|
|
169
|
+
kind="client_differential", test_ids=tuple(tests),
|
|
170
|
+
runner_command=tuple(sink.get("runner_command", ())),
|
|
171
|
+
base_status=_status(base_rc, at_base=True),
|
|
172
|
+
patched_status=_status(patched_rc, at_base=False),
|
|
173
|
+
base_excerpt=(sink.get("base_out", "") or "")[-800:],
|
|
174
|
+
patched_excerpt=(sink.get("patched_out", "") or "")[-800:],
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _status(rc: Optional[int], at_base: bool) -> str:
|
|
179
|
+
if rc is None:
|
|
180
|
+
return ""
|
|
181
|
+
if at_base:
|
|
182
|
+
return "FAIL" if rc != 0 else "PASS"
|
|
183
|
+
return "PASS" if rc == 0 else "FAIL"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _store(repo, res, key):
|
|
187
|
+
run_store.store_run(repo, res, run_key=key)
|
|
188
|
+
return res
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _sha(text: str) -> str:
|
|
192
|
+
import hashlib
|
|
193
|
+
return hashlib.sha256(text.encode("utf-8", "replace")).hexdigest()[:16]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""`substratum write "<intent>" --sig "name(args)"` -- greenfield code from intent.
|
|
2
|
+
|
|
3
|
+
Derivation, not generation: the intent is comprehended, the construction is derived from the
|
|
4
|
+
granular productions + grounding, verified intrinsically, and emitted or refused. No test is
|
|
5
|
+
required and none determines the answer -- resolve iff the intent is fully understood and the
|
|
6
|
+
construction is derivable, else a named refusal. Thin over product_ops.op_write_from_intent."""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from .. import codes
|
|
13
|
+
from .. import gitio
|
|
14
|
+
from .. import refusals
|
|
15
|
+
from .. import result as _result
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def cmd_write(args) -> "_result.SubstratumResult":
|
|
19
|
+
from .. import product_ops
|
|
20
|
+
repo = os.path.abspath(args.repo)
|
|
21
|
+
if not gitio.is_git_repo(repo):
|
|
22
|
+
return _result.make(codes.REFUSED, "sub_000000000000", "write",
|
|
23
|
+
code=codes.GATE_PRECONDITION,
|
|
24
|
+
refusal=refusals.build(codes.GATE_PRECONDITION,
|
|
25
|
+
ctx={"target": repo, "run_id": "sub_000000000000"}))
|
|
26
|
+
intent = getattr(args, "intent", "") or ""
|
|
27
|
+
if intent and os.path.isfile(intent):
|
|
28
|
+
intent = open(intent, encoding="utf-8", errors="replace").read()
|
|
29
|
+
|
|
30
|
+
emit = None
|
|
31
|
+
if getattr(args, "events", False):
|
|
32
|
+
import json
|
|
33
|
+
|
|
34
|
+
def emit(stage, text):
|
|
35
|
+
sys.stdout.write(json.dumps({"stage": stage, "text": text}) + "\n"); sys.stdout.flush()
|
|
36
|
+
elif not getattr(args, "json", False):
|
|
37
|
+
def emit(stage, text):
|
|
38
|
+
sys.stderr.write(f" {stage:7} {text}\n"); sys.stderr.flush()
|
|
39
|
+
|
|
40
|
+
return product_ops.op_write_from_intent(
|
|
41
|
+
repo, intent, getattr(args, "sig", ""), into=getattr(args, "into", None), emit=emit,
|
|
42
|
+
prefer_purpose=getattr(args, "mean", None))
|
substratum_cli/config.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""`.substratum.toml` reader. Autonomy ladder: L1 Suggest is the default (print, never write;
|
|
2
|
+
apply is the only writer). L2/L3 are PARSED but LOCKED in v1 -- an L2/L3 action refuses
|
|
3
|
+
AUTONOMY_LOCKED. Config keys: autonomy, output, drivers, limits. Stdlib tomllib only."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import tomllib # py3.11+
|
|
10
|
+
except ModuleNotFoundError: # pragma: no cover
|
|
11
|
+
tomllib = None
|
|
12
|
+
|
|
13
|
+
_DEFAULTS = {
|
|
14
|
+
"autonomy": {"level": "L1", "automerge_classes": []},
|
|
15
|
+
"output": {"refusal_verbosity": "always"},
|
|
16
|
+
"drivers": {},
|
|
17
|
+
"limits": {"attempts_per_target": 3, "runner_timeout": 600, "max_gate_runs": 8},
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load(repo: str) -> dict:
|
|
22
|
+
cfg = {k: dict(v) if isinstance(v, dict) else v for k, v in _DEFAULTS.items()}
|
|
23
|
+
path = os.path.join(repo, ".substratum.toml")
|
|
24
|
+
if tomllib and os.path.exists(path):
|
|
25
|
+
try:
|
|
26
|
+
with open(path, "rb") as fh:
|
|
27
|
+
user = tomllib.load(fh)
|
|
28
|
+
for section, vals in user.items():
|
|
29
|
+
if section in cfg and isinstance(cfg[section], dict) and isinstance(vals, dict):
|
|
30
|
+
cfg[section].update(vals)
|
|
31
|
+
else:
|
|
32
|
+
cfg[section] = vals
|
|
33
|
+
except Exception:
|
|
34
|
+
pass
|
|
35
|
+
return cfg
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def autonomy_level(repo: str) -> str:
|
|
39
|
+
return str(load(repo).get("autonomy", {}).get("level", "L1")).upper()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_write_allowed(repo: str, action: str) -> bool:
|
|
43
|
+
"""L1: only `apply` (an explicit user action) writes. L2/L3 are locked in v1."""
|
|
44
|
+
level = autonomy_level(repo)
|
|
45
|
+
if action == "apply":
|
|
46
|
+
return True # apply is always an explicit, user-invoked write
|
|
47
|
+
if level in ("L2", "L3"):
|
|
48
|
+
return False # visible-but-locked: the config declares ambition, not power
|
|
49
|
+
return False
|
substratum_cli/diffs.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Unified-diff parsing into per-file hunks, and materialization of patched whole-file content
|
|
2
|
+
(the gate takes {path: content}, not hunks). Stdlib only."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Dict, List, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
_FILE_RE = re.compile(r"^\+\+\+ b/(.+)$")
|
|
10
|
+
_HUNK_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Hunk:
|
|
15
|
+
header: str
|
|
16
|
+
old_start: int
|
|
17
|
+
old_len: int
|
|
18
|
+
new_start: int
|
|
19
|
+
new_len: int
|
|
20
|
+
lines: List[str] = field(default_factory=list) # with leading ' '/'+'/'-'
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class FileDiff:
|
|
25
|
+
path: str
|
|
26
|
+
hunks: List[Hunk] = field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse(diff_text: str) -> List[FileDiff]:
|
|
30
|
+
files: List[FileDiff] = []
|
|
31
|
+
cur: Optional[FileDiff] = None
|
|
32
|
+
cur_hunk: Optional[Hunk] = None
|
|
33
|
+
for line in diff_text.splitlines():
|
|
34
|
+
m = _FILE_RE.match(line)
|
|
35
|
+
if m:
|
|
36
|
+
cur = FileDiff(path=m.group(1))
|
|
37
|
+
files.append(cur)
|
|
38
|
+
cur_hunk = None
|
|
39
|
+
continue
|
|
40
|
+
if line.startswith("--- ") or line.startswith("diff --git") or line.startswith("index "):
|
|
41
|
+
continue
|
|
42
|
+
hm = _HUNK_RE.match(line)
|
|
43
|
+
if hm and cur is not None:
|
|
44
|
+
cur_hunk = Hunk(header=line,
|
|
45
|
+
old_start=int(hm.group(1)), old_len=int(hm.group(2) or 1),
|
|
46
|
+
new_start=int(hm.group(3)), new_len=int(hm.group(4) or 1))
|
|
47
|
+
cur.hunks.append(cur_hunk)
|
|
48
|
+
continue
|
|
49
|
+
if cur_hunk is not None and (not line or line[0] in " +-\\"):
|
|
50
|
+
cur_hunk.lines.append(line)
|
|
51
|
+
return [f for f in files if f.hunks]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def apply_hunks(base_content: str, hunks: List[Hunk]) -> Optional[str]:
|
|
55
|
+
"""Apply hunks to base with EXACT context matching. Returns new content, or None if any
|
|
56
|
+
hunk's context does not match (context stale -> the caller refuses, never fuzzes)."""
|
|
57
|
+
base_lines = base_content.splitlines(keepends=False)
|
|
58
|
+
out: List[str] = []
|
|
59
|
+
cursor = 0 # index into base_lines already consumed
|
|
60
|
+
for h in sorted(hunks, key=lambda x: x.old_start):
|
|
61
|
+
start = h.old_start - 1 # 0-indexed
|
|
62
|
+
if start < cursor or start > len(base_lines):
|
|
63
|
+
return None
|
|
64
|
+
out.extend(base_lines[cursor:start])
|
|
65
|
+
bi = start
|
|
66
|
+
for dl in h.lines:
|
|
67
|
+
if not dl:
|
|
68
|
+
# blank context line
|
|
69
|
+
if bi < len(base_lines) and base_lines[bi] == "":
|
|
70
|
+
out.append("")
|
|
71
|
+
bi += 1
|
|
72
|
+
continue
|
|
73
|
+
tag, text = dl[0], dl[1:]
|
|
74
|
+
if tag == " ":
|
|
75
|
+
if bi >= len(base_lines) or base_lines[bi] != text:
|
|
76
|
+
return None
|
|
77
|
+
out.append(text)
|
|
78
|
+
bi += 1
|
|
79
|
+
elif tag == "-":
|
|
80
|
+
if bi >= len(base_lines) or base_lines[bi] != text:
|
|
81
|
+
return None
|
|
82
|
+
bi += 1
|
|
83
|
+
elif tag == "+":
|
|
84
|
+
out.append(text)
|
|
85
|
+
elif tag == "\\":
|
|
86
|
+
pass # ""
|
|
87
|
+
cursor = bi
|
|
88
|
+
out.extend(base_lines[cursor:])
|
|
89
|
+
trailing = "\n" if base_content.endswith("\n") else ""
|
|
90
|
+
return "\n".join(out) + trailing
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def materialize(base_by_path: Dict[str, str],
|
|
94
|
+
files: List[FileDiff]) -> Tuple[Dict[str, str], List[str]]:
|
|
95
|
+
"""{path: patched content} for every file whose hunks apply cleanly; the second list is
|
|
96
|
+
paths with stale context (excluded, never fuzzed)."""
|
|
97
|
+
edits: Dict[str, str] = {}
|
|
98
|
+
stale: List[str] = []
|
|
99
|
+
for f in files:
|
|
100
|
+
base = base_by_path.get(f.path, "")
|
|
101
|
+
new = apply_hunks(base, f.hunks)
|
|
102
|
+
if new is None:
|
|
103
|
+
stale.append(f.path)
|
|
104
|
+
else:
|
|
105
|
+
edits[f.path] = new
|
|
106
|
+
return edits, stale
|
substratum_cli/engine.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""The lazy engine bridge -- the ONLY module in substratum_cli that reaches into backend.*.
|
|
2
|
+
|
|
3
|
+
The import bomb: `backend/substratum_v2/__init__.py` eagerly imports the numpy-heavy claim
|
|
4
|
+
pipeline, so a normal `from backend...` costs that chain (and dies without numpy). Two
|
|
5
|
+
strategies keep light commands fast and numpy-free:
|
|
6
|
+
|
|
7
|
+
1. LEAF LOAD by file path (importlib.util.spec_from_file_location) for modules verified to be
|
|
8
|
+
stdlib-only with NO relative imports: `client_gate.py`, `lib_roots.py`. These are loaded
|
|
9
|
+
without ever executing any `backend` package __init__, so `verify` and run-id hashing pay
|
|
10
|
+
nothing.
|
|
11
|
+
2. HEAVY IMPORT via the normal package path, done INSIDE the fix code path only, after the
|
|
12
|
+
first progress event has streamed (the user sees GROUND before the seconds-scale import).
|
|
13
|
+
|
|
14
|
+
Every attribute here is a lazy accessor; importing this module opens nothing.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import importlib.util
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from types import ModuleType
|
|
22
|
+
|
|
23
|
+
REPO_ROOT = os.environ.get("SUBSTRATUM_ENGINE_ROOT", "/home/thepadrevictor/Substratum")
|
|
24
|
+
_ENGINE_DIR = os.path.join(REPO_ROOT, "backend", "substratum_v2", "code_generation_v2")
|
|
25
|
+
_LEAF_CACHE: dict[str, ModuleType] = {}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _leaf(name: str) -> ModuleType:
|
|
29
|
+
"""Load `<engine>/<name>.py` in isolation (no backend __init__), cached."""
|
|
30
|
+
if name in _LEAF_CACHE:
|
|
31
|
+
return _LEAF_CACHE[name]
|
|
32
|
+
path = os.path.join(_ENGINE_DIR, f"{name}.py")
|
|
33
|
+
spec = importlib.util.spec_from_file_location(f"_substratum_leaf_{name}", path)
|
|
34
|
+
if spec is None or spec.loader is None:
|
|
35
|
+
raise ImportError(f"cannot leaf-load {path}")
|
|
36
|
+
mod = importlib.util.module_from_spec(spec)
|
|
37
|
+
# register so dataclasses/pickling inside the module resolve their own module name
|
|
38
|
+
sys.modules[spec.name] = mod
|
|
39
|
+
spec.loader.exec_module(mod)
|
|
40
|
+
_LEAF_CACHE[name] = mod
|
|
41
|
+
return mod
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _LazyAttr:
|
|
45
|
+
"""Access `.foo` -> leaf-load module `name` and return its `foo` (or the module if attr=None)."""
|
|
46
|
+
def __init__(self, name: str):
|
|
47
|
+
self._name = name
|
|
48
|
+
|
|
49
|
+
def __getattr__(self, attr: str):
|
|
50
|
+
return getattr(_leaf(self._name), attr)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---- leaf modules (fast, numpy-free) ----
|
|
54
|
+
client_gate = _LazyAttr("client_gate") # run_client_gate, resolve_test_command, ClientGateResult
|
|
55
|
+
lib_roots = _LazyAttr("lib_roots") # py_/go_/ts_library_root, defaults
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def coverage_registries() -> dict:
|
|
59
|
+
"""The LIVE driver registries for the Coverage Contract, read from the engine (not copied,
|
|
60
|
+
so no drift). All leaf-loaded -> zero numpy. Returns the extension buckets."""
|
|
61
|
+
nc = _leaf("noncode_drivers")
|
|
62
|
+
out = {"driven_noncode": set(getattr(nc, "_DRIVERS", {}).keys()),
|
|
63
|
+
"template_exts": set(), "known_undriven": set(getattr(nc, "_KNOWN_UNDRIVEN", ())),
|
|
64
|
+
"code_langs": {".py": "py", ".go": "go", ".ts": "ts", ".tsx": "ts",
|
|
65
|
+
".js": "ts", ".jsx": "ts"}}
|
|
66
|
+
try:
|
|
67
|
+
out["driven_noncode"] |= set(getattr(_leaf("struct_resolve"), "STRUCT_EXTS", ()))
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
try:
|
|
71
|
+
out["template_exts"] = set(getattr(_leaf("template_resolve"), "TEMPLATE_EXTS", ()))
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def canonical_diff():
|
|
78
|
+
"""canonical_diff_builder.build_file_diff -- audited for import-cleanliness at call time;
|
|
79
|
+
falls back to the heavy package path if it has relative imports."""
|
|
80
|
+
try:
|
|
81
|
+
return _leaf("canonical_diff_builder").build_file_diff
|
|
82
|
+
except Exception:
|
|
83
|
+
_ensure_repo_on_path()
|
|
84
|
+
from backend.substratum_v2.code_generation_v2.canonical_diff_builder import build_file_diff
|
|
85
|
+
return build_file_diff
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---- heavy path (numpy chain paid here, inside the fix op only) ----
|
|
89
|
+
def _ensure_repo_on_path():
|
|
90
|
+
if REPO_ROOT not in sys.path:
|
|
91
|
+
sys.path.insert(0, REPO_ROOT)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def available() -> bool:
|
|
95
|
+
"""Is the heavy engine importable here? A client-only (`pip install substratum-cli`) install
|
|
96
|
+
has no numpy, so numpy is the cheap discriminator between the thin client and a full engine
|
|
97
|
+
checkout. When False, the fix/write local path refuses cleanly instead of crashing."""
|
|
98
|
+
try:
|
|
99
|
+
import numpy # noqa: F401
|
|
100
|
+
return os.path.isdir(_ENGINE_DIR)
|
|
101
|
+
except Exception:
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
_HEAVY: dict | None = None
|
|
106
|
+
_LIB_CACHE: dict[str, object] = {}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def heavy():
|
|
110
|
+
"""Return a namespace of the heavy fix-path symbols. Pays the numpy chain ONCE per
|
|
111
|
+
process (cached), so a persistent session reuses the imports across every command."""
|
|
112
|
+
global _HEAVY
|
|
113
|
+
if _HEAVY is None:
|
|
114
|
+
_ensure_repo_on_path()
|
|
115
|
+
from backend.substratum_v2.code_generation_v2 import substrates # noqa: F401 (populates the registry)
|
|
116
|
+
from backend.substratum_v2.code_generation_v2.substrate_query import (
|
|
117
|
+
FixRequest, run_substrate_query)
|
|
118
|
+
from backend.substratum_v2.code_generation_v2.pattern_library_lazy import load_library_lazy
|
|
119
|
+
from backend.substratum_v2.code_generation_v2.v0_synth_entry import (
|
|
120
|
+
synthesize, synthesize_from_intent)
|
|
121
|
+
_HEAVY = {
|
|
122
|
+
"FixRequest": FixRequest,
|
|
123
|
+
"run_substrate_query": run_substrate_query,
|
|
124
|
+
"load_library_lazy": load_library_lazy,
|
|
125
|
+
"synthesize": synthesize,
|
|
126
|
+
"synthesize_from_intent": synthesize_from_intent,
|
|
127
|
+
}
|
|
128
|
+
return _HEAVY
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def load_library_cached(lib_root: str):
|
|
132
|
+
"""Load the library resident index ONCE per (process, root). The lazy loader hydrates
|
|
133
|
+
patterns on demand and keeps its own LRU, so a warm session pays the resident load once
|
|
134
|
+
and reuses hydration across every fix. This is the warm-library win."""
|
|
135
|
+
if lib_root not in _LIB_CACHE:
|
|
136
|
+
_LIB_CACHE[lib_root] = heavy()["load_library_lazy"](lib_root)
|
|
137
|
+
return _LIB_CACHE[lib_root]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def is_warm(lib_root: str | None = None) -> bool:
|
|
141
|
+
if lib_root is None:
|
|
142
|
+
lib_root = lib_roots.py_library_root()
|
|
143
|
+
return bool(_HEAVY is not None and lib_root and lib_root in _LIB_CACHE)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def warm(lib_root: str | None = None) -> bool:
|
|
147
|
+
"""Preload the heavy imports + the library so the first fix in a session is fast.
|
|
148
|
+
Safe to call from a background thread at session start."""
|
|
149
|
+
if lib_root is None:
|
|
150
|
+
lib_root = lib_roots.py_library_root()
|
|
151
|
+
if not lib_root:
|
|
152
|
+
return False
|
|
153
|
+
load_library_cached(lib_root)
|
|
154
|
+
return True
|
substratum_cli/gitio.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Read-only git helpers. Stdlib + the `git` binary only; never writes to the object DB
|
|
2
|
+
except `git hash-object` WITHOUT -w (which computes a hash without storing)."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _git(repo: str, *args: str, timeout: int = 60) -> subprocess.CompletedProcess:
|
|
10
|
+
return subprocess.run(["git", "-C", repo, *args], capture_output=True, text=True,
|
|
11
|
+
timeout=timeout)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def is_git_repo(repo: str) -> bool:
|
|
15
|
+
import os
|
|
16
|
+
return os.path.isdir(os.path.join(repo, ".git")) or \
|
|
17
|
+
_git(repo, "rev-parse", "--git-dir").returncode == 0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def apply_reverse(repo: str, diff_text: str) -> tuple[bool, str]:
|
|
21
|
+
"""Reverse-apply a stored diff (the exact inverse of `substratum apply`). Returns (ok, message).
|
|
22
|
+
Uses --3way so it undoes even if surrounding lines moved; refuses cleanly if it can't."""
|
|
23
|
+
if not diff_text.strip():
|
|
24
|
+
return False, "empty diff"
|
|
25
|
+
r = subprocess.run(["git", "-C", repo, "apply", "--reverse", "--3way", "-"],
|
|
26
|
+
input=diff_text, capture_output=True, text=True, timeout=60)
|
|
27
|
+
if r.returncode == 0:
|
|
28
|
+
return True, "reverted"
|
|
29
|
+
r2 = subprocess.run(["git", "-C", repo, "apply", "--reverse", "-"],
|
|
30
|
+
input=diff_text, capture_output=True, text=True, timeout=60)
|
|
31
|
+
if r2.returncode == 0:
|
|
32
|
+
return True, "reverted"
|
|
33
|
+
err = (r.stderr or r2.stderr or "").strip().splitlines()
|
|
34
|
+
return False, (err[-1] if err else "could not reverse-apply (the files changed since apply)")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def commit_files(repo: str, files, message: str) -> tuple[bool, str]:
|
|
38
|
+
"""Stage the given files and commit them with `message`. Returns (ok, short_sha_or_error).
|
|
39
|
+
Uses the repo's own git author config (never Substratum's)."""
|
|
40
|
+
files = list(files or [])
|
|
41
|
+
if not files:
|
|
42
|
+
return False, "no files to commit"
|
|
43
|
+
add = _git(repo, "add", "--", *files)
|
|
44
|
+
if add.returncode != 0:
|
|
45
|
+
return False, (add.stderr or "git add failed").strip()
|
|
46
|
+
c = _git(repo, "commit", "-m", message, "--", *files)
|
|
47
|
+
if c.returncode != 0:
|
|
48
|
+
return False, (c.stderr or c.stdout or "git commit failed").strip().splitlines()[-1] \
|
|
49
|
+
if (c.stderr or c.stdout) else "git commit failed"
|
|
50
|
+
sha = _git(repo, "rev-parse", "--short", "HEAD")
|
|
51
|
+
return True, sha.stdout.strip() if sha.returncode == 0 else "committed"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def head_sha(repo: str) -> str:
|
|
55
|
+
return _git(repo, "rev-parse", "HEAD").stdout.strip()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def show(repo: str, commit: str, path: str) -> Optional[str]:
|
|
59
|
+
r = _git(repo, "show", f"{commit}:{path}")
|
|
60
|
+
return r.stdout if r.returncode == 0 else None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def diff_head(repo: str) -> str:
|
|
64
|
+
"""Tracked modifications (staged + unstaged) vs HEAD, as text bytes for hashing."""
|
|
65
|
+
return _git(repo, "diff", "HEAD", "--no-color").stdout
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def staged_diff(repo: str) -> str:
|
|
69
|
+
return _git(repo, "diff", "--cached", "--no-color").stdout
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def commit_diff(repo: str, sha: str) -> str:
|
|
73
|
+
return _git(repo, "show", sha, "--no-color", "--format=").stdout
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def untracked_files(repo: str) -> List[str]:
|
|
77
|
+
out = _git(repo, "status", "--porcelain", "-z").stdout
|
|
78
|
+
files = []
|
|
79
|
+
for entry in out.split("\0"):
|
|
80
|
+
if entry.startswith("?? "):
|
|
81
|
+
files.append(entry[3:])
|
|
82
|
+
return sorted(files)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def hash_object(repo: str, path: str) -> str:
|
|
86
|
+
"""Content hash of a file WITHOUT storing it (no -w). Empty on failure."""
|
|
87
|
+
r = _git(repo, "hash-object", path)
|
|
88
|
+
return r.stdout.strip() if r.returncode == 0 else ""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def dirty_hash(repo: str) -> str:
|
|
92
|
+
"""A deterministic digest of the working-tree delta vs HEAD: tracked diff + untracked
|
|
93
|
+
file hashes. 'clean' when there is nothing. No object-DB writes."""
|
|
94
|
+
import hashlib
|
|
95
|
+
tracked = diff_head(repo)
|
|
96
|
+
parts = [tracked]
|
|
97
|
+
for f in untracked_files(repo):
|
|
98
|
+
parts.append(f"{f}\0{hash_object(repo, f)}")
|
|
99
|
+
blob = "\n".join(parts)
|
|
100
|
+
if not blob.strip():
|
|
101
|
+
return "clean"
|
|
102
|
+
return hashlib.sha256(blob.encode("utf-8", "replace")).hexdigest()[:16]
|