- 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.
132 lines
5.5 KiB
Python
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
|