Files
rune/scripts/construction_guards.py
Nick aab5159ccc feat(scaffolding): Construction Pattern core — evidence-backed applicability, filtered obligation derivation, process guards (SCAFFOLD-TRANCHE-006)
Also §0 pre-flight: SCAFFOLD-TRANCHE-005 found approved/open (parent-
lifecycle neglect — child closed, parent forgotten); closed with real
existing evidence only, provenance recorded, process-defect entity added.

- construction_pattern.py: open-world ConstructionPattern (signals,
  obligations with when-conditions, provenance, revision + history,
  maturity, ext); evaluate_applicability — deterministic signal groups,
  verdicts APPLIES/PARTIAL/DOES_NOT_APPLY/UNKNOWN, name similarity
  establishes nothing, project exclusions override, model proposals stay
  PENDING; derive_pattern_obligations — obligations FILTERED by project
  evidence (REQUIRED/APPLICABLE/NOT_APPLICABLE/UNKNOWN + EXCEPTED with
  provenance retained, §18); combine_pattern_obligations — dedupe with
  also_from, conflicts surfaced never silently resolved (§26-27);
  authority boundary: pattern = guidance, project evidence wins (§3)
- construction_patterns_builtin.py: 4 evidence-grounded patterns, all
  appropriately immature (§15):
  * capability-module-adaptation (UNIT-PROVEN; build-but-don't-break;
    provenance T1/T3/T4 contracts)
  * release-deployment-change (UNIT-PROVEN; 9 obligations incl.
    fail-closed qualification, exact identity, staging!=promotion,
    promotion truthfulness, monotonic ordering, negative+forward proofs,
    deployed-identity verification; provenance deploy-order-race
    incident + DEPLOY-MONOTONIC-001 + red-promotion incidents)
  * ui-tui-structural-change (UNIT-PROVEN; layout inheritance, USER-LOCKED
    preservation, UNKNOWN-never-PASS; provenance issue-105 + T3 real proof)
  * store-schema-evolution (CANDIDATE; backward readability, roundtrip,
    authority preservation, migration-only-if-needed; provenance T2/T4/T5)
- make_decision integration: APPLIES+PARTIAL patterns consulted (§7 —
  per-obligation when-conditions do the precision work); provenance
  PATTERN distinct from STRUCTURAL derivations (§8); pattern refs +
  revisions carried into generated contracts (§17/§19)
- construction_guards.py + Scaffold.save_verified/verify_on_disk (§21-22):
  Guard A durable-mutation readback — verify_on_disk is the pure no-write
  readback that catches the forgotten-save lie (T5 defect class A);
  Guard B source-edit postcondition — zero-match is a truthful no-op
  (EditNoOp), expected-count + postcondition enforced (T5 defect class B)
- REAL PROOFS: §23 read-only replay of DEPLOY-MONOTONIC-001 Need —
  release pattern APPLIES on evidence, 9 obligations derived WITHOUT the
  Need listing them (negative/forward/monotonic/deployed-identity all
  pattern-derived, provenance carried); resolution COMPOSE matches the
  original repair; §24 live change save_verified/verify_on_disk — the
  store pattern contributed store-roundtrip, an obligation the raw Need
  did not state; §25 held-out — webhook-config-only deploy change does
  NOT demand promotion rework but keeps release regression verification

Tests: 30 new (§28-§32 plans + held-out + live); suite 1556 green;
henxels rc=0; pyright clean.
2026-09-13 14:48:28 +01:00

124 lines
4.9 KiB
Python

