Files
rune/tests/test_v1_foundations.py
T
Nick 04b0f4ac96 feat(v1): impact preview, structural drift, schema consent, workspace lifecycle, forge boundary (T18 18G-18L)
- impact_preview.py (18G): user-visible structured pre-build projection
  from impact closure + Build Basis — target/domains/consumers/files/
  state/environment/migration/risks/verification/rollback. Structured
  conclusions only, no private reasoning. 2 held-outs.
- drift_resume extension (18H): detect_structural_drift — source-file
  moved/missing detection against structural-model claims; REALITY
  WINS (auto-reconcile suggestion) except user-locked claims escalate
  to CONFLICTING. 3 held-outs.
- structural_migration.py (18I): MINOR (additive, automatic) vs
  MATERIAL (rename/drop/id-format → ConsentRequired raised on silent
  attempt — structurally impossible to silently upgrade); consent
  brief; affected-projects listing. 4 held-outs.
- workspace lifecycle (18K): canonical stage machine on structural_model
  (base_verified→…→cleaned, order-enforced, generation references —
  never live-tree mutation). BuildManager stays the bounded worktree
  adapter (17G decision, rule 18K: not deleted for neatness). 2 held-outs.
- forge.py (18L): minimum stable forge boundary (Protocol + GiteaForge
  delegating to canonical gitea_auth/scripts — no duplicate clients).
  ForgeUnavailable is blocking-for-operation, never a truth change.
  Git remains source-history authority. 3 held-outs.

Suite 1951 green; ratchets PASS.
2026-09-15 23:00:23 +01:00

132 lines
5.5 KiB
Python

"""test_v1_foundations.py — T18 §18G/§18H/§18I held-outs: impact
preview, structural drift, schema versioning + consent."""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO / "src"))
from rune import build_basis as bb # noqa: E402
from rune import drift_resume as dr # noqa: E402
from rune import impact_preview as ip # noqa: E402
from rune import structural_model as sm # noqa: E402
@pytest.fixture()
def model(tmp_path):
m = sm.StructuralModel(tmp_path / "store", "demo")
m.add("domain", "d", parent="project:demo")
m.add("component", "widget", parent="domain:d",
ext={}, evidence_ref="e1")
e = m.get("component:widget")
e["identities"]["source"] = "src/widget.py"
m.relate("component:widget", "verified_by", "test_suite:t")
m.add("test_suite", "t", parent="project:demo")
m.relate("component:widget", "verified_by", "test_suite:t")
m.relate("component:widget", "deployed_by", "deployment_surface:live")
m.add("deployment_surface", "live", parent="project:demo")
m.save()
return m
# ── 18G: visible impact preview ─────────────────────────────────────────
def test_18g_preview_shape_and_no_reasoning(model):
closure = model.impact_closure("component:widget")
basis = bb.new_basis(question="improve widget",
depth_decision=bb.research_depth("INWARD"))
basis["risks"] = ["widget consumers may break"]
preview = ip.build_preview(model, closure, basis=basis)
assert ip.validate_preview(preview) == []
assert preview["target"] == "component:widget"
assert preview["verification_plan"] == ["test_suite:t"]
assert preview["environment_runtime_implications"] == [
"deployment_surface:live"]
assert any("widget consumers" in str(r) for r in
preview["risks_uncertainties"])
# structured conclusions only — no chain-of-thought fields
blob = str(preview)
assert "chain" not in blob.lower() and "reasoning" not in blob.lower()
def test_18g_preview_error_path(model):
p = ip.build_preview(model, {"error": "unknown entity x"})
assert p.get("error") == "unknown entity x"
# ── 18H: structural drift — REALITY WINS ────────────────────────────────
def test_18h_source_moved_detected(tmp_path, model):
# model claims src/widget.py; reality has it at src/widgets/widget.py
moved_dir = tmp_path / "src" / "widgets"
moved_dir.mkdir(parents=True)
(moved_dir / "widget.py").write_text("# moved here")
drifts = dr.detect_structural_drift(model, tmp_path)
assert len(drifts) == 1
d = drifts[0]
assert d["class"] == "MATERIAL" and "moved to src/widgets/widget.py" in d["evidence"]
assert d["reality_wins"] is True
def test_18h_source_missing_and_user_locked(tmp_path, model):
(tmp_path / "src").mkdir() # widget.py absent entirely
drifts = dr.detect_structural_drift(model, tmp_path)
assert drifts[0]["class"] == "MATERIAL"
# user-locked claim escalates to CONFLICTING, never silently rewritten
e = model.get("component:widget")
e["identities"]["user_locked"] = True
model.save()
drifts2 = dr.detect_structural_drift(model, tmp_path)
assert drifts2[0]["class"] == "CONFLICTING"
assert drifts2[0]["reality_wins"] is False
def test_18h_no_drift_when_reality_matches(tmp_path, model):
(tmp_path / "src").mkdir()
(tmp_path / "src" / "widget.py").write_text("# here")
assert dr.detect_structural_drift(model, tmp_path) == []
# ── 18I: schema versioning + consent ────────────────────────────────────
def test_18i_minor_vs_material_migration_classification():
from rune import structural_migration as mig
# minor: additive, backwards-compatible → automatic
assert mig.classify_migration(
from_v=1, to_v=2,
changes=["add optional entity kind 'capability'"]) == "MINOR"
# material: semantic change → consent required
assert mig.classify_migration(
from_v=1, to_v=2,
changes=["rename relationship 'owns' to 'possesses'",
"component ids change format"]) == "MATERIAL"
# removals are material
assert mig.classify_migration(
from_v=2, to_v=3, changes=["drop 'legacy_of' relation type"]) == \
"MATERIAL"
def test_18i_consent_flow_refuses_silent_material_upgrade():
from rune import structural_migration as mig
# a material upgrade without user approval is REFUSED
with pytest.raises(mig.ConsentRequired):
mig.apply_migration(model_store=None, from_v=1, to_v=2,
changes=["rename 'owns' to 'possesses'"],
user_approved=False)
# with approval it proceeds (recorded)
rec = mig.apply_migration(model_store=None, from_v=1, to_v=2,
changes=["rename 'owns' to 'possesses'"],
user_approved=True,
approved_by="user")
assert rec["user_approved"] is True and rec["to_v"] == 2
def test_18i_affected_projects_listing():
from rune import structural_migration as mig
# a material uplift can name the projects it would touch
stores = {"a": {"schema_version": 1}, "b": {"schema_version": 2}}
affected = mig.affected_projects(stores, proposed_v=2)
assert affected == ["a"] # only schema_version < 2 need migration