feat(self_modify): port to FULL Rust — dichotomy wave
Full name-preserving port of src/rune/self_modify.py to src/rust/self_modify (RUST verdict, #2479 dichotomy tranche — 7/7 fns incl. the _run subprocess seam, dichotomy audit match clean): - _run: the subprocess seam is kernel-owned — Command spawn with piped reader threads, poll-based timeout→kill (TimeoutExpired raise-parity via the envelope), strict-UTF-8 text=True, universal-newline translation, signal exit codes - propose_change: rev-parse gate ('unknown base' branch), mkdtemp 0700 'selfmod-' + 8 chars of Python's tempfile alphabet, worktree add with err.strip()[:200] char-parity failure branch, no-clock `at` stamp as an argument - run_gates: default pytest argv pinned, falsy/empty/null cmd falls back, (stdout+stderr)[-2000:] CHARACTER tail parity - compare_baseline: full Python truthiness lattice on passed (missing key, null, 0/1 coercion), the three verdict reasons verbatim - promote_or_discard: lazy rollback_ref read (KeyError fires only after merge/remove side effects, like the oracle), merge-fail err[:200] (no strip — oracle quirk preserved), discard ignores both git failures - rollback: bad-ref err[:200] verbatim; monitor: .get(key, "") passes ANY present value through verbatim (int/null included) - raise parity at the TYPE level: smod_loader._raise_like_oracle re-raises FileNotFoundError/PermissionError/TimeoutExpired/ KeyError/TypeError from the envelope err encodings - smod_loader mirrors broker_loader (doc-envelope FFI, cs_free_string, SMOD_RUST_LIB override; RUNE_SM_CORE belongs to shard_manager so the pin is RUNE_SMOD_CORE); facade delegation with python-oracle fallback preserved - 32 frozen truth rows vs the LIVE oracle (clock frozen, tempfile pinned, deterministic git fixtures with literal seed SHA; setup as data + PATH-stubbed default gate command), deterministic across two generator runs; battery replays VERBATIM (cargo test 8/8) and live replay is 32/32 at BOTH facade and kernel level; pytest 6/6 both through the Rust core and with RUNE_SMOD_CORE=python
This commit is contained in:
1 parent
4f3d1f7fc1
commit
1af27dc5d4
8 files changed
+2394
No files matched your search
@@ -0,0 +1,522 @@
|
||||
#!/usr/bin/env python3
|
||||
"""gen_self_modify_truth.py — freeze/replay parity truth for self_modify
|
||||
(dichotomy wave, #2479 tranche).
|
||||
|
||||
Freeze mode (default): exercises EVERY public function of the LIVE Python
|
||||
oracle (src/rune/self_modify.py) across every branch and records
|
||||
(kernel_input, expected_kernel_output) pairs into
|
||||
src/rust/self_modify/src/frozen_smod_truth.json.
|
||||
|
||||
Replay mode (--replay): re-runs the SAME scenarios with the Rust core
|
||||
delegation active and asserts both kernel-level (loader direct) and
|
||||
facade-level (oracle functions delegate) parity against the frozen file.
|
||||
|
||||
Poisoned-truth law: freeze pins RUNE_SMOD_CORE=python BEFORE any import
|
||||
(the RUNE_SM_CORE pin already belongs to shard_manager's core on master).
|
||||
|
||||
No-clock law: the wall clock is frozen by patching the `time` symbol in
|
||||
self_modify, so every propose record carries the frozen `at` stamp and
|
||||
replay is deterministic regardless of run date. The oracle's internal
|
||||
tempfile.mkdtemp is likewise pinned to a case-local path.
|
||||
|
||||
Deterministic-fixture law: git repos are built with fixed identity and
|
||||
fixed author/committer dates, so the seed commit SHA is identical across
|
||||
generator runs, replay runs and the Rust battery — the SHA is frozen
|
||||
LITERALLY, no placeholder. Machine-specific paths (repo dir, worktree dir,
|
||||
stub dir) are frozen as @REPO@ / @WORKTREE@ / @STUBDIR@ placeholders and
|
||||
substituted at replay time. Git-dependent scenarios declare their setup as
|
||||
data ("worktree:<branch>", "commit_in_worktree", "dirty_repo", plus the
|
||||
"stub" flag for the PATH-stubbed default gate command) so the generator,
|
||||
the Python replay and the Rust battery run the SAME recipe.
|
||||
|
||||
Raise-parity rows freeze {"raises": "<oracle exception>"}: the Python
|
||||
facade must raise the same exception; the Rust core (envelope law) must
|
||||
surface an error (the loader raises RuntimeError — documented divergence
|
||||
in exception TYPE only).
|
||||
"""
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
TRUTH_PATH = os.path.join(
|
||||
REPO, "src", "rust", "self_modify", "src", "frozen_smod_truth.json"
|
||||
)
|
||||
REPLAY = "--replay" in sys.argv
|
||||
|
||||
os.environ["RUNE_SMOD_CORE"] = "python" # poisoned-truth law (freeze mode)
|
||||
os.environ["RUNE_MEMORY_CORE"] = "python"
|
||||
|
||||
sys.path.insert(0, os.path.join(REPO, "src"))
|
||||
sys.path.insert(0, os.path.join(REPO, "src", "rune"))
|
||||
|
||||
import self_modify as oracle_mod # noqa: E402
|
||||
|
||||
if REPLAY:
|
||||
del os.environ["RUNE_SMOD_CORE"] # facade must delegate to the Rust core
|
||||
import smod_loader # noqa: E402 (kernel-level replay side)
|
||||
|
||||
FROZEN_AT = "2026-10-11T12:00:00Z"
|
||||
FROZEN_EPOCH = int(datetime(2026, 10, 11, 12, 0, 0, tzinfo=timezone.utc).timestamp())
|
||||
GIT_ENV_FIXED = {
|
||||
"GIT_AUTHOR_DATE": "2026-10-11T12:00:00+00:00",
|
||||
"GIT_COMMITTER_DATE": "2026-10-11T12:00:00+00:00",
|
||||
}
|
||||
MISSING_REPO = "/nonexistent-selfmod-truth/repo"
|
||||
|
||||
|
||||
class FrozenTime:
|
||||
"""time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) → FROZEN_AT."""
|
||||
|
||||
@staticmethod
|
||||
def gmtime():
|
||||
return time.gmtime(FROZEN_EPOCH)
|
||||
|
||||
@staticmethod
|
||||
def strftime(fmt, t=None):
|
||||
return time.strftime(fmt, t if t is not None else FrozenTime.gmtime())
|
||||
|
||||
|
||||
oracle_mod.time = FrozenTime
|
||||
|
||||
|
||||
class FrozenTempfile:
|
||||
"""tempfile.mkdtemp(prefix="selfmod-") → a case-local, known path."""
|
||||
|
||||
ROOT = None
|
||||
N = 0
|
||||
|
||||
@staticmethod
|
||||
def mkdtemp(prefix=""):
|
||||
FrozenTempfile.N += 1
|
||||
p = os.path.join(FrozenTempfile.ROOT, f"{prefix}{FrozenTempfile.N:02d}")
|
||||
os.makedirs(p)
|
||||
return p
|
||||
|
||||
|
||||
oracle_mod.tempfile = FrozenTempfile
|
||||
|
||||
|
||||
# ── deterministic fixture + setup interpreter (shared recipe) ───────────────
|
||||
|
||||
def _git(root, *argv):
|
||||
env = dict(os.environ, **GIT_ENV_FIXED)
|
||||
return subprocess.run(["git", "-C", root, *argv], capture_output=True,
|
||||
text=True, env=env)
|
||||
|
||||
|
||||
def make_fixture(case_root):
|
||||
"""The battery recipe — fixed identity, fixed dates, seed commit.
|
||||
The resulting master SHA is machine-independent (frozen literally)."""
|
||||
repo = os.path.join(case_root, "repo")
|
||||
os.makedirs(repo)
|
||||
_git(repo, "init", "-q", "-b", "master")
|
||||
_git(repo, "config", "user.email", "t@t")
|
||||
_git(repo, "config", "user.name", "t")
|
||||
_git(repo, "config", "commit.gpgsign", "false")
|
||||
with open(os.path.join(repo, "f.txt"), "w") as f:
|
||||
f.write("v1\n")
|
||||
_git(repo, "add", ".")
|
||||
_git(repo, "commit", "-qm", "seed")
|
||||
return repo
|
||||
|
||||
|
||||
def base_sha(repo):
|
||||
return _git(repo, "rev-parse", "master").stdout.strip()
|
||||
|
||||
|
||||
def run_setup(repo, steps):
|
||||
"""Declarative setup ops, executed identically by freeze, replay and
|
||||
the Rust battery. Returns the worktree path (or None)."""
|
||||
wt = None
|
||||
for step in steps:
|
||||
op, _, arg = step.partition(":")
|
||||
if op == "worktree":
|
||||
wt = os.path.join(os.path.dirname(repo), f"wt-{arg}")
|
||||
r = _git(repo, "worktree", "add", wt, "-b", arg, "master")
|
||||
assert r.returncode == 0, r.stderr
|
||||
elif op == "commit_in_worktree":
|
||||
with open(os.path.join(wt, "f.txt"), "w") as f:
|
||||
f.write("v2\n")
|
||||
r = _git(wt, "commit", "-qam", "c")
|
||||
assert r.returncode == 0, r.stderr
|
||||
elif op == "dirty_repo":
|
||||
with open(os.path.join(repo, "f.txt"), "w") as f:
|
||||
f.write("v9\n")
|
||||
else:
|
||||
raise ValueError(f"unknown setup op: {step}")
|
||||
return wt
|
||||
|
||||
|
||||
def make_stub_dir(case_root):
|
||||
"""PATH stub: a python3 that deterministically 'runs the gate suite'
|
||||
(the oracle's default gate command resolves through PATH)."""
|
||||
stub = os.path.join(case_root, "stub")
|
||||
os.makedirs(stub)
|
||||
path = os.path.join(stub, "python3")
|
||||
with open(path, "w") as f:
|
||||
f.write("#!/bin/sh\necho stub-gates-green\nexit 0\n")
|
||||
os.chmod(path, os.stat(path).st_mode | stat.S_IXUSR)
|
||||
return stub
|
||||
|
||||
|
||||
# ── scenario cases ───────────────────────────────────────────────────────────
|
||||
|
||||
class Case:
|
||||
def __init__(self, name, group, steps, inp, stub=False):
|
||||
self.name = name
|
||||
self.group = group
|
||||
self.steps = steps
|
||||
self.inp = inp # (repo, wt, stub) → kernel input dict
|
||||
self.stub = stub
|
||||
|
||||
|
||||
def propose_cases():
|
||||
return [
|
||||
Case("propose-ok", "propose_change", [],
|
||||
lambda r, w, s: {"repo": "@REPO@", "branch": "cand-ok",
|
||||
"base": "master", "now": FROZEN_AT}),
|
||||
Case("propose-base-head", "propose_change", [],
|
||||
lambda r, w, s: {"repo": "@REPO@", "branch": "cand-head",
|
||||
"base": "HEAD", "now": FROZEN_AT}),
|
||||
Case("propose-unknown-base", "propose_change", [],
|
||||
lambda r, w, s: {"repo": "@REPO@", "branch": "cand-bad",
|
||||
"base": "no-such-ref", "now": FROZEN_AT}),
|
||||
Case("propose-dup-branch", "propose_change", ["worktree:dup"],
|
||||
lambda r, w, s: {"repo": "@REPO@", "branch": "dup",
|
||||
"base": "master", "now": FROZEN_AT}),
|
||||
Case("propose-missing-repo", "propose_change", [],
|
||||
lambda r, w, s: {"repo": MISSING_REPO, "branch": "cand-gone",
|
||||
"base": "master", "now": FROZEN_AT}),
|
||||
]
|
||||
|
||||
|
||||
def gates_cases():
|
||||
return [
|
||||
Case("gates-pass", "run_gates", [],
|
||||
lambda r, w, s: {"worktree": "@REPO@",
|
||||
"test_cmd": ["echo", "gates-green"]}),
|
||||
Case("gates-fail-stderr", "run_gates", [],
|
||||
lambda r, w, s: {"worktree": "@REPO@",
|
||||
"test_cmd": ["sh", "-c", "echo boom 1>&2; exit 1"]}),
|
||||
Case("gates-tail-2000", "run_gates", [],
|
||||
lambda r, w, s: {"worktree": "@REPO@",
|
||||
"test_cmd": ["sh", "-c", "seq 1 3000"]}),
|
||||
Case("gates-out-then-err", "run_gates", [],
|
||||
lambda r, w, s: {"worktree": "@REPO@",
|
||||
"test_cmd": ["sh", "-c",
|
||||
"echo o1; echo e1 1>&2; echo o2; exit 7"]}),
|
||||
Case("gates-default-cmd", "run_gates", [],
|
||||
lambda r, w, s: {"worktree": "@REPO@", "test_cmd": None},
|
||||
stub=True),
|
||||
]
|
||||
|
||||
|
||||
def compare_cases():
|
||||
def row(name, cand, base):
|
||||
return Case("cb-" + name, "compare_baseline", [],
|
||||
lambda r, w, s, c=cand, b=base:
|
||||
{"candidate_result": c, "baseline_result": b})
|
||||
|
||||
return [
|
||||
row("candidate-red", {"passed": False}, {"passed": True}),
|
||||
row("candidate-red-baseline-red", {"passed": False}, {"passed": False}),
|
||||
row("baseline-red", {"passed": True}, {"passed": False}),
|
||||
row("both-green", {"passed": True}, {"passed": True}),
|
||||
row("missing-passed-key", {}, {"passed": True}),
|
||||
row("truthy-coercion", {"passed": 1}, {"passed": 0}),
|
||||
row("explicit-null-passed", {"passed": None}, {"passed": True}),
|
||||
]
|
||||
|
||||
|
||||
def _proposal(branch, repo):
|
||||
return {"proposed": True, "branch": branch, "worktree": "@WORKTREE@",
|
||||
"base": "master", "rollback_ref": base_sha(repo), "at": FROZEN_AT}
|
||||
|
||||
|
||||
def pod_cases():
|
||||
return [
|
||||
Case("pod-discard", "promote_or_discard", ["worktree:discard-1"],
|
||||
lambda r, w, s: {"repo": "@REPO@", "proposal": _proposal("discard-1", r),
|
||||
"verdict": {"promote": False, "reason": "test"}}),
|
||||
Case("pod-promote-up-to-date", "promote_or_discard", ["worktree:podd-2"],
|
||||
lambda r, w, s: {"repo": "@REPO@", "proposal": _proposal("podd-2", r),
|
||||
"verdict": {"promote": True, "reason": "test"}}),
|
||||
Case("pod-promote-real-merge", "promote_or_discard",
|
||||
["worktree:podd-3", "commit_in_worktree"],
|
||||
lambda r, w, s: {"repo": "@REPO@", "proposal": _proposal("podd-3", r),
|
||||
"verdict": {"promote": True, "reason": "test"}}),
|
||||
Case("pod-merge-fail", "promote_or_discard",
|
||||
["worktree:podd-4", "commit_in_worktree", "dirty_repo"],
|
||||
lambda r, w, s: {"repo": "@REPO@", "proposal": _proposal("podd-4", r),
|
||||
"verdict": {"promote": True, "reason": "test"}}),
|
||||
Case("pod-discard-ghost", "promote_or_discard", [],
|
||||
lambda r, w, s: {"repo": "@REPO@", "proposal": _proposal("ghost-branch", r),
|
||||
"verdict": {"promote": False, "reason": "test"}}),
|
||||
Case("pod-missing-branch-key", "promote_or_discard", [],
|
||||
lambda r, w, s: {"repo": "@REPO@",
|
||||
"proposal": {k: v for k, v in _proposal("keyless", r).items()
|
||||
if k != "branch"},
|
||||
"verdict": {"promote": True}}),
|
||||
]
|
||||
|
||||
|
||||
def rollback_cases():
|
||||
return [
|
||||
Case("rb-ok-after-dirty", "rollback", ["dirty_repo"],
|
||||
lambda r, w, s: {"repo": "@REPO@", "rollback_ref": base_sha(r)}),
|
||||
Case("rb-ref-master", "rollback", ["dirty_repo"],
|
||||
lambda r, w, s: {"repo": "@REPO@", "rollback_ref": "master"}),
|
||||
Case("rb-bad-ref", "rollback", [],
|
||||
lambda r, w, s: {"repo": "@REPO@", "rollback_ref": "no-such-ref"}),
|
||||
Case("rb-missing-repo", "rollback", [],
|
||||
lambda r, w, s: {"repo": MISSING_REPO, "rollback_ref": "master"}),
|
||||
]
|
||||
|
||||
|
||||
def monitor_cases():
|
||||
def row(name, proposal):
|
||||
return Case("mon-" + name, "monitor", [],
|
||||
lambda r, w, s, p=proposal: {"proposal": p})
|
||||
|
||||
return [
|
||||
row("full", {"branch": "b", "rollback_ref": "abc123"}),
|
||||
row("empty", {}),
|
||||
row("partial", {"branch": "only-b"}),
|
||||
row("extra-keys", {"branch": "b", "rollback_ref": "r", "extra": 1}),
|
||||
row("nonstring-branch", {"branch": 7, "rollback_ref": None}),
|
||||
]
|
||||
|
||||
|
||||
GROUPS = [
|
||||
("propose_change", propose_cases()),
|
||||
("run_gates", gates_cases()),
|
||||
("compare_baseline", compare_cases()),
|
||||
("promote_or_discard", pod_cases()),
|
||||
("rollback", rollback_cases()),
|
||||
("monitor", monitor_cases()),
|
||||
]
|
||||
|
||||
FACADE_CALL = {
|
||||
"propose_change": lambda i: oracle_mod.propose_change(
|
||||
i["repo"], i["branch"], i["base"]),
|
||||
"run_gates": lambda i: oracle_mod.run_gates(i["worktree"], i["test_cmd"]),
|
||||
"compare_baseline": lambda i: oracle_mod.compare_baseline(
|
||||
i["candidate_result"], i["baseline_result"]),
|
||||
"promote_or_discard": lambda i: oracle_mod.promote_or_discard(
|
||||
i["repo"], i["proposal"], i["verdict"]),
|
||||
"rollback": lambda i: oracle_mod.rollback(i["repo"], i["rollback_ref"]),
|
||||
"monitor": lambda i: oracle_mod.monitor(i["proposal"]),
|
||||
}
|
||||
|
||||
KERNEL_CALL = {
|
||||
"propose_change": lambda i: smod_loader.propose_change(
|
||||
i["repo"], i["branch"], i["base"], i["now"]),
|
||||
"run_gates": lambda i: smod_loader.run_gates(i["worktree"], i["test_cmd"]),
|
||||
"compare_baseline": lambda i: smod_loader.compare_baseline(
|
||||
i["candidate_result"], i["baseline_result"]),
|
||||
"promote_or_discard": lambda i: smod_loader.promote_or_discard(
|
||||
i["repo"], i["proposal"], i["verdict"]),
|
||||
"rollback": lambda i: smod_loader.rollback(i["repo"], i["rollback_ref"]),
|
||||
"monitor": lambda i: smod_loader.monitor(i["proposal"]),
|
||||
}
|
||||
|
||||
|
||||
# ── placeholder plumbing ─────────────────────────────────────────────────────
|
||||
|
||||
def substitute(value, repo, wt, stub):
|
||||
"""Machine paths → frozen placeholders (kernel input side)."""
|
||||
s = json.dumps(value)
|
||||
for actual, ph in ((repo, "@REPO@"), (wt, "@WORKTREE@"), (stub, "@STUBDIR@")):
|
||||
if actual:
|
||||
s = s.replace(actual, ph)
|
||||
return json.loads(s)
|
||||
|
||||
|
||||
def de_substitute(value, repo, wt, stub):
|
||||
"""Frozen placeholders → live paths (call side)."""
|
||||
s = json.dumps(value)
|
||||
for actual, ph in ((repo, "@REPO@"), (wt, "@WORKTREE@"), (stub, "@STUBDIR@")):
|
||||
if actual:
|
||||
s = s.replace(ph, actual)
|
||||
return json.loads(s)
|
||||
|
||||
|
||||
def normalize(out, repo, wt, stub):
|
||||
"""Actual output paths → placeholders (kernel output side). The
|
||||
worktree a propose call created internally is captured from the
|
||||
output itself — neither side can know it in advance."""
|
||||
reps = [t for t in ((wt, "@WORKTREE@") if wt else None,
|
||||
(repo, "@REPO@") if repo else None,
|
||||
(stub, "@STUBDIR@") if stub else None) if t]
|
||||
if isinstance(out, dict) and isinstance(out.get("worktree"), str):
|
||||
reps.append((out["worktree"], "@WORKTREE@"))
|
||||
s = json.dumps(out, sort_keys=True)
|
||||
for actual, ph in reps:
|
||||
if actual and actual != "/":
|
||||
s = s.replace(actual, ph)
|
||||
return json.loads(s)
|
||||
|
||||
|
||||
def _catch(fn, *a):
|
||||
try:
|
||||
return fn(*a)
|
||||
except FileNotFoundError:
|
||||
return {"__raised__": "FileNotFoundError"}
|
||||
except KeyError as e:
|
||||
return {"__raised__": f"KeyError: {e}"}
|
||||
except RuntimeError:
|
||||
return {"__raised__": "RuntimeError"}
|
||||
|
||||
|
||||
class _PathEnv:
|
||||
"""Set PATH to the stub dir for the duration of one call."""
|
||||
|
||||
def __init__(self, stub):
|
||||
self.stub = stub
|
||||
|
||||
def __enter__(self):
|
||||
self.old = os.environ.get("PATH")
|
||||
os.environ["PATH"] = self.stub + os.pathsep + (self.old or "")
|
||||
|
||||
def __exit__(self, *exc):
|
||||
if self.old is not None:
|
||||
os.environ["PATH"] = self.old
|
||||
return False
|
||||
|
||||
|
||||
def run_scenario(case, tmp_root, call):
|
||||
"""One scenario, identical for freeze and both replay tiers:
|
||||
fresh fixture → setup → substituted input → call → normalized output."""
|
||||
case_root = tempfile.mkdtemp(prefix=f"smod_{case.name}_", dir=tmp_root)
|
||||
repo = make_fixture(case_root)
|
||||
FrozenTempfile.ROOT = case_root
|
||||
FrozenTempfile.N = 0
|
||||
wt = run_setup(repo, case.steps)
|
||||
stub = make_stub_dir(case_root) if case.stub else None
|
||||
live_wt = wt or os.path.join(case_root, "wt-input")
|
||||
live_stub = stub or os.path.join(case_root, "stub-unused")
|
||||
inp = substitute(case.inp(repo, wt, stub), repo, wt, stub)
|
||||
inp_copy = copy.deepcopy(inp) # deep-copy law: inputs frozen before calls
|
||||
live = de_substitute(inp_copy, repo, live_wt, live_stub)
|
||||
if case.stub:
|
||||
with _PathEnv(live_stub):
|
||||
out = _catch(call, live)
|
||||
else:
|
||||
out = _catch(call, live)
|
||||
if isinstance(out, dict) and out.get("__raised__"):
|
||||
return inp, {"raises": out["__raised__"]}
|
||||
return inp, normalize(out, repo, live_wt, stub)
|
||||
|
||||
|
||||
def main():
|
||||
frozen = None
|
||||
if REPLAY:
|
||||
with open(TRUTH_PATH) as f:
|
||||
frozen = json.load(f)["cases"]
|
||||
|
||||
tmp_root = tempfile.mkdtemp(prefix="smod_truth_")
|
||||
groups = {}
|
||||
failures = 0
|
||||
total = 0
|
||||
|
||||
for group, cases in GROUPS:
|
||||
recs = []
|
||||
|
||||
def rec(name, inp, expected, case=None, _r=recs, _g=group):
|
||||
entry = {"name": name, "input": inp, "expected": expected}
|
||||
if case is not None:
|
||||
entry["setup"] = case.steps
|
||||
entry["stub"] = case.stub
|
||||
if REPLAY:
|
||||
old = next(c for c in frozen[_g] if c["name"] == name)
|
||||
for key in ("input", "expected"):
|
||||
if json.dumps(entry[key], sort_keys=True) != json.dumps(
|
||||
old[key], sort_keys=True):
|
||||
print(f"FACADE DIVERGE [{_g}] {name} {key}")
|
||||
print(" got: ", json.dumps(entry[key], sort_keys=True)[:600])
|
||||
print(" want:", json.dumps(old[key], sort_keys=True)[:600])
|
||||
_r.append({"name": name, "diverged": key})
|
||||
return
|
||||
_r.append(entry)
|
||||
|
||||
for case in cases:
|
||||
total += 1
|
||||
inp, want = run_scenario(case, tmp_root,
|
||||
lambda i, c=case: _catch(
|
||||
FACADE_CALL[c.group], i))
|
||||
rec(case.name, inp, want, case)
|
||||
|
||||
if REPLAY:
|
||||
old = next(c for c in frozen[case.group] if c["name"] == case.name)
|
||||
repo, wt, stub = kernel_paths(case, tmp_root, old)
|
||||
call = lambda: KERNEL_CALL[case.group](
|
||||
de_substitute(copy.deepcopy(old["input"]), repo, wt, stub))
|
||||
if old.get("stub"):
|
||||
with _PathEnv(stub):
|
||||
kout = _catch(lambda: call())
|
||||
else:
|
||||
kout = _catch(call)
|
||||
kwant = old["expected"]
|
||||
if not (isinstance(kout, dict) and kout.get("__raised__")):
|
||||
kout = normalize(kout, repo, wt, stub)
|
||||
if isinstance(kwant, dict) and kwant.get("raises"):
|
||||
ok = kout == {"__raised__": kwant["raises"]} or (
|
||||
kout == {"__raised__": "RuntimeError"})
|
||||
else:
|
||||
ok = json.dumps(kout, sort_keys=True) == json.dumps(
|
||||
kwant, sort_keys=True)
|
||||
if not ok:
|
||||
failures += 1
|
||||
print(f"KERNEL DIVERGE [{case.group}] {case.name}")
|
||||
print(" got: ", json.dumps(kout, sort_keys=True)[:600])
|
||||
print(" want:", json.dumps(kwant, sort_keys=True)[:600])
|
||||
|
||||
groups[group] = recs
|
||||
|
||||
if REPLAY:
|
||||
facade_div = sum(1 for v in groups.values() for c in v if "diverged" in c)
|
||||
print(f"replay: facade {total - facade_div}/{total}, "
|
||||
f"kernel {total - failures}/{total}")
|
||||
return 1 if (failures or facade_div) else 0
|
||||
|
||||
doc = {
|
||||
"frozen": datetime.now(timezone.utc).isoformat(),
|
||||
"oracle": "src/rune/self_modify.py",
|
||||
"pin": "RUNE_SMOD_CORE=python",
|
||||
"note": "Frozen via scripts/gen_self_modify_truth.py against the live "
|
||||
"Python oracle; wall clock frozen at " + FROZEN_AT + "; git "
|
||||
"fixtures use fixed identity+dates so the seed SHA is "
|
||||
"literal. @REPO@/@WORKTREE@/@STUBDIR@ are placeholders "
|
||||
"substituted at replay. Battery embeds this VERBATIM.",
|
||||
"cases": groups,
|
||||
}
|
||||
os.makedirs(os.path.dirname(TRUTH_PATH), exist_ok=True)
|
||||
with open(TRUTH_PATH, "w") as f:
|
||||
json.dump(doc, f, indent=1, sort_keys=True)
|
||||
f.write("\n")
|
||||
print(f"frozen {total} cases → {os.path.relpath(TRUTH_PATH, REPO)}")
|
||||
return 0
|
||||
|
||||
|
||||
def kernel_paths(case, tmp_root, old_entry):
|
||||
"""(repo, wt, stub) for kernel-level replay of one frozen entry."""
|
||||
case_root = tempfile.mkdtemp(prefix=f"smod_kernel_{case.name}_", dir=tmp_root)
|
||||
repo = make_fixture(case_root)
|
||||
wt = run_setup(repo, old_entry.get("setup", []))
|
||||
stub = None
|
||||
if old_entry.get("stub"):
|
||||
stub = make_stub_dir(case_root)
|
||||
live_wt = wt or os.path.join(case_root, "wt-input")
|
||||
live_stub = stub or os.path.join(case_root, "stub-unused")
|
||||
return repo, live_wt, live_stub
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -13,6 +13,14 @@ the gate suite green + no regression vs the baseline run.
|
||||
Worktree isolation: proposals build on `git worktree` branches off the
|
||||
live repo, so the candidate never touches the working tree until
|
||||
promoted. Discard = remove worktree + delete branch (full cleanup).
|
||||
|
||||
Dichotomy wave #2479: the FULL Rust core (src/rust/self_modify via
|
||||
smod_loader) owns the subprocess seam (_run: every git invocation and
|
||||
gate command) and every proposal decision; this module remains the
|
||||
parity oracle and the strangler fallback (RUNE_SMOD_CORE=python pins it
|
||||
— the RUNE_SM_CORE pin belongs to shard_manager). Raise parity holds at
|
||||
the type level: the loader re-raises FileNotFoundError /
|
||||
TimeoutExpired / KeyError / TypeError exactly as the oracle would.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -24,6 +32,25 @@ import tempfile
|
||||
import time
|
||||
|
||||
|
||||
def _smod_core():
|
||||
"""Dichotomy wave #2479: Rust core first (src/rust/self_modify via
|
||||
smod_loader). RUNE_SMOD_CORE=python pins the Python fallback below —
|
||||
it remains the parity oracle (strangler law). The RUNE_SM_CORE pin
|
||||
belongs to shard_manager. The kernel owns the subprocess seam (_run:
|
||||
every git invocation and gate command) and every proposal decision;
|
||||
the wall clock enters as an argument (no-clock law)."""
|
||||
if os.environ.get("RUNE_SMOD_CORE", "auto") == "python":
|
||||
return None
|
||||
try:
|
||||
from rune import smod_loader
|
||||
|
||||
if smod_loader.available():
|
||||
return smod_loader
|
||||
except ImportError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _run(cmd, cwd, timeout=300):
|
||||
r = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True,
|
||||
timeout=timeout)
|
||||
@@ -33,6 +60,11 @@ def _run(cmd, cwd, timeout=300):
|
||||
def propose_change(repo: str, branch: str, base: str = "master") -> dict:
|
||||
"""Create the isolated worktree + branch. Returns the proposal record
|
||||
(with rollback_ref = the base HEAD — the recovery path)."""
|
||||
core = _smod_core()
|
||||
if core is not None:
|
||||
return core.propose_change(
|
||||
repo, branch, base,
|
||||
time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()))
|
||||
rc, out, err = _run(["git", "rev-parse", base], cwd=repo)
|
||||
if rc != 0:
|
||||
return {"proposed": False, "error": f"unknown base: {base}"}
|
||||
@@ -49,6 +81,9 @@ def propose_change(repo: str, branch: str, base: str = "master") -> dict:
|
||||
|
||||
def run_gates(worktree: str, test_cmd: list | None = None) -> dict:
|
||||
"""Run the gate suite inside the worktree. Returns {passed, output}."""
|
||||
core = _smod_core()
|
||||
if core is not None:
|
||||
return core.run_gates(worktree, test_cmd)
|
||||
cmd = test_cmd or ["python3", "-m", "pytest", "tests/", "-q", "-x"]
|
||||
rc, out, err = _run(cmd, cwd=worktree, timeout=600)
|
||||
return {"passed": rc == 0,
|
||||
@@ -57,6 +92,9 @@ def run_gates(worktree: str, test_cmd: list | None = None) -> dict:
|
||||
|
||||
def compare_baseline(candidate_result: dict, baseline_result: dict) -> dict:
|
||||
"""Compare candidate vs baseline gate runs. Regression blocks."""
|
||||
core = _smod_core()
|
||||
if core is not None:
|
||||
return core.compare_baseline(candidate_result, baseline_result)
|
||||
if not candidate_result.get("passed"):
|
||||
return {"promote": False,
|
||||
"reason": "candidate gates red — regression blocks promotion"}
|
||||
@@ -69,6 +107,9 @@ def compare_baseline(candidate_result: dict, baseline_result: dict) -> dict:
|
||||
|
||||
def promote_or_discard(repo: str, proposal: dict, verdict: dict) -> dict:
|
||||
"""Merge (promote) or delete (discard) the proposal branch."""
|
||||
core = _smod_core()
|
||||
if core is not None:
|
||||
return core.promote_or_discard(repo, proposal, verdict)
|
||||
branch = proposal["branch"]
|
||||
wt = proposal["worktree"]
|
||||
if verdict.get("promote"):
|
||||
@@ -88,6 +129,9 @@ def promote_or_discard(repo: str, proposal: dict, verdict: dict) -> dict:
|
||||
|
||||
def rollback(repo: str, rollback_ref: str) -> dict:
|
||||
"""Restore the pre-change ref. The recovery path every proposal carries."""
|
||||
core = _smod_core()
|
||||
if core is not None:
|
||||
return core.rollback(repo, rollback_ref)
|
||||
rc, _out, err = _run(["git", "reset", "--hard", rollback_ref], cwd=repo)
|
||||
if rc != 0:
|
||||
return {"rolled_back": False, "error": err[:200]}
|
||||
@@ -96,6 +140,9 @@ def rollback(repo: str, rollback_ref: str) -> dict:
|
||||
|
||||
def monitor(proposal: dict) -> dict:
|
||||
"""The post-promotion watch record: what to observe after promotion."""
|
||||
core = _smod_core()
|
||||
if core is not None:
|
||||
return core.monitor(proposal)
|
||||
return {"monitoring": True, "branch": proposal.get("branch", ""),
|
||||
"rollback_ref": proposal.get("rollback_ref", ""),
|
||||
"watch": ["gate suite green on next CI run",
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ctypes loader for the Rust self_modify core (dichotomy wave, #2479
|
||||
tranche). Mirrors broker_loader.py's contract:
|
||||
|
||||
FFI contract (doc-envelope law — every call returns {"ok", "value"|"err"}):
|
||||
* smod_propose_change({repo, branch, base, now}) -> proposal dict
|
||||
* smod_run_gates({worktree, test_cmd|null}) -> {passed, output}
|
||||
* smod_compare_baseline({candidate_result, baseline_result}) -> verdict
|
||||
* smod_promote_or_discard({repo, proposal, verdict}) -> result dict
|
||||
* smod_rollback({repo, rollback_ref}) -> result dict
|
||||
* smod_monitor({proposal}) -> watch record
|
||||
|
||||
No-clock law: `now` (the oracle's
|
||||
time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())) is always an
|
||||
argument — the kernel never reads the wall clock.
|
||||
|
||||
Boundary: the subprocess seam (_run — every git invocation and gate
|
||||
command) is owned by the kernel, exactly like the oracle's _run. The
|
||||
facade keeps only the wall clock and the RUNE_SMOD_CORE pin.
|
||||
|
||||
Raise parity: the oracle RAISES (FileNotFoundError from a missing cwd /
|
||||
binary, TimeoutExpired on gate timeouts, KeyError from a missing proposal
|
||||
key, TypeError at the argv seam). The kernel answers with the envelope's
|
||||
err half and this loader re-raises the ORACLE'S exception TYPE via
|
||||
_raise_like_oracle — raise parity holds at the type level, not just
|
||||
raise-vs-return.
|
||||
|
||||
Free: capability_scoring's cs_free_string (shared process) — this crate
|
||||
deliberately exports NO free symbol of its own (duplicate exports break
|
||||
the cdylib link when sibling crates share one process). Override the .so
|
||||
with SMOD_RUST_LIB (a FILE path).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[2]
|
||||
_CRATE_DIR = _REPO / "src" / "rust" / "self_modify"
|
||||
_LIB_PATH = Path(
|
||||
os.environ.get("SMOD_RUST_LIB", _CRATE_DIR / "target" / "release" / "libself_modify.so")
|
||||
)
|
||||
|
||||
_SYMBOLS = (
|
||||
"smod_propose_change",
|
||||
"smod_run_gates",
|
||||
"smod_compare_baseline",
|
||||
"smod_promote_or_discard",
|
||||
"smod_rollback",
|
||||
"smod_monitor",
|
||||
)
|
||||
|
||||
_lib = None
|
||||
|
||||
|
||||
class SelfModifyCoreUnavailable(FileNotFoundError):
|
||||
"""The Rust self-modify core is not built. Callers fall back to Python."""
|
||||
|
||||
|
||||
def available() -> bool:
|
||||
return _LIB_PATH.is_file()
|
||||
|
||||
|
||||
def _load() -> ctypes.CDLL:
|
||||
global _lib
|
||||
if _lib is not None:
|
||||
return _lib
|
||||
if not _LIB_PATH.is_file():
|
||||
raise SelfModifyCoreUnavailable(
|
||||
f"Rust self_modify core not built: {_LIB_PATH} missing — run "
|
||||
f"`cargo build --release` in {_CRATE_DIR}"
|
||||
)
|
||||
lib = ctypes.CDLL(str(_LIB_PATH))
|
||||
for sym in _SYMBOLS:
|
||||
fn = getattr(lib, sym)
|
||||
fn.argtypes = [ctypes.c_void_p]
|
||||
fn.restype = ctypes.c_void_p
|
||||
_lib = lib
|
||||
return lib
|
||||
|
||||
|
||||
def _raise_like_oracle(err: str, sym: str):
|
||||
"""Re-raise the ORACLE's exception type, not a blanket RuntimeError.
|
||||
|
||||
The kernel's envelope err encodes enough to reconstruct the raise
|
||||
parity the Python oracle has (subprocess FileNotFoundError /
|
||||
PermissionError / TimeoutExpired, dict KeyError, argv TypeError):
|
||||
* "[Errno N] …" → OSError subclass by errno (spawn seam)
|
||||
* "Command '…' timed out after S seconds" → subprocess.TimeoutExpired
|
||||
* "KeyError: 'k'" → KeyError(k)
|
||||
* "TypeError: …" → TypeError
|
||||
Anything else stays a RuntimeError.
|
||||
"""
|
||||
if err.startswith("[Errno "):
|
||||
try:
|
||||
errno = int(err[7:].split("]", 1)[0])
|
||||
except ValueError:
|
||||
raise RuntimeError(err) from None
|
||||
if errno == 2:
|
||||
raise FileNotFoundError(err)
|
||||
if errno == 13:
|
||||
raise PermissionError(err)
|
||||
if errno == 20:
|
||||
raise NotADirectoryError(err)
|
||||
raise OSError(errno, err)
|
||||
m = re.match(r"Command '(.*)' timed out after (\d+) seconds$", err)
|
||||
if m:
|
||||
raise subprocess.TimeoutExpired(m.group(1), float(m.group(2)))
|
||||
if err.startswith("KeyError: "):
|
||||
raise KeyError(err[len("KeyError: "):].strip("'"))
|
||||
if err.startswith("TypeError: "):
|
||||
raise TypeError(err[len("TypeError: "):])
|
||||
raise RuntimeError(err)
|
||||
|
||||
|
||||
def _call(sym: str, payload: dict):
|
||||
lib = _load()
|
||||
data = json.dumps(payload).encode()
|
||||
buf = ctypes.create_string_buffer(data)
|
||||
ptr = getattr(lib, sym)(ctypes.cast(buf, ctypes.c_void_p))
|
||||
if not ptr:
|
||||
raise RuntimeError(f"{sym} returned null")
|
||||
out = ctypes.string_at(ptr).decode()
|
||||
# free via capability_scoring's cs_free_string (same process)
|
||||
try:
|
||||
scoring = ctypes.CDLL(str(
|
||||
_REPO / "src" / "rust" / "capability_scoring" / "target" / "release" / "libcapability_scoring.so"
|
||||
))
|
||||
scoring.cs_free_string.argtypes = [ctypes.c_void_p]
|
||||
scoring.cs_free_string(ptr)
|
||||
except OSError:
|
||||
pass # leak-on-fallback only if capability_scoring absent; bounded
|
||||
doc = json.loads(out)
|
||||
if not doc.get("ok"):
|
||||
_raise_like_oracle(doc.get("err", f"{sym} failed"), sym)
|
||||
return doc["value"]
|
||||
|
||||
|
||||
def propose_change(repo: str, branch: str, base: str, now: str) -> dict:
|
||||
return _call("smod_propose_change", {
|
||||
"repo": repo, "branch": branch, "base": base, "now": now,
|
||||
})
|
||||
|
||||
|
||||
def run_gates(worktree: str, test_cmd: list | None) -> dict:
|
||||
return _call("smod_run_gates", {
|
||||
"worktree": worktree, "test_cmd": test_cmd,
|
||||
})
|
||||
|
||||
|
||||
def compare_baseline(candidate_result: dict, baseline_result: dict) -> dict:
|
||||
return _call("smod_compare_baseline", {
|
||||
"candidate_result": candidate_result,
|
||||
"baseline_result": baseline_result,
|
||||
})
|
||||
|
||||
|
||||
def promote_or_discard(repo: str, proposal: dict, verdict: dict) -> dict:
|
||||
return _call("smod_promote_or_discard", {
|
||||
"repo": repo, "proposal": proposal, "verdict": verdict,
|
||||
})
|
||||
|
||||
|
||||
def rollback(repo: str, rollback_ref: str) -> dict:
|
||||
return _call("smod_rollback", {
|
||||
"repo": repo, "rollback_ref": rollback_ref,
|
||||
})
|
||||
|
||||
|
||||
def monitor(proposal: dict) -> dict:
|
||||
return _call("smod_monitor", {"proposal": proposal})
|
||||
Generated
+135
@@ -0,0 +1,135 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "self_modify"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
"indexmap",
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01016da373cd8f7ef12624f796309f5c31ba8d646dd08856c02cd741d823c622"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "self_modify"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["rlib", "cdylib", "staticlib"]
|
||||
|
||||
[dependencies]
|
||||
serde_json = { version = "1", features = ["preserve_order"] }
|
||||
@@ -0,0 +1,343 @@
|
||||
//! self_modify branch battery — parity vs the frozen Python-oracle truth
|
||||
//! (frozen_smod_truth.json: 32 cases across all six public functions and
|
||||
//! every branch — propose ok/HEAD/unknown-base/duplicate-branch/missing
|
||||
//! repo, gates pass/fail-stderr/2000-char tail/out-then-err/PATH-stubbed
|
||||
//! default command, the full compare_baseline truthiness lattice, promote
|
||||
//! up-to-date/real-merge/merge-fail/discard/ghost/missing-key, rollback
|
||||
//! ok/master/bad-ref/missing repo, monitor full/empty/partial/extra/
|
||||
//! non-string pass-through). Frozen via scripts/gen_self_modify_truth.py
|
||||
//! with RUNE_SMOD_CORE=python pinned (poisoned-truth law). Embedded
|
||||
//! VERBATIM — never transcribed.
|
||||
//!
|
||||
//! Deterministic-fixture law: git fixtures use fixed identity and fixed
|
||||
//! author/committer dates, so the seed SHA is identical everywhere and is
|
||||
//! frozen LITERALLY; @REPO@ / @WORKTREE@ / @STUBDIR@ placeholders are
|
||||
//! substituted per fresh fixture, and the declarative per-case "setup"
|
||||
//! steps run through the same interpreter as the generator and the Python
|
||||
//! replay. Raise-parity rows ({"raises": ...}) assert the kernel returns
|
||||
//! an error (envelope law — the loader re-raises).
|
||||
|
||||
use crate::{
|
||||
compare_baseline, monitor, propose_change, promote_or_discard, rollback, run_gates,
|
||||
FfiError,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
const TRUTH: &str = include_str!("frozen_smod_truth.json");
|
||||
|
||||
/// One gate at a time: the PATH-stub case mutates process env, and git
|
||||
/// fixtures share the temp root.
|
||||
static SERIAL: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn truth() -> &'static Value {
|
||||
static TRUTH_VAL: OnceLock<Value> = OnceLock::new();
|
||||
TRUTH_VAL.get_or_init(|| serde_json::from_str(TRUTH).expect("frozen truth parses"))
|
||||
}
|
||||
|
||||
fn cases(group: &str) -> &[Value] {
|
||||
truth()["cases"][group]
|
||||
.as_array()
|
||||
.expect("frozen group exists")
|
||||
}
|
||||
|
||||
// ── fixture + setup interpreter (same recipe as the generator) ───────────
|
||||
|
||||
fn battery_root(group: &str) -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"smod-battery-{}-{group}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(&root).expect("battery root");
|
||||
root
|
||||
}
|
||||
|
||||
fn git(root: &Path, args: &[&str]) -> (bool, String, String) {
|
||||
let out = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(root)
|
||||
.args(args)
|
||||
.env("GIT_AUTHOR_DATE", "2026-10-11T12:00:00+00:00")
|
||||
.env("GIT_COMMITTER_DATE", "2026-10-11T12:00:00+00:00")
|
||||
.output()
|
||||
.expect("git runs");
|
||||
(
|
||||
out.status.success(),
|
||||
String::from_utf8_lossy(&out.stdout).into_owned(),
|
||||
String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
fn make_fixture(case_root: &Path) -> PathBuf {
|
||||
let repo = case_root.join("repo");
|
||||
fs::create_dir_all(&repo).expect("fixture repo dir");
|
||||
let (ok, _, err) = git(&repo, &["init", "-q", "-b", "master"]);
|
||||
assert!(ok, "git init failed: {err}");
|
||||
git(&repo, &["config", "user.email", "t@t"]);
|
||||
git(&repo, &["config", "user.name", "t"]);
|
||||
git(&repo, &["config", "commit.gpgsign", "false"]);
|
||||
fs::write(repo.join("f.txt"), "v1\n").expect("seed file");
|
||||
git(&repo, &["add", "."]);
|
||||
let (ok, _, err) = git(&repo, &["commit", "-qm", "seed"]);
|
||||
assert!(ok, "seed commit failed: {err}");
|
||||
repo
|
||||
}
|
||||
|
||||
fn base_sha(repo: &Path) -> String {
|
||||
let (ok, out, _) = git(repo, &["rev-parse", "master"]);
|
||||
assert!(ok, "rev-parse master failed");
|
||||
out.trim().to_string()
|
||||
}
|
||||
|
||||
fn run_setup(repo: &Path, steps: &[Value]) -> Option<PathBuf> {
|
||||
let mut wt = None;
|
||||
for step in steps {
|
||||
let step = step.as_str().expect("setup step is a string");
|
||||
let (op, arg) = match step.split_once(':') {
|
||||
Some((op, arg)) => (op, arg),
|
||||
None => (step, ""),
|
||||
};
|
||||
match op {
|
||||
"worktree" => {
|
||||
let path = repo.parent().unwrap().join(format!("wt-{arg}"));
|
||||
let (ok, _, err) =
|
||||
git(repo, &["worktree", "add", &path.to_string_lossy(), "-b", arg, "master"]);
|
||||
assert!(ok, "worktree add failed: {err}");
|
||||
wt = Some(path);
|
||||
}
|
||||
"commit_in_worktree" => {
|
||||
let path = wt.clone().expect("worktree exists");
|
||||
fs::write(path.join("f.txt"), "v2\n").expect("worktree file");
|
||||
let (ok, _, err) = git(&path, &["commit", "-qam", "c"]);
|
||||
assert!(ok, "worktree commit failed: {err}");
|
||||
}
|
||||
"dirty_repo" => {
|
||||
fs::write(repo.join("f.txt"), "v9\n").expect("dirty file");
|
||||
}
|
||||
other => panic!("unknown setup op: {other}"),
|
||||
}
|
||||
}
|
||||
wt
|
||||
}
|
||||
|
||||
fn make_stub_dir(case_root: &Path) -> PathBuf {
|
||||
let stub = case_root.join("stub");
|
||||
fs::create_dir_all(&stub).expect("stub dir");
|
||||
let path = stub.join("python3");
|
||||
fs::write(&path, "#!/bin/sh\necho stub-gates-green\nexit 0\n").expect("stub script");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = fs::metadata(&path).expect("stub stat").permissions();
|
||||
perms.set_mode(perms.mode() | 0o100);
|
||||
fs::set_permissions(&path, perms).expect("stub chmod");
|
||||
}
|
||||
stub
|
||||
}
|
||||
|
||||
// ── placeholder plumbing ─────────────────────────────────────────────────
|
||||
|
||||
fn de_substitute(value: &Value, repo: &Path, wt: &Path, stub: &Path) -> Value {
|
||||
let s = serde_json::to_string(value).expect("serialize");
|
||||
let s = s
|
||||
.replace("@REPO@", &repo.to_string_lossy())
|
||||
.replace("@WORKTREE@", &wt.to_string_lossy())
|
||||
.replace("@STUBDIR@", &stub.to_string_lossy());
|
||||
serde_json::from_str(&s).expect("resubstituted json")
|
||||
}
|
||||
|
||||
fn normalize(got: &Value, repo: &Path, wt: &Path, stub: &Path) -> Value {
|
||||
let mut s = serde_json::to_string(got).expect("serialize got");
|
||||
let reps: Vec<(String, &str)> = vec![
|
||||
(
|
||||
got.get("worktree")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string),
|
||||
"@WORKTREE@",
|
||||
),
|
||||
(Some(repo.to_string_lossy().into_owned()), "@REPO@"),
|
||||
(Some(stub.to_string_lossy().into_owned()), "@STUBDIR@"),
|
||||
(Some(wt.to_string_lossy().into_owned()), "@WORKTREE@"),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(a, ph)| a.map(|a| (a, ph)))
|
||||
.collect();
|
||||
for (actual, ph) in reps {
|
||||
s = s.replace(&actual, ph);
|
||||
}
|
||||
serde_json::from_str(&s).expect("normalized json")
|
||||
}
|
||||
|
||||
// ── kernel dispatch ──────────────────────────────────────────────────────
|
||||
|
||||
fn call_kernel(group: &str, input: &Value) -> Result<Value, FfiError> {
|
||||
let s = |key: &str| input.get(key).and_then(Value::as_str).unwrap_or("");
|
||||
match group {
|
||||
"propose_change" => propose_change(s("repo"), s("branch"), s("base"), s("now")),
|
||||
"run_gates" => run_gates(
|
||||
s("worktree"),
|
||||
input.get("test_cmd").and_then(Value::as_array),
|
||||
),
|
||||
"compare_baseline" => Ok(compare_baseline(
|
||||
&input["candidate_result"],
|
||||
&input["baseline_result"],
|
||||
)),
|
||||
"promote_or_discard" => promote_or_discard(
|
||||
s("repo"),
|
||||
&input["proposal"],
|
||||
&input["verdict"],
|
||||
),
|
||||
"rollback" => rollback(s("repo"), s("rollback_ref")),
|
||||
"monitor" => Ok(monitor(&input["proposal"])),
|
||||
other => panic!("unknown kernel group: {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn replay_case(group: &str, case: &Value) {
|
||||
let name = case["name"].as_str().expect("case name");
|
||||
let root = battery_root(group);
|
||||
let case_root = root.join(name);
|
||||
fs::create_dir_all(&case_root).expect("case root");
|
||||
let repo = make_fixture(&case_root);
|
||||
let wt = run_setup(&repo, case["setup"].as_array().map(Vec::as_slice).unwrap_or(&[]))
|
||||
.unwrap_or_else(|| case_root.join("wt-input"));
|
||||
let stub = if case["stub"].as_bool().unwrap_or(false) {
|
||||
make_stub_dir(&case_root)
|
||||
} else {
|
||||
case_root.join("stub-unused")
|
||||
};
|
||||
|
||||
let live = de_substitute(&case["input"], &repo, &wt, &stub);
|
||||
let got = if case["stub"].as_bool().unwrap_or(false) {
|
||||
let guard = PathGuard::stub(&stub);
|
||||
let r = call_kernel(group, &live);
|
||||
guard.restore();
|
||||
r
|
||||
} else {
|
||||
call_kernel(group, &live)
|
||||
};
|
||||
|
||||
let expected = &case["expected"];
|
||||
if expected.get("raises").is_some() {
|
||||
assert!(
|
||||
got.is_err(),
|
||||
"[{group}] {name}: expected a raise ({}), kernel returned {:?}",
|
||||
expected["raises"].as_str().unwrap_or(""),
|
||||
got.ok()
|
||||
);
|
||||
return;
|
||||
}
|
||||
let got = got.unwrap_or_else(|e| {
|
||||
panic!("[{group}] {name}: kernel raised {e}, expected {:?}", expected)
|
||||
});
|
||||
let got = normalize(&got, &repo, &wt, &stub);
|
||||
assert_eq!(
|
||||
&got,
|
||||
expected,
|
||||
"[{group}] {name} diverged (frozen truth, kernel-level)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Sets PATH to the stub dir; restore() puts the old PATH back.
|
||||
struct PathGuard {
|
||||
old: Option<std::ffi::OsString>,
|
||||
}
|
||||
|
||||
impl PathGuard {
|
||||
fn stub(stub: &Path) -> PathGuard {
|
||||
let old = std::env::var_os("PATH");
|
||||
std::env::set_var(
|
||||
"PATH",
|
||||
std::env::join_paths(
|
||||
std::iter::once(stub.to_path_buf()).chain(
|
||||
std::env::split_paths(old.as_deref().unwrap_or_default()),
|
||||
),
|
||||
)
|
||||
.expect("joined PATH"),
|
||||
);
|
||||
PathGuard { old }
|
||||
}
|
||||
|
||||
fn restore(self) {
|
||||
if let Some(old) = self.old {
|
||||
std::env::set_var("PATH", old);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── the battery ──────────────────────────────────────────────────────────
|
||||
|
||||
fn replay_group(group: &str) -> usize {
|
||||
let _serial = SERIAL.lock().expect("serial battery");
|
||||
let group_cases = cases(group);
|
||||
for case in group_cases {
|
||||
replay_case(group, case);
|
||||
}
|
||||
group_cases.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propose_change_parity_battery() {
|
||||
assert_eq!(replay_group("propose_change"), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_gates_parity_battery() {
|
||||
assert_eq!(replay_group("run_gates"), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_baseline_parity_battery() {
|
||||
assert_eq!(replay_group("compare_baseline"), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn promote_or_discard_parity_battery() {
|
||||
assert_eq!(replay_group("promote_or_discard"), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollback_parity_battery() {
|
||||
assert_eq!(replay_group("rollback"), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monitor_parity_battery() {
|
||||
assert_eq!(replay_group("monitor"), 5);
|
||||
}
|
||||
|
||||
/// The frozen propose rows pin the seed SHA literally — assert the
|
||||
/// battery fixture reproduces it (the deterministic-fixture law itself).
|
||||
#[test]
|
||||
fn fixture_sha_is_deterministic() {
|
||||
let _serial = SERIAL.lock().expect("serial battery");
|
||||
let root = battery_root("sha-probe");
|
||||
fs::create_dir_all(&root).expect("probe root");
|
||||
let repo = make_fixture(&root);
|
||||
assert_eq!(
|
||||
base_sha(&repo),
|
||||
cases("rollback")[0]["input"]["rollback_ref"]
|
||||
.as_str()
|
||||
.expect("frozen sha"),
|
||||
"fixture recipe no longer reproduces the frozen seed SHA"
|
||||
);
|
||||
}
|
||||
|
||||
/// The monitor watch list is verbatim oracle vocabulary, pinned twice
|
||||
/// (frozen rows + this direct assertion).
|
||||
#[test]
|
||||
fn monitor_watch_vocabulary() {
|
||||
let m = monitor(&json!({"branch": "b", "rollback_ref": "abc123"}));
|
||||
assert_eq!(
|
||||
m["watch"],
|
||||
json!([
|
||||
"gate suite green on next CI run",
|
||||
"no new failure signatures in the next 24h"
|
||||
])
|
||||
);
|
||||
assert_eq!(m["monitoring"], json!(true));
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
{
|
||||
"cases": {
|
||||
"compare_baseline": [
|
||||
{
|
||||
"expected": {
|
||||
"promote": false,
|
||||
"reason": "candidate gates red \u2014 regression blocks promotion"
|
||||
},
|
||||
"input": {
|
||||
"baseline_result": {
|
||||
"passed": true
|
||||
},
|
||||
"candidate_result": {
|
||||
"passed": false
|
||||
}
|
||||
},
|
||||
"name": "cb-candidate-red",
|
||||
"setup": [],
|
||||
"stub": false
|
||||
},
|
||||
{
|
||||
"expected": {
|
||||
"promote": false,
|
||||
"reason": "candidate gates red \u2014 regression blocks promotion"
|
||||
},
|
||||
"input": {
|
||||
"baseline_result": {
|
||||
"passed": false
|
||||
},
|
||||
"candidate_result": {
|
||||
"passed": false
|
||||
}
|
||||
},
|
||||
"name": "cb-candidate-red-baseline-red",
|
||||
"setup": [],
|
||||
"stub": false
|
||||
},
|
||||
{
|
||||
"expected": {
|
||||
"promote": true,
|
||||
"reason": "candidate green, baseline was already red \u2014 no regression introduced"
|
||||
},
|
||||
"input": {
|
||||
"baseline_result": {
|
||||
"passed": false
|
||||
},
|
||||
"candidate_result": {
|
||||
"passed": true
|
||||
}
|
||||
},
|
||||
"name": "cb-baseline-red",
|
||||
"setup": [],
|
||||
"stub": false
|
||||
},
|
||||
{
|
||||
"expected": {
|
||||
"promote": true,
|
||||
"reason": "candidate green, baseline green \u2014 no regression"
|
||||
},
|
||||
"input": {
|
||||
"baseline_result": {
|
||||
"passed": true
|
||||
},
|
||||
"candidate_result": {
|
||||
"passed": true
|
||||
}
|
||||
},
|
||||
"name": "cb-both-green",
|
||||
"setup": [],
|
||||
"stub": false
|
||||
},
|
||||
{
|
||||
"expected": {
|
||||
"promote": false,
|
||||
"reason": "candidate gates red \u2014 regression blocks promotion"
|
||||
},
|
||||
"input": {
|
||||
"baseline_result": {
|
||||
"passed": true
|
||||
},
|
||||
"candidate_result": {}
|
||||
},
|
||||
"name": "cb-missing-passed-key",
|
||||
"setup": [],
|
||||
"stub": false
|
||||
},
|
||||
{
|
||||
"expected": {
|
||||
"promote": true,
|
||||
"reason": "candidate green, baseline was already red \u2014 no regression introduced"
|
||||
},
|
||||
"input": {
|
||||
"baseline_result": {
|
||||
"passed": 0
|
||||
},
|
||||
"candidate_result": {
|
||||
"passed": 1
|
||||
}
|
||||
},
|
||||
"name": "cb-truthy-coercion",
|
||||
"setup": [],
|
||||
"stub": false
|
||||
},
|
||||
{
|
||||
"expected": {
|
||||
"promote": false,
|
||||
"reason": "candidate gates red \u2014 regression blocks promotion"
|
||||
},
|
||||
"input": {
|
||||
"baseline_result": {
|
||||
"passed": true
|
||||
},
|
||||
"candidate_result": {
|
||||
"passed": null
|
||||
}
|
||||
},
|
||||
"name": "cb-explicit-null-passed",
|
||||
"setup": [],
|
||||
"stub": false
|
||||
}
|
||||
],
|
||||
"monitor": [
|
||||
{
|
||||
"expected": {
|
||||
"branch": "b",
|
||||
"monitoring": true,
|
||||
"rollback_ref": "abc123",
|
||||
"watch": [
|
||||
"gate suite green on next CI run",
|
||||
"no new failure signatures in the next 24h"
|
||||
]
|
||||
},
|
||||
"input": {
|
||||
"proposal": {
|
||||
"branch": "b",
|
||||
"rollback_ref": "abc123"
|
||||
}
|
||||
},
|
||||
"name": "mon-full",
|
||||
"setup": [],
|
||||
"stub": false
|
||||
},
|
||||
{
|
||||
"expected": {
|
||||
"branch": "",
|
||||
"monitoring": true,
|
||||
"rollback_ref": "",
|
||||
"watch": [
|
||||
"gate suite green on next CI run",
|
||||
"no new failure signatures in the next 24h"
|
||||
]
|
||||
},
|
||||
"input": {
|
||||
"proposal": {}
|
||||
},
|
||||
"name": "mon-empty",
|
||||
"setup": [],
|
||||
"stub": false
|
||||
},
|
||||
{
|
||||
"expected": {
|
||||
"branch": "only-b",
|
||||
"monitoring": true,
|
||||
"rollback_ref": "",
|
||||
"watch": [
|
||||
"gate suite green on next CI run",
|
||||
"no new failure signatures in the next 24h"
|
||||
]
|
||||
},
|
||||
"input": {
|
||||
"proposal": {
|
||||
"branch": "only-b"
|
||||
}
|
||||
},
|
||||
"name": "mon-partial",
|
||||
"setup": [],
|
||||
"stub": false
|
||||
},
|
||||
{
|
||||
"expected": {
|
||||
"branch": "b",
|
||||
"monitoring": true,
|
||||
"rollback_ref": "r",
|
||||
"watch": [
|
||||
"gate suite green on next CI run",
|
||||
"no new failure signatures in the next 24h"
|
||||
]
|
||||
},
|
||||
"input": {
|
||||
"proposal": {
|
||||
"branch": "b",
|
||||
"extra": 1,
|
||||
"rollback_ref": "r"
|
||||
}
|
||||
},
|
||||
"name": "mon-extra-keys",
|
||||
"setup": [],
|
||||
"stub": false
|
||||
},
|
||||
{
|
||||
"expected": {
|
||||
"branch": 7,
|
||||
"monitoring": true,
|
||||
"rollback_ref": null,
|
||||
"watch": [
|
||||
"gate suite green on next CI run",
|
||||
"no new failure signatures in the next 24h"
|
||||
]
|
||||
},
|
||||
"input": {
|
||||
"proposal": {
|
||||
"branch": 7,
|
||||
"rollback_ref": null
|
||||
}
|
||||
},
|
||||
"name": "mon-nonstring-branch",
|
||||
"setup": [],
|
||||
"stub": false
|
||||
}
|
||||
],
|
||||
"promote_or_discard": [
|
||||
{
|
||||
"expected": {
|
||||
"branch": "discard-1",
|
||||
"discarded": true,
|
||||
"promoted": false,
|
||||
"rollback_ref": "16405a24f7fb0bab798f7c4cb4fb63eec407d6b1"
|
||||
},
|
||||
"input": {
|
||||
"proposal": {
|
||||
"at": "2026-10-11T12:00:00Z",
|
||||
"base": "master",
|
||||
"branch": "discard-1",
|
||||
"proposed": true,
|
||||
"rollback_ref": "16405a24f7fb0bab798f7c4cb4fb63eec407d6b1",
|
||||
"worktree": "@WORKTREE@"
|
||||
},
|
||||
"repo": "@REPO@",
|
||||
"verdict": {
|
||||
"promote": false,
|
||||
"reason": "test"
|
||||
}
|
||||
},
|
||||
"name": "pod-discard",
|
||||
"setup": [
|
||||
"worktree:discard-1"
|
||||
],
|
||||
"stub": false
|
||||
},
|
||||
{
|
||||
"expected": {
|
||||
"branch": "podd-2",
|
||||
"promoted": true,
|
||||
"rollback_ref": "16405a24f7fb0bab798f7c4cb4fb63eec407d6b1"
|
||||
},
|
||||
"input": {
|
||||
"proposal": {
|
||||
"at": "2026-10-11T12:00:00Z",
|
||||
"base": "master",
|
||||
"branch": "podd-2",
|
||||
"proposed": true,
|
||||
"rollback_ref": "16405a24f7fb0bab798f7c4cb4fb63eec407d6b1",
|
||||
"worktree": "@WORKTREE@"
|
||||
},
|
||||
"repo": "@REPO@",
|
||||
"verdict": {
|
||||
"promote": true,
|
||||
"reason": "test"
|
||||
}
|
||||
},
|
||||
"name": "pod-promote-up-to-date",
|
||||
"setup": [
|
||||
"worktree:podd-2"
|
||||
],
|
||||
"stub": false
|
||||
},
|
||||
{
|
||||
"expected": {
|
||||
"branch": "podd-3",
|
||||
"promoted": true,
|
||||
"rollback_ref": "16405a24f7fb0bab798f7c4cb4fb63eec407d6b1"
|
||||
},
|
||||
"input": {
|
||||
"proposal": {
|
||||
"at": "2026-10-11T12:00:00Z",
|
||||
"base": "master",
|
||||
"branch": "podd-3",
|
||||
"proposed": true,
|
||||
"rollback_ref": "16405a24f7fb0bab798f7c4cb4fb63eec407d6b1",
|
||||
"worktree": "@WORKTREE@"
|
||||
},
|
||||
"repo": "@REPO@",
|
||||
"verdict": {
|
||||
"promote": true,
|
||||
"reason": "test"
|
||||
}
|
||||
},
|
||||
"name": "pod-promote-real-merge",
|
||||
"setup": [
|
||||
"worktree:podd-3",
|
||||
"commit_in_worktree"
|
||||
],
|
||||
"stub": false
|
||||
},
|
||||
{
|
||||
"expected": {
|
||||
"error": "merge failed: error: Your local changes to the following files would be overwritten by merge:\n\tf.txt\nPlease commit your changes or stash them before you merge.\nAborting\nMerge with strategy ort failed.\n",
|
||||
"promoted": false,
|
||||
"rollback_ref": "16405a24f7fb0bab798f7c4cb4fb63eec407d6b1"
|
||||
},
|
||||
"input": {
|
||||
"proposal": {
|
||||
"at": "2026-10-11T12:00:00Z",
|
||||
"base": "master",
|
||||
"branch": "podd-4",
|
||||
"proposed": true,
|
||||
"rollback_ref": "16405a24f7fb0bab798f7c4cb4fb63eec407d6b1",
|
||||
"worktree": "@WORKTREE@"
|
||||
},
|
||||
"repo": "@REPO@",
|
||||
"verdict": {
|
||||
"promote": true,
|
||||
"reason": "test"
|
||||
}
|
||||
},
|
||||
"name": "pod-merge-fail",
|
||||
"setup": [
|
||||
"worktree:podd-4",
|
||||
"commit_in_worktree",
|
||||
"dirty_repo"
|
||||
],
|
||||
"stub": false
|
||||
},
|
||||
{
|
||||
"expected": {
|
||||
"branch": "ghost-branch",
|
||||
"discarded": true,
|
||||
"promoted": false,
|
||||
"rollback_ref": "16405a24f7fb0bab798f7c4cb4fb63eec407d6b1"
|
||||
},
|
||||
"input": {
|
||||
"proposal": {
|
||||
"at": "2026-10-11T12:00:00Z",
|
||||
"base": "master",
|
||||
"branch": "ghost-branch",
|
||||
"proposed": true,
|
||||
"rollback_ref": "16405a24f7fb0bab798f7c4cb4fb63eec407d6b1",
|
||||
"worktree": "@WORKTREE@"
|
||||
},
|
||||
"repo": "@REPO@",
|
||||
"verdict": {
|
||||
"promote": false,
|
||||
"reason": "test"
|
||||
}
|
||||
},
|
||||
"name": "pod-discard-ghost",
|
||||
"setup": [],
|
||||
"stub": false
|
||||
},
|
||||
{
|
||||
"expected": {
|
||||
"raises": "KeyError: 'branch'"
|
||||
},
|
||||
"input": {
|
||||
"proposal": {
|
||||
"at": "2026-10-11T12:00:00Z",
|
||||
"base": "master",
|
||||
"proposed": true,
|
||||
"rollback_ref": "16405a24f7fb0bab798f7c4cb4fb63eec407d6b1",
|
||||
"worktree": "@WORKTREE@"
|
||||
},
|
||||
"repo": "@REPO@",
|
||||
"verdict": {
|
||||
"promote": true
|
||||
}
|
||||
},
|
||||
"name": "pod-missing-branch-key",
|
||||
"setup": [],
|
||||
"stub": false
|
||||
}
|
||||
],
|
||||
"propose_change": [
|
||||
{
|
||||
"expected": {
|
||||
"at": "2026-10-11T12:00:00Z",
|
||||
"base": "master",
|
||||
"branch": "cand-ok",
|
||||
"proposed": true,
|
||||
"rollback_ref": "16405a24f7fb0bab798f7c4cb4fb63eec407d6b1",
|
||||
"worktree": "@WORKTREE@"
|
||||
},
|
||||
"input": {
|
||||
"base": "master",
|
||||
"branch": "cand-ok",
|
||||
"now": "2026-10-11T12:00:00Z",
|
||||
"repo": "@REPO@"
|
||||
},
|
||||
"name": "propose-ok",
|
||||
"setup": [],
|
||||
"stub": false
|
||||
},
|
||||
{
|
||||
"expected": {
|
||||
"at": "2026-10-11T12:00:00Z",
|
||||
"base": "HEAD",
|
||||
"branch": "cand-head",
|
||||
"proposed": true,
|
||||
"rollback_ref": "16405a24f7fb0bab798f7c4cb4fb63eec407d6b1",
|
||||
"worktree": "@WORKTREE@"
|
||||
},
|
||||
"input": {
|
||||
"base": "HEAD",
|
||||
"branch": "cand-head",
|
||||
"now": "2026-10-11T12:00:00Z",
|
||||
"repo": "@REPO@"
|
||||
},
|
||||
"name": "propose-base-head",
|
||||
"setup": [],
|
||||
"stub": false
|
||||
},
|
||||
{
|
||||
"expected": {
|
||||
"error": "unknown base: no-such-ref",
|
||||
"proposed": false
|
||||
},
|
||||
"input": {
|
||||
"base": "no-such-ref",
|
||||
"branch": "cand-bad",
|
||||
"now": "2026-10-11T12:00:00Z",
|
||||
"repo": "@REPO@"
|
||||
},
|
||||
"name": "propose-unknown-base",
|
||||
"setup": [],
|
||||
"stub": false
|
||||
},
|
||||
{
|
||||
"expected": {
|
||||
"error": "worktree failed: Preparing worktree (new branch 'dup')\nfatal: a branch named 'dup' already exists",
|
||||
"proposed": false
|
||||
},
|
||||
"input": {
|
||||
"base": "master",
|
||||
"branch": "dup",
|
||||
"now": "2026-10-11T12:00:00Z",
|
||||
"repo": "@REPO@"
|
||||
},
|
||||
"name": "propose-dup-branch",
|
||||
"setup": [
|
||||
"worktree:dup"
|
||||
],
|
||||
"stub": false
|
||||
},
|
||||
{
|
||||
"expected": {
|
||||
"raises": "FileNotFoundError"
|
||||
},
|
||||
"input": {
|
||||
"base": "master",
|
||||
"branch": "cand-gone",
|
||||
"now": "2026-10-11T12:00:00Z",
|
||||
"repo": "/nonexistent-selfmod-truth/repo"
|
||||
},
|
||||
"name": "propose-missing-repo",
|
||||
"setup": [],
|
||||
"stub": false
|
||||
}
|
||||
],
|
||||
"rollback": [
|
||||
{
|
||||
"expected": {
|
||||
"ref": "16405a24f7fb0bab798f7c4cb4fb63eec407d6b1",
|
||||
"rolled_back": true
|
||||
},
|
||||
"input": {
|
||||
"repo": "@REPO@",
|
||||
"rollback_ref": "16405a24f7fb0bab798f7c4cb4fb63eec407d6b1"
|
||||
},
|
||||
"name": "rb-ok-after-dirty",
|
||||
"setup": [
|
||||
"dirty_repo"
|
||||
],
|
||||
"stub": false
|
||||
},
|
||||
{
|
||||
"expected": {
|
||||
"ref": "master",
|
||||
"rolled_back": true
|
||||
},
|
||||
"input": {
|
||||
"repo": "@REPO@",
|
||||
"rollback_ref": "master"
|
||||
},
|
||||
"name": "rb-ref-master",
|
||||
"setup": [
|
||||
"dirty_repo"
|
||||
],
|
||||
"stub": false
|
||||
},
|
||||
{
|
||||
"expected": {
|
||||
"error": "fatal: ambiguous argument 'no-such-ref': unknown revision or path not in the working tree.\nUse '--' to separate paths from revisions, like this:\n'git <command> [<revision>...] -- [<file>...]'\n",
|
||||
"rolled_back": false
|
||||
},
|
||||
"input": {
|
||||
"repo": "@REPO@",
|
||||
"rollback_ref": "no-such-ref"
|
||||
},
|
||||
"name": "rb-bad-ref",
|
||||
"setup": [],
|
||||
"stub": false
|
||||
},
|
||||
{
|
||||
"expected": {
|
||||
"raises": "FileNotFoundError"
|
||||
},
|
||||
"input": {
|
||||
"repo": "/nonexistent-selfmod-truth/repo",
|
||||
"rollback_ref": "master"
|
||||
},
|
||||
"name": "rb-missing-repo",
|
||||
"setup": [],
|
||||
"stub": false
|
||||
}
|
||||
],
|
||||
"run_gates": [
|
||||
{
|
||||
"expected": {
|
||||
"output": "gates-green\n",
|
||||
"passed": true
|
||||
},
|
||||
"input": {
|
||||
"test_cmd": [
|
||||
"echo",
|
||||
"gates-green"
|
||||
],
|
||||
"worktree": "@REPO@"
|
||||
},
|
||||
"name": "gates-pass",
|
||||
"setup": [],
|
||||
"stub": false
|
||||
},
|
||||
{
|
||||
"expected": {
|
||||
"output": "boom\n",
|
||||
"passed": false
|
||||
},
|
||||
"input": {
|
||||
"test_cmd": [
|
||||
"sh",
|
||||
"-c",
|
||||
"echo boom 1>&2; exit 1"
|
||||
],
|
||||
"worktree": "@REPO@"
|
||||
},
|
||||
"name": "gates-fail-stderr",
|
||||
"setup": [],
|
||||
"stub": false
|
||||
},
|
||||
{
|
||||
"expected": {
|
||||
"output": "2601\n2602\n2603\n2604\n2605\n2606\n2607\n2608\n2609\n2610\n2611\n2612\n2613\n2614\n2615\n2616\n2617\n2618\n2619\n2620\n2621\n2622\n2623\n2624\n2625\n2626\n2627\n2628\n2629\n2630\n2631\n2632\n2633\n2634\n2635\n2636\n2637\n2638\n2639\n2640\n2641\n2642\n2643\n2644\n2645\n2646\n2647\n2648\n2649\n2650\n2651\n2652\n2653\n2654\n2655\n2656\n2657\n2658\n2659\n2660\n2661\n2662\n2663\n2664\n2665\n2666\n2667\n2668\n2669\n2670\n2671\n2672\n2673\n2674\n2675\n2676\n2677\n2678\n2679\n2680\n2681\n2682\n2683\n2684\n2685\n2686\n2687\n2688\n2689\n2690\n2691\n2692\n2693\n2694\n2695\n2696\n2697\n2698\n2699\n2700\n2701\n2702\n2703\n2704\n2705\n2706\n2707\n2708\n2709\n2710\n2711\n2712\n2713\n2714\n2715\n2716\n2717\n2718\n2719\n2720\n2721\n2722\n2723\n2724\n2725\n2726\n2727\n2728\n2729\n2730\n2731\n2732\n2733\n2734\n2735\n2736\n2737\n2738\n2739\n2740\n2741\n2742\n2743\n2744\n2745\n2746\n2747\n2748\n2749\n2750\n2751\n2752\n2753\n2754\n2755\n2756\n2757\n2758\n2759\n2760\n2761\n2762\n2763\n2764\n2765\n2766\n2767\n2768\n2769\n2770\n2771\n2772\n2773\n2774\n2775\n2776\n2777\n2778\n2779\n2780\n2781\n2782\n2783\n2784\n2785\n2786\n2787\n2788\n2789\n2790\n2791\n2792\n2793\n2794\n2795\n2796\n2797\n2798\n2799\n2800\n2801\n2802\n2803\n2804\n2805\n2806\n2807\n2808\n2809\n2810\n2811\n2812\n2813\n2814\n2815\n2816\n2817\n2818\n2819\n2820\n2821\n2822\n2823\n2824\n2825\n2826\n2827\n2828\n2829\n2830\n2831\n2832\n2833\n2834\n2835\n2836\n2837\n2838\n2839\n2840\n2841\n2842\n2843\n2844\n2845\n2846\n2847\n2848\n2849\n2850\n2851\n2852\n2853\n2854\n2855\n2856\n2857\n2858\n2859\n2860\n2861\n2862\n2863\n2864\n2865\n2866\n2867\n2868\n2869\n2870\n2871\n2872\n2873\n2874\n2875\n2876\n2877\n2878\n2879\n2880\n2881\n2882\n2883\n2884\n2885\n2886\n2887\n2888\n2889\n2890\n2891\n2892\n2893\n2894\n2895\n2896\n2897\n2898\n2899\n2900\n2901\n2902\n2903\n2904\n2905\n2906\n2907\n2908\n2909\n2910\n2911\n2912\n2913\n2914\n2915\n2916\n2917\n2918\n2919\n2920\n2921\n2922\n2923\n2924\n2925\n2926\n2927\n2928\n2929\n2930\n2931\n2932\n2933\n2934\n2935\n2936\n2937\n2938\n2939\n2940\n2941\n2942\n2943\n2944\n2945\n2946\n2947\n2948\n2949\n2950\n2951\n2952\n2953\n2954\n2955\n2956\n2957\n2958\n2959\n2960\n2961\n2962\n2963\n2964\n2965\n2966\n2967\n2968\n2969\n2970\n2971\n2972\n2973\n2974\n2975\n2976\n2977\n2978\n2979\n2980\n2981\n2982\n2983\n2984\n2985\n2986\n2987\n2988\n2989\n2990\n2991\n2992\n2993\n2994\n2995\n2996\n2997\n2998\n2999\n3000\n",
|
||||
"passed": true
|
||||
},
|
||||
"input": {
|
||||
"test_cmd": [
|
||||
"sh",
|
||||
"-c",
|
||||
"seq 1 3000"
|
||||
],
|
||||
"worktree": "@REPO@"
|
||||
},
|
||||
"name": "gates-tail-2000",
|
||||
"setup": [],
|
||||
"stub": false
|
||||
},
|
||||
{
|
||||
"expected": {
|
||||
"output": "o1\no2\ne1\n",
|
||||
"passed": false
|
||||
},
|
||||
"input": {
|
||||
"test_cmd": [
|
||||
"sh",
|
||||
"-c",
|
||||
"echo o1; echo e1 1>&2; echo o2; exit 7"
|
||||
],
|
||||
"worktree": "@REPO@"
|
||||
},
|
||||
"name": "gates-out-then-err",
|
||||
"setup": [],
|
||||
"stub": false
|
||||
},
|
||||
{
|
||||
"expected": {
|
||||
"output": "stub-gates-green\n",
|
||||
"passed": true
|
||||
},
|
||||
"input": {
|
||||
"test_cmd": null,
|
||||
"worktree": "@REPO@"
|
||||
},
|
||||
"name": "gates-default-cmd",
|
||||
"setup": [],
|
||||
"stub": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"frozen": "2026-10-12T00:57:24.060736+00:00",
|
||||
"note": "Frozen via scripts/gen_self_modify_truth.py against the live Python oracle; wall clock frozen at 2026-10-11T12:00:00Z; git fixtures use fixed identity+dates so the seed SHA is literal. @REPO@/@WORKTREE@/@STUBDIR@ are placeholders substituted at replay. Battery embeds this VERBATIM.",
|
||||
"oracle": "src/rune/self_modify.py",
|
||||
"pin": "RUNE_SMOD_CORE=python"
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
//! self_modify — reversible, benchmark-gated self-modification (W-sim).
|
||||
//!
|
||||
//! FULL parity port of src/rune/self_modify.py (dichotomy wave, #2479
|
||||
//! tranche). The required loop, as code (oracle docstring, verbatim):
|
||||
//!
|
||||
//! ```text
|
||||
//! candidate change → isolated branch/worktree → tests →
|
||||
//! RuneBench subset → compare baseline → promote or discard →
|
||||
//! monitor → rollback
|
||||
//! ```
|
||||
//!
|
||||
//! No production state is modified without a recovery path: every proposal
|
||||
//! carries its rollback ref (the pre-change HEAD), and promotion requires
|
||||
//! the gate suite green + no regression vs the baseline run.
|
||||
//!
|
||||
//! Boundary: the subprocess seam (`_run` — every git invocation and gate
|
||||
//! command) is OWNED by the kernel, exactly like the oracle's _run. The
|
||||
//! wall clock never enters the kernel (no-clock law): `propose_change`
|
||||
//! takes `now` as an argument and the facade stamps
|
||||
//! time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()).
|
||||
//!
|
||||
//! FFI (doc-envelope law): every symbol takes one JSON doc and returns
|
||||
//! {"ok": true, "value": ...} | {"ok": false, "err": "..."}:
|
||||
//! * smod_propose_change({repo, branch, base, now}) → proposal dict
|
||||
//! * smod_run_gates({worktree, test_cmd|null}) → {passed, output}
|
||||
//! * smod_compare_baseline({candidate_result, baseline_result}) → verdict
|
||||
//! * smod_promote_or_discard({repo, proposal, verdict}) → result dict
|
||||
//! * smod_rollback({repo, rollback_ref}) → result dict
|
||||
//! * smod_monitor({proposal}) → watch record
|
||||
//!
|
||||
//! Raise parity: the oracle RAISES where the kernel cannot return a dict
|
||||
//! (missing cwd / binary → FileNotFoundError, gate timeout →
|
||||
//! TimeoutExpired, missing proposal keys → KeyError, non-str argv →
|
||||
//! TypeError). The kernel answers with the envelope's err half, encoded
|
||||
//! so smod_loader re-raises the ORACLE'S exception TYPE — raise parity
|
||||
//! holds at the type level, not just raise-vs-return.
|
||||
//!
|
||||
//! No smod_free_string export — sibling crates sharing the Python process
|
||||
//! already export identical free symbols; callers free through
|
||||
//! capability_scoring's cs_free_string.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use std::fmt;
|
||||
use std::io::Read;
|
||||
use std::os::raw::c_char;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub const RUN_TIMEOUT_SECS: u64 = 300;
|
||||
pub const GATES_TIMEOUT_SECS: u64 = 600;
|
||||
const OUTPUT_TAIL_CHARS: usize = 2000;
|
||||
const ERROR_HEAD_CHARS: usize = 200;
|
||||
const DEFAULT_TEST_CMD: [&str; 6] = ["python3", "-m", "pytest", "tests/", "-q", "-x"];
|
||||
const MKDTEMP_PREFIX: &str = "selfmod-";
|
||||
const MKDTEMP_CHARS: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789_";
|
||||
const WATCH: [&str; 2] = [
|
||||
"gate suite green on next CI run",
|
||||
"no new failure signatures in the next 24h",
|
||||
];
|
||||
|
||||
// ── error lattice ────────────────────────────────────────────────────────
|
||||
|
||||
pub enum FfiError {
|
||||
/// subprocess spawn failed — the oracle's FileNotFoundError
|
||||
/// (missing cwd, missing binary) propagates the same way.
|
||||
Spawn(std::io::Error),
|
||||
/// pipe read / wait failed (the oracle's decode errors propagate too).
|
||||
Io(std::io::Error),
|
||||
/// subprocess.run(timeout=…) — TimeoutExpired propagates in the oracle.
|
||||
Timeout { cmd: String, secs: u64 },
|
||||
/// dict[missing-key] — the oracle's KeyError.
|
||||
KeyError(String),
|
||||
/// a non-string reached the argv seam — the oracle's TypeError.
|
||||
TypeError(&'static str),
|
||||
/// tempfile.mkdtemp could not find a free name (OSError).
|
||||
NoTempName,
|
||||
NullPtr,
|
||||
Utf8(std::str::Utf8Error),
|
||||
Json(serde_json::Error),
|
||||
}
|
||||
|
||||
impl fmt::Display for FfiError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
FfiError::Spawn(e) => write!(f, "[Errno {errno}] {e}", errno = e.raw_os_error().unwrap_or(0)),
|
||||
FfiError::Io(e) => write!(f, "{e}"),
|
||||
FfiError::Timeout { cmd, secs } => {
|
||||
write!(f, "Command '{cmd}' timed out after {secs} seconds")
|
||||
}
|
||||
FfiError::KeyError(k) => write!(f, "KeyError: '{k}'"),
|
||||
FfiError::TypeError(m) => write!(f, "TypeError: {m}"),
|
||||
FfiError::NoTempName => write!(f, "OSError: no free temp name"),
|
||||
FfiError::NullPtr => write!(f, "null argument pointer"),
|
||||
FfiError::Utf8(e) => write!(f, "{e}"),
|
||||
FfiError::Json(e) => write!(f, "{e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── the subprocess seam (`_run`) ─────────────────────────────────────────
|
||||
|
||||
/// `subprocess.run(cmd, cwd=cwd, capture_output=True, text=True,
|
||||
/// timeout=timeout)` → (returncode, stdout, stderr). text=True means
|
||||
/// strict UTF-8 (a decode failure raises) and universal newlines.
|
||||
fn _run(cmd: &[String], cwd: &str, timeout_secs: u64) -> Result<(i32, String, String), FfiError> {
|
||||
let mut command = Command::new(&cmd[0]);
|
||||
command
|
||||
.args(&cmd[1..])
|
||||
.current_dir(cwd)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
let mut child = command.spawn().map_err(FfiError::Spawn)?;
|
||||
let mut stdout_pipe = child.stdout.take().expect("piped stdout");
|
||||
let mut stderr_pipe = child.stderr.take().expect("piped stderr");
|
||||
let out_handle = thread::spawn(move || {
|
||||
let mut buf = String::new();
|
||||
stdout_pipe
|
||||
.read_to_string(&mut buf)
|
||||
.map(|_| buf)
|
||||
.map_err(FfiError::Io)
|
||||
});
|
||||
let err_handle = thread::spawn(move || {
|
||||
let mut buf = String::new();
|
||||
stderr_pipe
|
||||
.read_to_string(&mut buf)
|
||||
.map(|_| buf)
|
||||
.map_err(FfiError::Io)
|
||||
});
|
||||
|
||||
let start = Instant::now();
|
||||
let status = loop {
|
||||
match child.try_wait().map_err(FfiError::Io)? {
|
||||
Some(status) => break status,
|
||||
None => {
|
||||
if start.elapsed() >= Duration::from_secs(timeout_secs) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(FfiError::Timeout {
|
||||
cmd: cmd.join(" "),
|
||||
secs: timeout_secs,
|
||||
});
|
||||
}
|
||||
thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let out = join_reader(out_handle)?;
|
||||
let err = join_reader(err_handle)?;
|
||||
// text=True universal newlines: \r\n and lone \r both become \n.
|
||||
let uni = |s: String| s.replace("\r\n", "\n").replace('\r', "\n");
|
||||
Ok((status.code().unwrap_or(-1), uni(out), uni(err)))
|
||||
}
|
||||
|
||||
fn join_reader(
|
||||
handle: thread::JoinHandle<Result<String, FfiError>>,
|
||||
) -> Result<String, FfiError> {
|
||||
match handle.join() {
|
||||
Ok(r) => r,
|
||||
Err(_) => Err(FfiError::Io(std::io::Error::other(
|
||||
"output reader thread panicked",
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
// ── character-parity helpers (Python slices by characters, not bytes) ────
|
||||
|
||||
fn chars_head(s: &str, n: usize) -> String {
|
||||
s.chars().take(n).collect()
|
||||
}
|
||||
|
||||
fn chars_tail(s: &str, n: usize) -> String {
|
||||
let count = s.chars().count();
|
||||
if count <= n {
|
||||
s.to_string()
|
||||
} else {
|
||||
s.chars().skip(count - n).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Python truthiness over a JSON value (`if not x.get("passed")`).
|
||||
fn truthy(v: Option<&Value>) -> bool {
|
||||
match v {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(Value::Bool(b)) => *b,
|
||||
Some(Value::Number(n)) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
|
||||
Some(Value::String(s)) => !s.is_empty(),
|
||||
Some(Value::Array(a)) => !a.is_empty(),
|
||||
Some(Value::Object(o)) => !o.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
/// tempfile.mkdtemp(prefix="selfmod-"): a fresh 0700 dir under the
|
||||
/// platform temp root with `prefix` + 8 random name characters
|
||||
/// (Python's tempfile draws from lowercase letters, digits and `_`).
|
||||
fn mkdtemp_selfmod() -> Result<String, FfiError> {
|
||||
for _ in 0..100 {
|
||||
let name = random_name();
|
||||
let path = std::env::temp_dir().join(format!("{MKDTEMP_PREFIX}{name}"));
|
||||
match std::fs::create_dir(&path) {
|
||||
Ok(()) => {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(
|
||||
&path,
|
||||
std::fs::Permissions::from_mode(0o700),
|
||||
);
|
||||
}
|
||||
return Ok(path.to_string_lossy().into_owned());
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(FfiError::Io(e)),
|
||||
}
|
||||
}
|
||||
Err(FfiError::NoTempName)
|
||||
}
|
||||
|
||||
fn random_name() -> String {
|
||||
(0..8)
|
||||
.map(|_| {
|
||||
let byte = random_byte();
|
||||
MKDTEMP_CHARS[byte as usize % MKDTEMP_CHARS.len()] as char
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn random_byte() -> u8 {
|
||||
use std::fs::File;
|
||||
if let Ok(mut f) = File::open("/dev/urandom") {
|
||||
let mut b = [0u8; 1];
|
||||
if f.read_exact(&mut b).is_ok() {
|
||||
return b[0];
|
||||
}
|
||||
}
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.subsec_nanos())
|
||||
.unwrap_or(0);
|
||||
(nanos % 256) as u8
|
||||
}
|
||||
|
||||
// ── kernel functions (name-preserving) ───────────────────────────────────
|
||||
|
||||
/// `propose_change` — create the isolated worktree + branch. Returns the
|
||||
/// proposal record (with rollback_ref = the base HEAD — the recovery path).
|
||||
pub fn propose_change(
|
||||
repo: &str,
|
||||
branch: &str,
|
||||
base: &str,
|
||||
now: &str,
|
||||
) -> Result<Value, FfiError> {
|
||||
let rev_parse = vec![
|
||||
"git".to_string(),
|
||||
"rev-parse".to_string(),
|
||||
base.to_string(),
|
||||
];
|
||||
let (rc, out, _err) = _run(&rev_parse, repo, RUN_TIMEOUT_SECS)?;
|
||||
if rc != 0 {
|
||||
return Ok(json!({"proposed": false, "error": format!("unknown base: {base}")}));
|
||||
}
|
||||
let base_sha = out.trim().to_string();
|
||||
let wt = mkdtemp_selfmod()?;
|
||||
let worktree_add = vec![
|
||||
"git".to_string(),
|
||||
"worktree".to_string(),
|
||||
"add".to_string(),
|
||||
wt.clone(),
|
||||
"-b".to_string(),
|
||||
branch.to_string(),
|
||||
base.to_string(),
|
||||
];
|
||||
let (add_rc, _out, add_err) = _run(&worktree_add, repo, RUN_TIMEOUT_SECS)?;
|
||||
if add_rc != 0 {
|
||||
return Ok(json!({
|
||||
"proposed": false,
|
||||
"error": format!("worktree failed: {}", chars_head(add_err.trim(), 200)),
|
||||
}));
|
||||
}
|
||||
Ok(json!({
|
||||
"proposed": true,
|
||||
"branch": branch,
|
||||
"worktree": wt,
|
||||
"base": base,
|
||||
"rollback_ref": base_sha,
|
||||
"at": now,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `run_gates` — run the gate suite inside the worktree.
|
||||
/// Returns {passed, output} where output is (stdout+stderr)[-2000:].
|
||||
pub fn run_gates(worktree: &str, test_cmd: Option<&Vec<Value>>) -> Result<Value, FfiError> {
|
||||
let cmd: Vec<String> = match test_cmd {
|
||||
Some(list) if !list.is_empty() => list
|
||||
.iter()
|
||||
.map(|v| {
|
||||
v.as_str()
|
||||
.map(str::to_string)
|
||||
.ok_or(FfiError::TypeError("test_cmd elements must be strings"))
|
||||
})
|
||||
.collect::<Result<Vec<String>, FfiError>>()?,
|
||||
_ => DEFAULT_TEST_CMD.iter().map(|s| s.to_string()).collect(),
|
||||
};
|
||||
let (rc, out, err) = _run(&cmd, worktree, GATES_TIMEOUT_SECS)?;
|
||||
let mut combined = out;
|
||||
combined.push_str(&err);
|
||||
Ok(json!({
|
||||
"passed": rc == 0,
|
||||
"output": chars_tail(&combined, OUTPUT_TAIL_CHARS),
|
||||
}))
|
||||
}
|
||||
|
||||
/// `compare_baseline` — candidate vs baseline gate runs. Regression blocks.
|
||||
pub fn compare_baseline(candidate_result: &Value, baseline_result: &Value) -> Value {
|
||||
if !truthy(candidate_result.get("passed")) {
|
||||
return json!({
|
||||
"promote": false,
|
||||
"reason": "candidate gates red — regression blocks promotion",
|
||||
});
|
||||
}
|
||||
if !truthy(baseline_result.get("passed")) {
|
||||
return json!({
|
||||
"promote": true,
|
||||
"reason": "candidate green, baseline was already red — no regression introduced",
|
||||
});
|
||||
}
|
||||
json!({
|
||||
"promote": true,
|
||||
"reason": "candidate green, baseline green — no regression",
|
||||
})
|
||||
}
|
||||
|
||||
/// dict[key] for keys that reach the argv seam: missing → KeyError
|
||||
/// raise-parity; non-string → TypeError raise-parity (subprocess would
|
||||
/// reject the non-str argv element).
|
||||
fn argv_str<'a>(doc: &'a Value, key: &str) -> Result<&'a str, FfiError> {
|
||||
match doc.get(key) {
|
||||
None => Err(FfiError::KeyError(key.to_string())),
|
||||
Some(Value::String(s)) => Ok(s.as_str()),
|
||||
Some(_) => Err(FfiError::TypeError(
|
||||
"expected str, bytes or os.PathLike object",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// dict[key] passed through verbatim (present None stays None); only a
|
||||
/// MISSING key raises KeyError.
|
||||
fn passthrough_field(doc: &Value, key: &str) -> Result<Value, FfiError> {
|
||||
doc.get(key)
|
||||
.cloned()
|
||||
.ok_or_else(|| FfiError::KeyError(key.to_string()))
|
||||
}
|
||||
|
||||
/// `promote_or_discard` — merge (promote) or delete (discard) the
|
||||
/// proposal branch. rollback_ref is read lazily: in the oracle the
|
||||
/// KeyError for a missing key fires only after the merge/remove side
|
||||
/// effects have run.
|
||||
pub fn promote_or_discard(repo: &str, proposal: &Value, verdict: &Value) -> Result<Value, FfiError> {
|
||||
let branch = argv_str(proposal, "branch")?.to_string();
|
||||
let wt = argv_str(proposal, "worktree")?.to_string();
|
||||
let rollback_ref = || passthrough_field(proposal, "rollback_ref");
|
||||
|
||||
let remove_wt = vec![
|
||||
"git".to_string(),
|
||||
"worktree".to_string(),
|
||||
"remove".to_string(),
|
||||
wt,
|
||||
"--force".to_string(),
|
||||
];
|
||||
if truthy(verdict.get("promote")) {
|
||||
let msg = format!("self-modify: promote {branch}");
|
||||
let merge = vec![
|
||||
"git".to_string(),
|
||||
"merge".to_string(),
|
||||
branch.clone(),
|
||||
"--no-ff".to_string(),
|
||||
"-m".to_string(),
|
||||
msg,
|
||||
];
|
||||
let (rc, _out, err) = _run(&merge, repo, RUN_TIMEOUT_SECS)?;
|
||||
let _ = _run(&remove_wt, repo, RUN_TIMEOUT_SECS);
|
||||
let rollback_ref = rollback_ref()?;
|
||||
if rc != 0 {
|
||||
return Ok(json!({
|
||||
"promoted": false,
|
||||
"error": format!("merge failed: {}", chars_head(&err, ERROR_HEAD_CHARS)),
|
||||
"rollback_ref": rollback_ref,
|
||||
}));
|
||||
}
|
||||
return Ok(json!({
|
||||
"promoted": true,
|
||||
"branch": branch,
|
||||
"rollback_ref": rollback_ref,
|
||||
}));
|
||||
}
|
||||
let _ = _run(&remove_wt, repo, RUN_TIMEOUT_SECS);
|
||||
let drop_branch = vec![
|
||||
"git".to_string(),
|
||||
"branch".to_string(),
|
||||
"-D".to_string(),
|
||||
branch.clone(),
|
||||
];
|
||||
let _ = _run(&drop_branch, repo, RUN_TIMEOUT_SECS);
|
||||
let rollback_ref = rollback_ref()?;
|
||||
Ok(json!({
|
||||
"promoted": false,
|
||||
"discarded": true,
|
||||
"branch": branch,
|
||||
"rollback_ref": rollback_ref,
|
||||
}))
|
||||
}
|
||||
|
||||
/// `rollback` — restore the pre-change ref. The recovery path every
|
||||
/// proposal carries.
|
||||
pub fn rollback(repo: &str, rollback_ref: &str) -> Result<Value, FfiError> {
|
||||
let reset = vec![
|
||||
"git".to_string(),
|
||||
"reset".to_string(),
|
||||
"--hard".to_string(),
|
||||
rollback_ref.to_string(),
|
||||
];
|
||||
let (rc, _out, err) = _run(&reset, repo, RUN_TIMEOUT_SECS)?;
|
||||
if rc != 0 {
|
||||
return Ok(json!({
|
||||
"rolled_back": false,
|
||||
"error": chars_head(&err, ERROR_HEAD_CHARS),
|
||||
}));
|
||||
}
|
||||
Ok(json!({"rolled_back": true, "ref": rollback_ref}))
|
||||
}
|
||||
|
||||
/// `monitor` — the post-promotion watch record. proposal.get(key, "")
|
||||
/// passes ANY present value through verbatim (even a number or null);
|
||||
/// only a MISSING key defaults to "".
|
||||
pub fn monitor(proposal: &Value) -> Value {
|
||||
let get_or_empty = |key: &str| {
|
||||
proposal
|
||||
.get(key)
|
||||
.cloned()
|
||||
.unwrap_or(Value::String(String::new()))
|
||||
};
|
||||
json!({
|
||||
"monitoring": true,
|
||||
"branch": get_or_empty("branch"),
|
||||
"rollback_ref": get_or_empty("rollback_ref"),
|
||||
"watch": WATCH,
|
||||
})
|
||||
}
|
||||
|
||||
// ── FFI (doc-envelope law) ───────────────────────────────────────────────
|
||||
|
||||
fn read_arg(ptr: *const c_char) -> Result<Value, FfiError> {
|
||||
if ptr.is_null() {
|
||||
return Err(FfiError::NullPtr);
|
||||
}
|
||||
let s = unsafe { std::ffi::CStr::from_ptr(ptr) }
|
||||
.to_str()
|
||||
.map_err(FfiError::Utf8)?;
|
||||
serde_json::from_str(s).map_err(FfiError::Json)
|
||||
}
|
||||
|
||||
fn envelope(r: Result<Value, FfiError>) -> *mut c_char {
|
||||
let v = match r {
|
||||
Ok(x) => json!({"ok": true, "value": x}),
|
||||
Err(e) => json!({"ok": false, "err": e.to_string()}),
|
||||
};
|
||||
std::ffi::CString::new(serde_json::to_string(&v).unwrap_or_default())
|
||||
.map(|c| c.into_raw())
|
||||
.unwrap_or(std::ptr::null_mut())
|
||||
}
|
||||
|
||||
fn required_str<'a>(a: &'a Value, key: &str) -> Result<&'a str, FfiError> {
|
||||
a.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| FfiError::KeyError(key.to_string()))
|
||||
}
|
||||
|
||||
/// smod_propose_change({repo, branch, base, now}) → proposal dict
|
||||
#[no_mangle]
|
||||
pub extern "C" fn smod_propose_change(arg: *const c_char) -> *mut c_char {
|
||||
envelope(read_arg(arg).and_then(|a| {
|
||||
let base = a.get("base").and_then(Value::as_str).unwrap_or("master");
|
||||
let now = required_str(&a, "now")?;
|
||||
propose_change(
|
||||
required_str(&a, "repo")?,
|
||||
required_str(&a, "branch")?,
|
||||
base,
|
||||
now,
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
/// smod_run_gates({worktree, test_cmd|null}) → {passed, output}
|
||||
#[no_mangle]
|
||||
pub extern "C" fn smod_run_gates(arg: *const c_char) -> *mut c_char {
|
||||
envelope(read_arg(arg).and_then(|a| {
|
||||
let worktree = required_str(&a, "worktree")?;
|
||||
let test_cmd = a.get("test_cmd").and_then(Value::as_array);
|
||||
run_gates(worktree, test_cmd)
|
||||
}))
|
||||
}
|
||||
|
||||
/// smod_compare_baseline({candidate_result, baseline_result}) → verdict
|
||||
#[no_mangle]
|
||||
pub extern "C" fn smod_compare_baseline(arg: *const c_char) -> *mut c_char {
|
||||
envelope(read_arg(arg).map(|a| {
|
||||
let empty = Value::Object(serde_json::Map::new());
|
||||
compare_baseline(
|
||||
a.get("candidate_result").unwrap_or(&empty),
|
||||
a.get("baseline_result").unwrap_or(&empty),
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
/// smod_promote_or_discard({repo, proposal, verdict}) → result dict
|
||||
#[no_mangle]
|
||||
pub extern "C" fn smod_promote_or_discard(arg: *const c_char) -> *mut c_char {
|
||||
envelope(read_arg(arg).and_then(|a| {
|
||||
let repo = required_str(&a, "repo")?;
|
||||
let proposal = a.get("proposal").ok_or_else(|| FfiError::KeyError("proposal".to_string()))?;
|
||||
let verdict = a
|
||||
.get("verdict")
|
||||
.ok_or_else(|| FfiError::KeyError("verdict".to_string()))?;
|
||||
promote_or_discard(repo, proposal, verdict)
|
||||
}))
|
||||
}
|
||||
|
||||
/// smod_rollback({repo, rollback_ref}) → result dict
|
||||
#[no_mangle]
|
||||
pub extern "C" fn smod_rollback(arg: *const c_char) -> *mut c_char {
|
||||
envelope(read_arg(arg).and_then(|a| {
|
||||
rollback(
|
||||
required_str(&a, "repo")?,
|
||||
required_str(&a, "rollback_ref")?,
|
||||
)
|
||||
}))
|
||||
}
|
||||
|
||||
/// smod_monitor({proposal}) → watch record
|
||||
#[no_mangle]
|
||||
pub extern "C" fn smod_monitor(arg: *const c_char) -> *mut c_char {
|
||||
envelope(read_arg(arg).map(|a| {
|
||||
let empty = Value::Object(serde_json::Map::new());
|
||||
monitor(a.get("proposal").unwrap_or(&empty))
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod branch_battery;
|
||||
Reference in new issue
Block a user