feat(project_registry): port to FULL Rust — dichotomy wave

This commit is contained in:
Nick committed 2026-10-13 03:22:54 +01:00
1 parent b1cc915dae
commit e978510b0b
8 files changed
+3126 -61

No files matched your search

+585
View File
@@ -0,0 +1,585 @@
#!/usr/bin/env python3
"""gen_project_registry_truth.py — freeze/replay parity truth for
project_registry (dichotomy wave, #2479 tranche).
Freeze mode (default): exercises EVERY public function of the LIVE Python
oracle (src/rune/project_registry.py) across every branch and records
(kernel_input, expected_kernel_output) pairs into
src/rust/project_registry/src/frozen_pr_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 methods delegate) parity against the frozen file.
Poisoned-truth law: freeze pins RUNE_PR_CORE=python BEFORE any import (a
no-op pre-port, load-bearing post-port — the facade must never delegate
into the kernel while truth is being frozen).
No-clock law: the wall clock is frozen by patching the `time` symbol in
project_registry, so every kernel payload carries its own `now` and
replay is deterministic regardless of run date.
Boundary law: the registry FILE IO (_load hit branch, save) stays in the
Python facade — the kernel is a pure function of the loaded doc. The
`load` and `save` truth groups are facade-only (no kernel symbol) and
are skipped by the kernel-level replay pass.
"""
import copy
import json
import os
import sys
import tempfile
from pathlib import Path
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TRUTH_PATH = os.path.join(REPO, "src", "rust", "project_registry", "src",
"frozen_pr_truth.json")
REPLAY = "--replay" in sys.argv
os.environ["RUNE_PR_CORE"] = "python" # poisoned-truth law
sys.path.insert(0, os.path.join(REPO, "src"))
sys.path.insert(0, os.path.join(REPO, "src", "rune"))
import project_registry as oracle_mod # noqa: E402
if REPLAY:
from rune import pr_loader # noqa: E402 (kernel-level replay side)
# replay law: the facade pass must exercise the LIVE Rust delegation
# path, so the pin is lifted AFTER import (the facade reads the env
# at call time). The kernel pass calls the loader directly.
del os.environ["RUNE_PR_CORE"]
FROZEN_TS = 1791806400 # 2026-10-12T12:00:00Z
FROZEN_NOW = "2026-10-12T12:00:00Z"
class FakeTime:
@staticmethod
def strftime(fmt, t):
import time as _t
return _t.strftime(fmt, t)
@staticmethod
def gmtime():
import time as _t
return _t.gmtime(FROZEN_TS)
oracle_mod.time = FakeTime
def fresh_dir():
return tempfile.mkdtemp(prefix="pr_truth_")
def fresh_registry(**kw):
return oracle_mod.ProjectRegistry(fresh_dir(), **kw)
def rec_kernel(rec, name, inp, expected, oracle_ret=None):
"""Record a kernel case; inputs deep-copied BEFORE oracle mutation."""
rec(name, copy.deepcopy(inp), oracle_ret, expected)
def rec_facade(rec, name, inp, expected, oracle_ret=None):
rec(name, inp, oracle_ret, expected)
def err_or(fn):
"""Run a facade call; freeze the kernel error envelope on exception."""
try:
return fn(), None
except ValueError as e:
return None, f"ValueError: {e}"
except KeyError as e:
return None, f"KeyError: {e.args[0]}"
# ── default_doc (kernel) ─────────────────────────────────────────────────────
def c_default_doc(rec):
r = fresh_registry()
rec_kernel(rec, "default-doc-fresh", {"now": FROZEN_NOW}, {"doc": r.doc})
# ── register (kernel) ────────────────────────────────────────────────────────
def _register_case(rec, name, doc_before, **kw):
r = doc_before if doc_before is not None else fresh_registry()
before = copy.deepcopy(r.doc)
entry = r.register(**kw)
inp = {"doc": before, "project_id": kw["project_id"],
"scaffold_location": kw.get("scaffold_location", ""),
"repo_identity": kw.get("repo_identity", ""),
"forge_repo": kw.get("forge_repo", ""),
"default_branch": kw.get("default_branch", "master"),
"structural_schema_version": kw.get("structural_schema_version", 1),
"runtime_generation": kw.get("runtime_generation", ""),
"last_known_good": kw.get("last_known_good", ""),
"now": FROZEN_NOW}
rec_kernel(rec, name, inp, {"doc": r.doc, "entry": entry})
def c_register_defaults(rec):
_register_case(rec, "register-defaults", None,
project_id="alpha", scaffold_location="/tmp/alpha/scaffold")
def c_register_full(rec):
_register_case(rec, "register-full-kwargs", None,
project_id="beta", scaffold_location="/tmp/beta/scaffold",
repo_identity="git:/repos/beta", forge_repo="conductor/beta",
default_branch="main", structural_schema_version=3,
runtime_generation="gen-7", last_known_good="gen-6")
def c_register_overwrites(rec):
r = fresh_registry()
r.register(project_id="alpha", scaffold_location="/old")
_register_case(rec, "register-overwrites-existing", r,
project_id="alpha", scaffold_location="/new",
repo_identity="git:/repos/alpha")
def c_register_second_project(rec):
r = fresh_registry()
r.register(project_id="alpha", scaffold_location="/a")
_register_case(rec, "register-second-preserves-first", r,
project_id="beta", scaffold_location="/b")
# ── get (kernel) ─────────────────────────────────────────────────────────────
def _get_case(rec, name, register_kw, probe):
r = fresh_registry()
if register_kw:
r.register(**register_kw)
entry = r.get(probe)
inp = {"doc": copy.deepcopy(r.doc), "project_id": probe}
rec_kernel(rec, name, inp, {"entry": entry})
def c_get_present(rec):
_get_case(rec, "get-present", {"project_id": "alpha",
"scaffold_location": "/a"}, "alpha")
def c_get_missing(rec):
_get_case(rec, "get-missing-null", {"project_id": "alpha",
"scaffold_location": "/a"}, "ghost")
def c_get_empty_registry(rec):
_get_case(rec, "get-empty-registry", None, "alpha")
# ── all_projects (kernel) ────────────────────────────────────────────────────
def _all_case(rec, name, pids):
r = fresh_registry()
for pid in pids:
r.register(project_id=pid, scaffold_location=f"/{pid}")
projs = r.all_projects()
inp = {"doc": copy.deepcopy(r.doc)}
rec_kernel(rec, name, inp, {"projects": projs})
def c_all_empty(rec):
_all_case(rec, "all-empty-registry", [])
def c_all_single(rec):
_all_case(rec, "all-single", ["alpha"])
def c_all_sorted(rec):
_all_case(rec, "all-sorted-by-project-id",
["zeta", "alpha", "mu", "beta"])
# ── set_health (kernel) ──────────────────────────────────────────────────────
def _health_case(rec, name, health, note="", pid="alpha", setup=None):
r = fresh_registry()
r.register(project_id="alpha", scaffold_location="/a",
repo_identity="git:/repos/alpha")
if setup:
setup(r)
before = copy.deepcopy(r.doc)
ret, err = err_or(lambda: r.set_health(pid, health, note=note))
inp = {"doc": before, "project_id": pid, "health": health, "note": note,
"now": FROZEN_NOW}
rec_kernel(rec, name, inp,
{"doc": r.doc} if err is None else {"error": err})
def c_health_valid_no_note(rec):
_health_case(rec, "health-valid-no-note", "HEALTHY")
def c_health_with_note(rec):
_health_case(rec, "health-with-note", "DRIFTED", note="diverged upstream")
def c_health_note_truncated(rec):
_health_case(rec, "health-note-truncated-160", "MIGRATION_REQUIRED",
note="n" * 200)
def c_health_appends_notes(rec):
def setup(r):
r.set_health("alpha", "DRIFTED", note="first")
_health_case(rec, "health-second-note-appends", "HEALTHY", note="second",
setup=setup)
def c_health_invalid(rec):
_health_case(rec, "health-invalid-valueerror", "BROKEN")
def c_health_unknown_project(rec):
_health_case(rec, "health-unknown-project-keyerror", "HEALTHY",
pid="ghost")
# ── set_outstanding_validations (kernel) ─────────────────────────────────────
def _outstanding_case(rec, name, pid, count, setup=None):
r = fresh_registry()
r.register(project_id="alpha", scaffold_location="/a")
if setup:
setup(r)
before = copy.deepcopy(r.doc)
ret, err = err_or(lambda: r.set_outstanding_validations(pid, count))
inp = {"doc": before, "project_id": pid, "count": count}
rec_kernel(rec, name, inp,
{"doc": r.doc} if err is None else {"error": err})
def c_outstanding_set(rec):
_outstanding_case(rec, "outstanding-set-count", "alpha", 4)
def c_outstanding_overwrite(rec):
def setup(r):
r.set_outstanding_validations("alpha", 9)
_outstanding_case(rec, "outstanding-overwrite", "alpha", 2, setup=setup)
def c_outstanding_unknown(rec):
_outstanding_case(rec, "outstanding-unknown-keyerror", "ghost", 1)
# ── mark_generation (kernel) ─────────────────────────────────────────────────
def _mark_case(rec, name, current, last_known_good="", setup=None):
r = fresh_registry()
r.register(project_id="alpha", scaffold_location="/a",
runtime_generation="gen-1")
if setup:
setup(r)
before = copy.deepcopy(r.doc)
ret, err = err_or(lambda: r.mark_generation(
"alpha", current=current, last_known_good=last_known_good))
inp = {"doc": before, "project_id": "alpha", "current": current,
"last_known_good": last_known_good}
rec_kernel(rec, name, inp,
{"doc": r.doc} if err is None else {"error": err})
def c_mark_with_lkg(rec):
_mark_case(rec, "mark-generation-with-lkg", "gen-2", "gen-1")
def c_mark_no_lkg(rec):
_mark_case(rec, "mark-generation-no-lkg-untouched", "gen-3")
def c_mark_unknown(rec):
r = fresh_registry()
r.register(project_id="alpha", scaffold_location="/a")
before = copy.deepcopy(r.doc)
ret, err = err_or(lambda: r.mark_generation(
"ghost", current="gen-9", last_known_good="gen-8"))
rec_kernel(rec, "mark-generation-unknown-keyerror",
{"doc": before, "project_id": "ghost", "current": "gen-9",
"last_known_good": "gen-8"}, {"error": err})
# ── resolve_cwd (kernel) ─────────────────────────────────────────────────────
def _cwd_case(rec, name, register_kw, probe, mutate=None):
r = fresh_registry()
r.register(**register_kw)
if mutate:
mutate(r)
before = copy.deepcopy(r.doc)
ret, err = err_or(lambda: r.resolve_cwd(probe))
inp = {"doc": before, "project_id": probe}
rec_kernel(rec, name, inp,
{"cwd": ret} if err is None else {"error": err})
def c_cwd_git_prefix(rec):
_cwd_case(rec, "cwd-git-prefix-stripped",
{"project_id": "rune", "scaffold_location": "/x/scaffold",
"repo_identity": "git:/repos/rune"}, "rune")
def c_cwd_bare_path(rec):
_cwd_case(rec, "cwd-bare-path", {"project_id": "ext",
"scaffold_location": "/x/s",
"repo_identity": "/tmp/ext/repo"}, "ext")
def c_cwd_worktree_overrides(rec):
_cwd_case(rec, "cwd-active-worktree-overrides",
{"project_id": "rune", "scaffold_location": "/x/scaffold",
"repo_identity": "git:/repos/rune"}, "rune",
mutate=lambda r: r.doc["projects"]["rune"].update(
{"active_worktree": "/wt/rune"}))
def c_cwd_whitespace_repo(rec):
_cwd_case(rec, "cwd-whitespace-repo-refused",
{"project_id": "ws", "scaffold_location": "/x/s",
"repo_identity": " "}, "ws")
def c_cwd_unknown(rec):
_cwd_case(rec, "cwd-unknown-project-refused",
{"project_id": "rune", "scaffold_location": "/x/s",
"repo_identity": "git:/repos/rune"}, "ghost")
def c_cwd_no_repo_identity(rec):
_cwd_case(rec, "cwd-no-repo-identity-refused",
{"project_id": "remote-only", "scaffold_location": "/x/s"}, "remote-only")
# ── needs_migration (kernel) ─────────────────────────────────────────────────
def _migration_case(rec, name, versions, proposed):
r = fresh_registry()
for pid, ver in versions.items():
r.register(project_id=pid, scaffold_location=f"/{pid}",
structural_schema_version=ver)
out = r.needs_migration(proposed_schema_version=proposed)
inp = {"doc": copy.deepcopy(r.doc), "proposed": proposed}
rec_kernel(rec, name, inp, {"ids": out})
def c_migration_mixed(rec):
_migration_case(rec, "migration-mixed-sorted",
{"new": 3, "old": 1, "mid": 2}, 2)
def c_migration_none(rec):
_migration_case(rec, "migration-none-older", {"a": 2, "b": 3}, 2)
def c_migration_default_version(rec):
_migration_case(rec, "migration-default-version-1", {"a": 1, "b": 1}, 2)
def c_migration_empty(rec):
_migration_case(rec, "migration-empty-registry", {}, 5)
# ── load / save (facade-only: FILE IO stays in Python) ──────────────────────
def c_load_missing(rec):
r = fresh_registry()
rec_facade(rec, "load-missing-file-default-doc", {"path": "missing"},
{"doc": r.doc})
def c_load_valid(rec):
d = fresh_dir()
pre = {"schema_version": 1, "projects": {"alpha": {"project_id": "alpha"}},
"updated_at": FROZEN_NOW}
Path(d, "project_registry.json").write_text(
json.dumps(pre, indent=1, sort_keys=True))
r = oracle_mod.ProjectRegistry(d)
rec_facade(rec, "load-valid-file-roundtrip", {"path": "valid"},
{"doc": r.doc})
def c_save(rec):
r = fresh_registry()
r.register(project_id="alpha", scaffold_location="/a",
repo_identity="git:/repos/alpha")
r.set_health("alpha", "DRIFTED", note="sweep")
r.save()
text = Path(r.path).read_text()
rec_facade(rec, "save-stamps-updated-at-indent1-sortkeys",
{"doc": copy.deepcopy(r.doc)}, {"file_text": text},
oracle_ret=None)
# ── driver ───────────────────────────────────────────────────────────────────
DEFAULT_DOC = [c_default_doc]
REGISTER = [c_register_defaults, c_register_full, c_register_overwrites,
c_register_second_project]
GET = [c_get_present, c_get_missing, c_get_empty_registry]
ALL = [c_all_empty, c_all_single, c_all_sorted]
HEALTH = [c_health_valid_no_note, c_health_with_note, c_health_note_truncated,
c_health_appends_notes, c_health_invalid, c_health_unknown_project]
OUTSTANDING = [c_outstanding_set, c_outstanding_overwrite, c_outstanding_unknown]
MARK = [c_mark_with_lkg, c_mark_no_lkg, c_mark_unknown]
CWD = [c_cwd_git_prefix, c_cwd_bare_path, c_cwd_worktree_overrides,
c_cwd_whitespace_repo, c_cwd_unknown, c_cwd_no_repo_identity]
MIGRATION = [c_migration_mixed, c_migration_none, c_migration_default_version,
c_migration_empty]
LOAD = [c_load_missing, c_load_valid]
SAVE = [c_save]
GROUPS = [("default_doc", DEFAULT_DOC), ("register", REGISTER), ("get", GET),
("all_projects", ALL), ("set_health", HEALTH),
("set_outstanding", OUTSTANDING), ("mark_generation", MARK),
("resolve_cwd", CWD), ("needs_migration", MIGRATION),
("load", LOAD), ("save", SAVE)]
# kernel symbol per group (None → facade-only group, skipped kernel-side)
KERNEL = {
"default_doc": "pr_default_doc",
"register": "pr_register",
"get": "pr_get",
"all_projects": "pr_all_projects",
"set_health": "pr_set_health",
"set_outstanding": "pr_set_outstanding",
"mark_generation": "pr_mark_generation",
"resolve_cwd": "pr_resolve_cwd",
"needs_migration": "pr_needs_migration",
"load": None,
"save": None,
}
# loader wrapper + slice of `expected` the unwrapped FFI value maps to
CALLS = {
"default_doc": (lambda w: w["doc"],
lambda i, ld: ld.default_doc(now=i["now"])),
"register": (lambda w: w,
lambda i, ld: ld.register(i["doc"], project_id=i["project_id"],
scaffold_location=i["scaffold_location"],
repo_identity=i["repo_identity"],
forge_repo=i["forge_repo"],
default_branch=i["default_branch"],
structural_schema_version=i["structural_schema_version"],
runtime_generation=i["runtime_generation"],
last_known_good=i["last_known_good"],
now=i["now"])),
"get": (lambda w: w["entry"],
lambda i, ld: ld.get(i["doc"], i["project_id"])),
"all_projects": (lambda w: w["projects"],
lambda i, ld: ld.all_projects(i["doc"])),
"set_health": (lambda w: w["doc"],
lambda i, ld: ld.set_health(i["doc"], i["project_id"],
i["health"], note=i["note"],
now=i["now"])),
"set_outstanding": (lambda w: w["doc"],
lambda i, ld: ld.set_outstanding(i["doc"],
i["project_id"],
i["count"])),
"mark_generation": (lambda w: w["doc"],
lambda i, ld: ld.mark_generation(i["doc"],
i["project_id"],
current=i["current"],
last_known_good=i["last_known_good"])),
"resolve_cwd": (lambda w: w["cwd"],
lambda i, ld: ld.resolve_cwd(i["doc"], i["project_id"])),
"needs_migration": (lambda w: w["ids"],
lambda i, ld: ld.needs_migration(i["doc"],
i["proposed"])),
}
def canon(x) -> str:
"""Content canonicalization: a sorted dump makes the comparison
key-order independent (int keys never occur here)."""
return json.dumps(json.loads(json.dumps(x)), sort_keys=True)
def main():
frozen = None
if REPLAY:
with open(TRUTH_PATH) as f:
frozen = json.load(f)["cases"]
groups = {}
for group, cases in GROUPS:
recs = []
def rec(name, inp, oracle_ret, expected, _r=recs):
entry = {"name": name, "input": inp, "return": oracle_ret,
"expected": expected}
if REPLAY:
old = next(c for c in frozen[group] if c["name"] == name)
for key in ("input", "return", "expected"):
got, want = entry[key], old[key]
if canon(got) != canon(want):
print(f"FACADE DIVERGE [{group}] {name} {key}")
print(" got: ", json.dumps(got, sort_keys=True, default=str)[:600])
print(" want:", json.dumps(want, sort_keys=True, default=str)[:600])
recs.append({"name": name, "diverged": key})
return
_r.append(entry)
for c in cases:
c(rec)
groups[group] = recs
total = sum(len(v) for v in groups.values())
if REPLAY:
facade_failures = sum(1 for v in groups.values() for c in v if "diverged" in c)
failures = 0
checked = 0
for group, cases in frozen.items():
spec = CALLS.get(group)
if spec is None:
continue
unwrap, call = spec
for case in cases:
checked += 1
try:
got = call(copy.deepcopy(case["input"]), pr_loader)
except (ValueError, KeyError) as e:
got = (f"ValueError: {e}" if isinstance(e, ValueError)
else f"KeyError: {e.args[0]}")
want = (case["expected"]["error"]
if isinstance(case["expected"], dict)
and "error" in case["expected"]
else unwrap(case["expected"]))
if canon(got) != canon(want):
failures += 1
print(f"KERNEL DIVERGE [{group}] {case['name']}")
print(" got: ", json.dumps(got, sort_keys=True, default=str)[:600])
print(" want:", json.dumps(want, sort_keys=True, default=str)[:600])
print(f"replay: facade {total - facade_failures}/{total}, "
f"kernel {checked - failures}/{checked}")
return 1 if (failures or facade_failures) else 0
doc = {
"frozen": FROZEN_NOW,
"oracle": "src/rune/project_registry.py",
"pin": "RUNE_PR_CORE=python",
"note": "Frozen via scripts/gen_project_registry_truth.py against the live "
"Python oracle; wall clock frozen at " + FROZEN_NOW +
" (FakeTime patched into project_registry.time; epoch " +
str(FROZEN_TS) + "). Battery embeds this VERBATIM. Groups `load` "
"and `save` are facade-only (FILE IO stays in Python).",
"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)
f.write("\n")
print(f"frozen {total} cases → {os.path.relpath(TRUTH_PATH, REPO)}")
return 0
if __name__ == "__main__":
sys.exit(main())
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""ctypes loader for the FULL Rust project_registry core (dichotomy wave,
#2479 tranche). Mirrors broker_loader.py's contract:
FFI contract (doc-envelope law — every call returns {"ok", "value"|"err"}):
* pr_default_doc({now}) -> {doc}
* pr_register({doc, project_id, scaffold_location, repo_identity,
forge_repo, default_branch, structural_schema_version,
runtime_generation, last_known_good, now}) -> {doc, entry}
* pr_get({doc, project_id}) -> {entry}
* pr_all_projects({doc}) -> {projects}
* pr_set_health({doc, project_id, health, note, now}) -> {doc}
* pr_set_outstanding({doc, project_id, count}) -> {doc}
* pr_mark_generation({doc, project_id, current, last_known_good})
-> {doc}
* pr_resolve_cwd({doc, project_id}) -> {cwd}
* pr_needs_migration({doc, proposed}) -> {ids}
No-clock law: `now` (UTC ISO-8601) is always an argument to the stamping
calls — the kernel never reads the wall clock.
Boundary: the registry FILE IO (_load hit branch, save — including the
updated_at stamp and the indent=1/sort_keys dump) stays in the Python
facade (project_registry.py); the kernel is a pure function of the
loaded doc.
Exception law: the kernel's `err` string carries the oracle's Python
exception kind — "ValueError: <msg>" / "KeyError: <key>" — and this
loader re-raises the SAME exception type the oracle raised (parity for
pytest.raises callers).
FFI segfault law: every symbol gets explicit argtypes + restype (c_void_p
for returned char* — the default c_int truncates 64-bit pointers).
Strings are freed via capability_scoring's cs_free_string — the kernel
exports NO free symbol of its own (duplicate exports break the cdylib
link when sibling crates share one process).
Override the .so with PR_RUST_LIB (a FILE path).
"""
from __future__ import annotations
import ctypes
import json
import os
from pathlib import Path
_REPO = Path(__file__).resolve().parents[2]
_CRATE_DIR = _REPO / "src" / "rust" / "project_registry"
_LIB_PATH = Path(
os.environ.get(
"PR_RUST_LIB", _CRATE_DIR / "target" / "release" / "libproject_registry.so"
)
)
_SYMBOLS = (
"pr_default_doc",
"pr_register",
"pr_get",
"pr_all_projects",
"pr_set_health",
"pr_set_outstanding",
"pr_mark_generation",
"pr_resolve_cwd",
"pr_needs_migration",
)
_lib = None
class ProjectRegistryCoreUnavailable(FileNotFoundError):
"""The Rust project_registry 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 ProjectRegistryCoreUnavailable(
f"Rust project_registry 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 _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 any sibling free symbol already in-process (capability_scoring)
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"):
err = doc.get("err", f"{sym} failed")
if err.startswith("ValueError: "):
raise ValueError(err[len("ValueError: "):])
if err.startswith("KeyError: "):
raise KeyError(err[len("KeyError: "):])
raise RuntimeError(err)
return doc["value"]
def default_doc(now: str) -> dict:
return _call("pr_default_doc", {"now": now})["doc"]
def register(doc: dict, *, project_id: str, scaffold_location: str,
repo_identity: str = "", forge_repo: str = "",
default_branch: str = "master",
structural_schema_version: int = 1,
runtime_generation: str = "",
last_known_good: str = "", now: str = "") -> dict:
return _call("pr_register", {
"doc": doc, "project_id": project_id,
"scaffold_location": scaffold_location,
"repo_identity": repo_identity, "forge_repo": forge_repo,
"default_branch": default_branch,
"structural_schema_version": structural_schema_version,
"runtime_generation": runtime_generation,
"last_known_good": last_known_good, "now": now})
def get(doc: dict, project_id: str):
return _call("pr_get", {"doc": doc, "project_id": project_id})["entry"]
def all_projects(doc: dict) -> list:
return _call("pr_all_projects", {"doc": doc})["projects"]
def set_health(doc: dict, project_id: str, health: str, *,
note: str = "", now: str = "") -> dict:
return _call("pr_set_health", {"doc": doc, "project_id": project_id,
"health": health, "note": note,
"now": now})["doc"]
def set_outstanding(doc: dict, project_id: str, count: int) -> dict:
return _call("pr_set_outstanding", {"doc": doc, "project_id": project_id,
"count": count})["doc"]
def mark_generation(doc: dict, project_id: str, *, current: str,
last_known_good: str = "") -> dict:
return _call("pr_mark_generation", {"doc": doc, "project_id": project_id,
"current": current,
"last_known_good": last_known_good})["doc"]
def resolve_cwd(doc: dict, project_id: str) -> str:
return _call("pr_resolve_cwd", {"doc": doc,
"project_id": project_id})["cwd"]
def needs_migration(doc: dict, proposed: int) -> list:
return _call("pr_needs_migration", {"doc": doc,
"proposed": proposed})["ids"]
+66
View File
@@ -12,9 +12,17 @@ BOUNDARIES (rule 19C, proven by tests):
Registry state: one JSON document (versioned, provenance-aware per rule
G) at <store_dir>/project_registry.json. Operational uses: /testing
across projects (19L), drift/migration sweeps, re-entry discovery.
Dichotomy wave #2479: the doc transformations (register entry shaping,
health lattice + note log, generation marks, resolve_cwd precedence,
migration sweep) live in the Rust kernel (src/rust/project_registry)
behind doc-envelope FFI; the registry FILE IO (_load hit branch, save)
stays here — the kernel is a pure function of the loaded doc.
RUNE_PR_CORE=python pins the Python fallback (parity harnesses).
"""
from __future__ import annotations
import os
import time
from pathlib import Path
@@ -32,6 +40,22 @@ def _now() -> str:
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
def _pr_core():
"""Dichotomy wave #2479: Rust core first (RUNE_PR_CORE=python pins the
Python fallback — parity harnesses). The kernel owns the doc
transformations; the FILE IO stays in this facade."""
if os.environ.get("RUNE_PR_CORE", "auto") == "python":
return None
try:
from rune import pr_loader
if pr_loader.available():
return pr_loader
except ImportError:
pass
return None
class ProjectRegistry:
def __init__(self, store_dir: Path | str):
self.path = Path(store_dir) / "project_registry.json"
@@ -41,6 +65,9 @@ class ProjectRegistry:
if self.path.is_file():
import json
return json.loads(self.path.read_text())
core = _pr_core()
if core is not None:
return core.default_doc(now=_now())
return {"schema_version": REGISTRY_SCHEMA_VERSION,
"projects": {}, "updated_at": _now()}
@@ -58,6 +85,18 @@ class ProjectRegistry:
last_known_good: str = "") -> dict:
"""Register/index a scaffolded project. References only — the
project's structural truth stays in ITS store (boundary rule)."""
core = _pr_core()
if core is not None:
out = core.register(self.doc, project_id=project_id,
scaffold_location=scaffold_location,
repo_identity=repo_identity,
forge_repo=forge_repo,
default_branch=default_branch,
structural_schema_version=structural_schema_version,
runtime_generation=runtime_generation,
last_known_good=last_known_good, now=_now())
self.doc = out["doc"]
return out["entry"]
entry = {
"project_id": project_id,
"scaffold_location": scaffold_location,
@@ -76,14 +115,25 @@ class ProjectRegistry:
return entry
def get(self, project_id: str) -> dict | None:
core = _pr_core()
if core is not None:
return core.get(self.doc, project_id)
return self.doc["projects"].get(project_id)
def all_projects(self) -> list[dict]:
core = _pr_core()
if core is not None:
return core.all_projects(self.doc)
return sorted(self.doc["projects"].values(),
key=lambda p: p["project_id"])
def set_health(self, project_id: str, health: str, *,
note: str = "") -> None:
core = _pr_core()
if core is not None:
self.doc = core.set_health(self.doc, project_id, health,
note=note, now=_now())
return
if health not in (HEALTHY, DRIFTED, MIGRATION_REQUIRED, UNKNOWN):
raise ValueError(f"unknown health {health!r}")
p = self.doc["projects"][project_id]
@@ -95,10 +145,20 @@ class ProjectRegistry:
def set_outstanding_validations(self, project_id: str,
count: int) -> None:
core = _pr_core()
if core is not None:
self.doc = core.set_outstanding(self.doc, project_id, count)
return
self.doc["projects"][project_id]["outstanding_validations"] = count
def mark_generation(self, project_id: str, *, current: str,
last_known_good: str = "") -> None:
core = _pr_core()
if core is not None:
self.doc = core.mark_generation(self.doc, project_id,
current=current,
last_known_good=last_known_good)
return
p = self.doc["projects"][project_id]
p["runtime_generation"] = current
if last_known_good:
@@ -112,6 +172,9 @@ class ProjectRegistry:
Registry stores repo_identity as "git:<path>" or a bare path.
Scaffold storage is NEVER used as a repo-cwd proxy. Raises for
unknown projects (refuse mutation rather than guessing)."""
core = _pr_core()
if core is not None:
return core.resolve_cwd(self.doc, project_id)
entry = self.get(project_id)
if entry is None:
raise ValueError(
@@ -134,6 +197,9 @@ class ProjectRegistry:
def needs_migration(self, proposed_schema_version: int) -> list[str]:
"""Projects whose structural schema is older than a proposed
version (feeds the 18I consent sweep — never silent upgrades)."""
core = _pr_core()
if core is not None:
return core.needs_migration(self.doc, proposed_schema_version)
return sorted(pid for pid, p in self.doc["projects"].items()
if p.get("structural_schema_version", 1)
< proposed_schema_version)
+23 -2
View File
@@ -2,6 +2,28 @@
# 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 = "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"
@@ -27,7 +49,6 @@ dependencies = [
name = "project_registry"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
@@ -47,7 +68,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
@@ -76,6 +96,7 @@ version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"indexmap",
"itoa",
"memchr",
"serde",
+5 -2
View File
@@ -7,5 +7,8 @@ edition = "2021"
crate-type = ["rlib", "cdylib", "staticlib"]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# preserve_order: Python-dict insertion-order semantics for the registry
# doc and the register() entry literal;
# float_roundtrip: correctly-rounded float parsing (schema versions and
# validation counts ride along verbatim).
serde_json = { version = "1", features = ["preserve_order", "float_roundtrip"] }
@@ -0,0 +1,142 @@
//! project_registry branch battery — parity vs the frozen Python-oracle
//! truth (frozen_pr_truth.json: 36 cases across every public function and
//! every branch — fresh-registry default doc, register (defaults, full
//! kwargs, overwrite, second-project preservation), get (present /
//! missing / empty registry), all_projects sorting (empty / single /
//! out-of-order insertion), set_health (valid with and without notes,
//! 160-CHAR note truncation, note appending, invalid health → ValueError,
//! unknown project → KeyError), set_outstanding_validations (set /
//! overwrite / unknown project → KeyError), mark_generation (with and
//! without last_known_good, unknown project → KeyError), resolve_cwd
//! (git: prefix strip, bare path, active-worktree override, whitespace
//! repo refused, unknown project refused, no repo_identity refused),
//! needs_migration (mixed sorted / none older / default version 1 /
//! empty registry)).
//! Frozen 2026-10-12 via scripts/gen_project_registry_truth.py with
//! RUNE_PR_CORE=python pinned (poisoned-truth law) and the wall clock
//! frozen at 2026-10-12T12:00:00Z (epoch 1791806400). Embedded VERBATIM —
//! never transcribed. The `load` and `save` groups are facade-only (FILE
//! IO stays in Python) and are not replayed here.
use crate::{
all_projects, default_doc, get, mark_generation, needs_migration, register, resolve_cwd,
set_health, set_outstanding,
};
use serde_json::{json, Map, Value};
const TRUTH: &str = include_str!("frozen_pr_truth.json");
fn cases(group: &str) -> Vec<Value> {
let truth: Value = serde_json::from_str(TRUTH).unwrap();
truth["cases"][group].as_array().unwrap().clone()
}
fn doc_of(i: &Value) -> Map<String, Value> {
i["doc"].as_object().cloned().unwrap_or_default()
}
fn s<'a>(i: &'a Value, k: &str) -> &'a str {
i[k].as_str().unwrap()
}
fn replay(group: &str, f: impl Fn(&Value) -> Value) {
for case in cases(group) {
let got = f(&case["input"]);
assert_eq!(
&got,
&case["expected"],
"[{}] case '{}' diverged",
group,
case["name"].as_str().unwrap()
);
}
}
fn replay_result(group: &str, f: impl Fn(&Value) -> Result<Value, crate::RegistryError>) {
replay(group, |i| match f(i) {
Ok(v) => v,
Err(e) => json!({"error": e.to_string()}),
});
}
#[test]
fn default_doc_parity_battery() {
replay("default_doc", |i| {
json!({"doc": default_doc(s(i, "now"))})
});
}
#[test]
fn register_parity_battery() {
replay("register", |i| {
let (doc, entry) = register(
&doc_of(i),
s(i, "project_id"),
s(i, "scaffold_location"),
s(i, "repo_identity"),
s(i, "forge_repo"),
s(i, "default_branch"),
&i["structural_schema_version"],
s(i, "runtime_generation"),
s(i, "last_known_good"),
s(i, "now"),
)
.unwrap();
json!({"doc": doc, "entry": entry})
});
}
#[test]
fn get_parity_battery() {
replay("get", |i| {
json!({"entry": get(&doc_of(i), s(i, "project_id")).unwrap()})
});
}
#[test]
fn all_projects_parity_battery() {
replay("all_projects", |i| {
json!({"projects": all_projects(&doc_of(i)).unwrap()})
});
}
#[test]
fn set_health_parity_battery() {
replay_result("set_health", |i| {
set_health(&doc_of(i), s(i, "project_id"), s(i, "health"), s(i, "note"),
s(i, "now"))
.map(|doc| json!({"doc": doc}))
});
}
#[test]
fn set_outstanding_parity_battery() {
replay_result("set_outstanding", |i| {
set_outstanding(&doc_of(i), s(i, "project_id"), i["count"].as_i64().unwrap())
.map(|doc| json!({"doc": doc}))
});
}
#[test]
fn mark_generation_parity_battery() {
replay_result("mark_generation", |i| {
mark_generation(&doc_of(i), s(i, "project_id"), s(i, "current"),
s(i, "last_known_good"))
.map(|doc| json!({"doc": doc}))
});
}
#[test]
fn resolve_cwd_parity_battery() {
replay_result("resolve_cwd", |i| {
resolve_cwd(&doc_of(i), s(i, "project_id"))
.map(|cwd| json!({"cwd": cwd}))
});
}
#[test]
fn needs_migration_parity_battery() {
replay("needs_migration", |i| {
json!({"ids": needs_migration(&doc_of(i), i["proposed"].as_i64().unwrap()).unwrap()})
});
}
File diff suppressed because it is too large. Load diff
+444 -57
View File
@@ -1,74 +1,461 @@
//! project_registry — known project registry (W6).
//! PARITY port of src/rune/project_registry.py.
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
//! project_registry — canonical registry of scaffolded projects (T19
//! §19C).
//!
//! FULL parity port of src/rune/project_registry.py (dichotomy wave,
//! #2479 tranche). The registry is DISCOVERY/INDEX only (what projects
//! exist, where their truth lives, health/migration state); a project's
//! structural truth stays in ITS store, and Spine never absorbs it
//! (boundary rule 19C).
//!
//! FFI (doc-envelope law): every symbol takes one JSON doc and returns
//! {"ok": true, "value": ...} | {"ok": false, "err": ...}:
//! * pr_default_doc({now}) → {doc}
//! * pr_register({doc, project_id, scaffold_location, repo_identity,
//! forge_repo, default_branch, structural_schema_version,
//! runtime_generation, last_known_good, now}) → {doc, entry}
//! * pr_get({doc, project_id}) → {entry} (null → unknown project)
//! * pr_all_projects({doc}) → {projects} (sorted by project_id)
//! * pr_set_health({doc, project_id, health, note, now}) → {doc}
//! * pr_set_outstanding({doc, project_id, count}) → {doc}
//! * pr_mark_generation({doc, project_id, current, last_known_good})
//! → {doc}
//! * pr_resolve_cwd({doc, project_id}) → {cwd}
//! * pr_needs_migration({doc, proposed}) → {ids}
//!
//! Error envelope: oracle exceptions ride the `err` string with their
//! Python kind prefix — "ValueError: <msg>" / "KeyError: <key>" — so the
//! ctypes loader re-raises the SAME exception type the oracle raised.
//!
//! No-clock law: `now` (UTC ISO-8601) is always an argument to the
//! stamping calls; the kernel never reads the wall clock.
//!
//! Boundary: the registry FILE IO (_load hit branch / save — including
//! the updated_at stamp and the indent=1/sort_keys dump) stays in the
//! Python facade; the kernel is a pure function of the loaded doc.
//!
//! No pr_free_string export — sibling crates sharing the Python process
//! already export identical free symbols; callers free through
//! capability_scoring's cs_free_string.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectEntry {
pub project_id: String,
pub scaffold_location: String,
pub registered_at: String,
pub description: String,
use serde_json::{json, Map, Value};
use std::fmt;
use std::os::raw::c_char;
pub const REGISTRY_SCHEMA_VERSION: i64 = 1;
// project health states (operational: drives /testing and migration
// consent sweeps)
pub const HEALTHY: &str = "HEALTHY";
pub const DRIFTED: &str = "DRIFTED";
pub const MIGRATION_REQUIRED: &str = "MIGRATION_REQUIRED";
pub const UNKNOWN: &str = "UNKNOWN";
/// The four legal health states — the oracle's ValueError lattice.
const LEGAL_HEALTH: [&str; 4] = [HEALTHY, DRIFTED, MIGRATION_REQUIRED, UNKNOWN];
// ── oracle exceptions (typed, behind the doc envelope) ──────────────────────
/// The oracle raises ValueError (validation/refusal) or KeyError (missing
/// registry entry). The envelope's `err` string carries the Python kind
/// prefix so the loader re-raises the same exception type.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RegistryError {
Value(String),
Key(String),
}
pub struct ProjectRegistry {
pub projects: BTreeMap<String, ProjectEntry>,
}
impl ProjectRegistry {
pub fn new() -> Self {
ProjectRegistry {
projects: BTreeMap::new(),
impl fmt::Display for RegistryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RegistryError::Value(m) => write!(f, "ValueError: {m}"),
RegistryError::Key(k) => write!(f, "KeyError: {k}"),
}
}
}
pub fn register(
&mut self,
project_id: &str,
scaffold_location: &str,
description: &str,
) -> ProjectEntry {
let entry = ProjectEntry {
project_id: project_id.to_string(),
scaffold_location: scaffold_location.to_string(),
registered_at: now(),
description: description.to_string(),
};
self.projects.insert(project_id.to_string(), entry.clone());
entry
fn unknown_health(health: &str) -> RegistryError {
RegistryError::Value(format!("unknown health '{health}'"))
}
fn unknown_project(project_id: &str) -> RegistryError {
RegistryError::Value(format!(
"unknown project '{project_id}' — resolve identity before \
mutation; never execute from HOME"
))
}
fn no_repo_identity(project_id: &str) -> RegistryError {
RegistryError::Value(format!(
"project '{project_id}' has no repo_identity — no local \
execution checkout available"
))
}
fn projects_of(doc: &Map<String, Value>) -> Result<&Map<String, Value>, RegistryError> {
doc.get("projects")
.and_then(Value::as_object)
.ok_or_else(|| RegistryError::Key("projects".to_string()))
}
fn projects_mut(doc: &mut Map<String, Value>) -> Result<&mut Map<String, Value>, RegistryError> {
doc.get_mut("projects")
.and_then(Value::as_object_mut)
.ok_or_else(|| RegistryError::Key("projects".to_string()))
}
fn truncate_chars(s: &str, n: usize) -> String {
s.chars().take(n).collect()
}
// ── registry operations (pure functions of the loaded doc) ──────────────────
/// `_load` miss branch — the fresh registry document.
pub fn default_doc(now: &str) -> Value {
json!({"schema_version": REGISTRY_SCHEMA_VERSION,
"projects": {}, "updated_at": now})
}
/// `register` — index a scaffolded project. References only — the
/// project's structural truth stays in ITS store (boundary rule).
#[allow(clippy::too_many_arguments)]
pub fn register(
doc: &Map<String, Value>,
project_id: &str,
scaffold_location: &str,
repo_identity: &str,
forge_repo: &str,
default_branch: &str,
structural_schema_version: &Value,
runtime_generation: &str,
last_known_good: &str,
now: &str,
) -> Result<(Map<String, Value>, Value), RegistryError> {
let mut out = doc.clone();
let entry = json!({
"project_id": project_id,
"scaffold_location": scaffold_location,
"repo_identity": repo_identity,
"forge_repo": forge_repo,
"default_branch": default_branch,
"structural_schema_version": structural_schema_version,
"scaffold_state": {"health": UNKNOWN, "last_material_update": now},
"runtime_generation": runtime_generation,
"last_known_good": last_known_good,
"outstanding_validations": 0,
"registered_at": now,
});
projects_mut(&mut out)?.insert(project_id.to_string(), entry.clone());
Ok((out, entry))
}
/// `get` — registry lookup; null for unknown projects (the facade maps
/// JSON null back to Python None).
pub fn get(doc: &Map<String, Value>, project_id: &str) -> Result<Value, RegistryError> {
Ok(projects_of(doc)?
.get(project_id)
.cloned()
.unwrap_or(Value::Null))
}
/// `all_projects` — every entry, sorted by project_id (Python `sorted`).
pub fn all_projects(doc: &Map<String, Value>) -> Result<Vec<Value>, RegistryError> {
let mut projects: Vec<Value> = projects_of(doc)?.values().cloned().collect();
projects.sort_by_key(|p| {
p.get("project_id")
.and_then(Value::as_str)
.unwrap_or("")
.to_string()
});
Ok(projects)
}
/// `set_health` — validate against the health lattice first (ValueError
/// beats KeyError when both would fire), then stamp the material update
/// and append the (160-char-truncated) note when present.
pub fn set_health(
doc: &Map<String, Value>,
project_id: &str,
health: &str,
note: &str,
now: &str,
) -> Result<Map<String, Value>, RegistryError> {
if !LEGAL_HEALTH.contains(&health) {
return Err(unknown_health(health));
}
let mut out = doc.clone();
let p = projects_mut(&mut out)?
.get_mut(project_id)
.ok_or_else(|| RegistryError::Key(project_id.to_string()))?;
let state = p
.get_mut("scaffold_state")
.and_then(Value::as_object_mut)
.ok_or_else(|| RegistryError::Key("scaffold_state".to_string()))?;
state.insert("health".to_string(), json!(health));
state
.insert("last_material_update".to_string(), json!(now));
if !note.is_empty() {
let notes = state
.entry("notes".to_string())
.or_insert_with(|| json!([]));
if let Some(list) = notes.as_array_mut() {
list.push(json!({
"at": now,
"note": truncate_chars(note, 160),
}));
}
}
Ok(out)
}
pub fn get(&self, project_id: &str) -> Option<&ProjectEntry> {
self.projects.get(project_id)
/// `set_outstanding_validations` — the /testing aggregate writes back the
/// outstanding count per project.
pub fn set_outstanding(
doc: &Map<String, Value>,
project_id: &str,
count: i64,
) -> Result<Map<String, Value>, RegistryError> {
let mut out = doc.clone();
let p = projects_mut(&mut out)?
.get_mut(project_id)
.ok_or_else(|| RegistryError::Key(project_id.to_string()))?
.as_object_mut()
.ok_or_else(|| RegistryError::Key(project_id.to_string()))?;
p.insert("outstanding_validations".to_string(), json!(count));
Ok(out)
}
/// `mark_generation` — runtime generation bump; last_known_good only
/// moves when a non-empty value is supplied.
pub fn mark_generation(
doc: &Map<String, Value>,
project_id: &str,
current: &str,
last_known_good: &str,
) -> Result<Map<String, Value>, RegistryError> {
let mut out = doc.clone();
let p = projects_mut(&mut out)?
.get_mut(project_id)
.ok_or_else(|| RegistryError::Key(project_id.to_string()))?
.as_object_mut()
.ok_or_else(|| RegistryError::Key(project_id.to_string()))?;
p.insert("runtime_generation".to_string(), json!(current));
if !last_known_good.is_empty() {
p.insert("last_known_good".to_string(), json!(last_known_good));
}
Ok(out)
}
/// `resolve_cwd` — T21.2 §21.2B: canonical REPOSITORY/worktree execution
/// cwd for a project — NOT scaffold_location. Precedence (21.2B-3):
/// explicit contract/worktree cwd > programme cwd > registry repo cwd >
/// unresolved/refuse. Registry stores repo_identity as "git:<path>" or a
/// bare path. Scaffold storage is NEVER used as a repo-cwd proxy. Raises
/// for unknown projects (refuse mutation rather than guessing).
pub fn resolve_cwd(
doc: &Map<String, Value>,
project_id: &str,
) -> Result<String, RegistryError> {
let entry = projects_of(doc)?
.get(project_id)
.ok_or_else(|| unknown_project(project_id))?;
let mut repo = entry
.get("repo_identity")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if let Some(stripped) = repo.strip_prefix("git:") {
repo = stripped.to_string();
}
let repo = repo.trim().to_string();
if repo.is_empty() {
return Err(no_repo_identity(project_id));
}
// an active worktree overrides the repo root (21.2B-3 precedence)
let wt = entry
.get("active_worktree")
.and_then(Value::as_str)
.unwrap_or("");
if !wt.is_empty() {
return Ok(wt.to_string());
}
Ok(repo)
}
/// `needs_migration` — projects whose structural schema is older than a
/// proposed version (feeds the 18I consent sweep — never silent
/// upgrades). Entries default to schema version 1.
pub fn needs_migration(doc: &Map<String, Value>, proposed: i64) -> Result<Vec<String>, RegistryError> {
let mut ids: Vec<String> = projects_of(doc)?
.iter()
.filter(|(_, p)| {
p.get("structural_schema_version")
.and_then(Value::as_i64)
.unwrap_or(1)
< proposed
})
.map(|(pid, _)| pid.clone())
.collect();
ids.sort();
Ok(ids)
}
// ── FFI (doc-envelope law) ──────────────────────────────────────────────────
enum FfiError {
NullPtr,
Utf8(std::str::Utf8Error),
Json(serde_json::Error),
Registry(RegistryError),
}
impl fmt::Display for FfiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
FfiError::NullPtr => write!(f, "null argument pointer"),
FfiError::Utf8(e) => write!(f, "{e}"),
FfiError::Json(e) => write!(f, "{e}"),
FfiError::Registry(e) => write!(f, "{e}"),
}
}
}
impl Default for ProjectRegistry {
fn default() -> Self {
Self::new()
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 now() -> String {
format!(
"{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
)
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 doc_of(a: &Value) -> Map<String, Value> {
a.get("doc")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default()
}
/// pr_default_doc({now}) → {doc}
#[no_mangle]
pub extern "C" fn pr_default_doc(arg: *const c_char) -> *mut c_char {
envelope(read_arg(arg).map(|a| {
let now = a.get("now").and_then(Value::as_str).unwrap_or("");
json!({"doc": default_doc(now)})
}))
}
/// pr_register({doc, project_id, scaffold_location, repo_identity,
/// forge_repo, default_branch, structural_schema_version,
/// runtime_generation, last_known_good, now}) → {doc, entry}
#[no_mangle]
pub extern "C" fn pr_register(arg: *const c_char) -> *mut c_char {
envelope(read_arg(arg).and_then(|a| {
let s = |k: &str| a.get(k).and_then(Value::as_str).unwrap_or("");
register(
&doc_of(&a),
s("project_id"),
s("scaffold_location"),
s("repo_identity"),
s("forge_repo"),
s("default_branch"),
a.get("structural_schema_version")
.unwrap_or(&Value::Null),
s("runtime_generation"),
s("last_known_good"),
s("now"),
)
.map(|(doc, entry)| json!({"doc": doc, "entry": entry}))
.map_err(FfiError::Registry)
}))
}
/// pr_get({doc, project_id}) → {entry}
#[no_mangle]
pub extern "C" fn pr_get(arg: *const c_char) -> *mut c_char {
envelope(read_arg(arg).and_then(|a| {
let s = |k: &str| a.get(k).and_then(Value::as_str).unwrap_or("");
get(&doc_of(&a), s("project_id"))
.map(|entry| json!({"entry": entry}))
.map_err(FfiError::Registry)
}))
}
/// pr_all_projects({doc}) → {projects}
#[no_mangle]
pub extern "C" fn pr_all_projects(arg: *const c_char) -> *mut c_char {
envelope(read_arg(arg).and_then(|a| {
all_projects(&doc_of(&a))
.map(|projects| json!({"projects": projects}))
.map_err(FfiError::Registry)
}))
}
/// pr_set_health({doc, project_id, health, note, now}) → {doc}
#[no_mangle]
pub extern "C" fn pr_set_health(arg: *const c_char) -> *mut c_char {
envelope(read_arg(arg).and_then(|a| {
let s = |k: &str| a.get(k).and_then(Value::as_str).unwrap_or("");
set_health(&doc_of(&a), s("project_id"), s("health"), s("note"), s("now"))
.map(|doc| json!({"doc": doc}))
.map_err(FfiError::Registry)
}))
}
/// pr_set_outstanding({doc, project_id, count}) → {doc}
#[no_mangle]
pub extern "C" fn pr_set_outstanding(arg: *const c_char) -> *mut c_char {
envelope(read_arg(arg).and_then(|a| {
let s = |k: &str| a.get(k).and_then(Value::as_str).unwrap_or("");
let count = a.get("count").and_then(Value::as_i64).unwrap_or(0);
set_outstanding(&doc_of(&a), s("project_id"), count)
.map(|doc| json!({"doc": doc}))
.map_err(FfiError::Registry)
}))
}
/// pr_mark_generation({doc, project_id, current, last_known_good}) → {doc}
#[no_mangle]
pub extern "C" fn pr_mark_generation(arg: *const c_char) -> *mut c_char {
envelope(read_arg(arg).and_then(|a| {
let s = |k: &str| a.get(k).and_then(Value::as_str).unwrap_or("");
mark_generation(&doc_of(&a), s("project_id"), s("current"), s("last_known_good"))
.map(|doc| json!({"doc": doc}))
.map_err(FfiError::Registry)
}))
}
/// pr_resolve_cwd({doc, project_id}) → {cwd}
#[no_mangle]
pub extern "C" fn pr_resolve_cwd(arg: *const c_char) -> *mut c_char {
envelope(read_arg(arg).and_then(|a| {
let s = |k: &str| a.get(k).and_then(Value::as_str).unwrap_or("");
resolve_cwd(&doc_of(&a), s("project_id"))
.map(|cwd| json!({"cwd": cwd}))
.map_err(FfiError::Registry)
}))
}
/// pr_needs_migration({doc, proposed}) → {ids}
#[no_mangle]
pub extern "C" fn pr_needs_migration(arg: *const c_char) -> *mut c_char {
envelope(read_arg(arg).and_then(|a| {
let proposed = a.get("proposed").and_then(Value::as_i64).unwrap_or(0);
needs_migration(&doc_of(&a), proposed)
.map(|ids| json!({"ids": ids}))
.map_err(FfiError::Registry)
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_register_and_get() {
let mut reg = ProjectRegistry::new();
reg.register("test-project", "/tmp/test", "A test project");
let entry = reg.get("test-project");
assert!(entry.is_some());
assert_eq!(entry.unwrap().scaffold_location, "/tmp/test");
}
}
mod branch_battery;