"""construction_guards.py — deterministic guards for observed process
defects (SCAFFOLD-TRANCHE-006 §21-22).
Two real defect classes from recent sessions:
A. DURABLE-STORE MUTATION WITHOUT PERSISTENCE (Tranche 5: closure script
mutated the contract in memory, never called save(); the next read saw
stale state). Guard: a durable-boundary operation verifies by
RELOAD/READBACK, not by successful in-memory mutation.
B. BLIND SOURCE-EDIT NO-OP (multiple sessions: python str.replace() on
ruff-reformatted files silently matched nothing; the script reported
success while changing nothing). Guard: an automated edit verifies its
POSTCONDITION — expected text present / old text absent — and a
zero-match replacement is an explicit, truthful no-op, never success.
Not a generic editor framework. Two small, deterministic postcondition
checks at the boundaries where the defects actually occurred.
"""
from __future__ import annotations
import json
from pathlib import Path
__all__ = ["verify_persisted", "assert_persisted", "apply_source_edit", "EditNoOp", "EditPostconditionFailed"]
class EditNoOp(Exception):
"""The edit matched zero targets — an explicit, truthful no-op."""
class EditPostconditionFailed(Exception):
"""The edit claimed success but the expected postcondition is absent."""
# ── Guard A: durable mutation verified by readback ──────────────────────────
def verify_persisted(path: Path | str, selector, expect) -> bool:
"""Reload the durable store from disk and check the expectation against
the RELOADED state. `selector(reloaded_doc) -> value`; pass == value
equals `expect` (or expect(value) is truthy when callable).
The reloaded document is parsed as JSON when the file is JSON-shaped;
otherwise the raw text is passed to the selector (plain-text stores).
This is the Tranche 5 defect guard: an in-memory mutation that was
never save()d fails here, because the disk state is what gets read.
"""
raw = Path(path).read_text(encoding="utf-8")
try:
doc = json.loads(raw)
except ValueError:
doc = raw
value = selector(doc)
if callable(expect):
return bool(expect(value))
return value == expect
def assert_persisted(path: Path | str, selector, expect, *, what: str = "durable state") -> None:
"""verify_persisted that raises on mismatch — for use right after a
save() at durable boundaries."""
if not verify_persisted(path, selector, expect):
raise AssertionError(
f"persistence verification FAILED for {what}: reloaded state "
f"does not match expectation — the mutation may not have been "
f"saved (Tranche 5 defect class A)"
)
# ── Guard B: source edit with postcondition ─────────────────────────────────
def apply_source_edit(
text: str,
old: str,
new: str,
*,
expected_count: int | None = None,
postcondition: str | None = None,
allow_noop: bool = False,
) -> str:
"""Apply a source edit WITH postcondition verification (Guard B).
- zero matches: raises EditNoOp (truthful no-op) unless allow_noop=True
- expected_count given: match count must equal it
- postcondition given: the RESULT must contain it, else
EditPostconditionFailed
Returns the new text. Never claims success for a change that did not
happen."""
count = text.count(old)
if count == 0:
if allow_noop:
return text # truthful, explicit no-op — caller records it
raise EditNoOp(
f"edit matched 0 targets — nothing changed (Tranche 5 defect "
f"class B: str.replace silently no-ops on reformatted files)"
)
if expected_count is not None and count != expected_count:
raise EditPostconditionFailed(f"edit matched {count} targets, expected {expected_count}")
result = text.replace(old, new)
if postcondition and postcondition not in result:
raise EditPostconditionFailed(
"postcondition text absent from result — edit did not achieve its expected effect"
)
return result
def apply_source_edit_file(
path: Path | str, old: str, new: str, *, expected_count: int | None = None, postcondition: str | None = None
) -> int:
"""File-level convenience: read, apply_source_edit, write back, then
VERIFY BY READBACK (both guards composed). Returns match count."""
p = Path(path)
text = p.read_text(encoding="utf-8")
result = apply_source_edit(text, old, new, expected_count=expected_count, postcondition=postcondition)
p.write_text(result, encoding="utf-8")
# Guard A composed: the written file must contain the postcondition
if postcondition:
assert_persisted(p, lambda t: t, lambda t: postcondition in t, what=f"edit of {p.name}")
return text.count(old)