From 187d50c8fc7fde8cf22a000fcf7a4aee6d408fa9 Mon Sep 17 00:00:00 2001 From: PhysShell Date: Fri, 17 Jul 2026 15:46:48 +0500 Subject: [PATCH 1/9] =?UTF-8?q?feat(s2-step10):=20green=20=E2=80=94=20the?= =?UTF-8?q?=20analyzer-delta=20classifier=20+=20atomic=20publisher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 1 of S2 step 10 (analyzer delta verification), the accepted contract's red-to-green slice 1. Adds ownlang/fix_delta.py with the pure, SDK-free core: - the two-representation delta classifier (Section 8): the subscription finding-id equations, the complete core OWN001 multiset delta (P_all - B_all == empty, B_all - P_all == R_C), the OWN050 multiset (P50 - B50 == empty), and the analyzer-to-id bridge that fails closed as ANALYSIS_IDENTITY on an unbridged / mixed-action / ambiguous candidate; - the closed core.json schema parser (LA4) with strict field types (advisory is a required boolean; canonical relative file identity); - the canonical evidence serializer (sorted keys, trailing newline); - _publish_delta (LA3): ONE atomic rename of the claimed private workdir to OUTPUT_DIR, so either OUTPUT_DIR is absent or holds the complete delta-result.json — nothing runs after the rename. Reuses the frozen Step 9 helpers by import (never rewrites them). No CLI wiring, extractor, or core subprocess yet (later slices). tests/test_verify_delta.py covers the classifier's pass/fail matrix, the core.json schema, and publication. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G --- ownlang/fix_delta.py | 403 +++++++++++++++++++++++++++++++++++++ tests/test_verify_delta.py | 236 ++++++++++++++++++++++ 2 files changed, 639 insertions(+) create mode 100644 ownlang/fix_delta.py create mode 100644 tests/test_verify_delta.py diff --git a/ownlang/fix_delta.py b/ownlang/fix_delta.py new file mode 100644 index 00000000..0bbce565 --- /dev/null +++ b/ownlang/fix_delta.py @@ -0,0 +1,403 @@ +"""S2 step 10 — the analyzer-delta verifier over an accepted step 8 bundle. + + python -m ownlang own-fix subscriptions verify-delta \ + --bundle --plan \ + --candidates --root \ + --gate --extractor-dll \ + --out [--ref-dir ]... + +Step 9 proves the patch is structurally admissible and that an INDEPENDENT git applies it +to the pristine preimage. It never runs the analyzer, so it cannot tell whether the fix +removed the *leak the analyzer reports*. Step 10 does exactly that: it re-runs Own.NET's +real core analyzer (`check_facts`, which calls `check_module`) over the accepted preimage +and postimage — the analyzer running from a SNAPSHOTTED ownlang package in a fresh, +isolated subprocess, so the fingerprinted bytes are the bytes that actually run — and +proves the OWN001 leak findings changed exactly as the plan promised: the converted +candidates gone, the manual-review candidates preserved, no new OWN001 of ANY resource +lane, no unrelated OWN001 lost, and no newly-introduced OWN050. + +Trust: Step 10 trusts NOTHING it is handed except the fingerprinted toolchain (the +extractor deployment, the dotnet host + selected runtime, the snapshotted ownlang package, +the generated core runner, and the Python executable + declared identity). Every byte +input is read through the same ONE snapshot boundary Step 9 uses (reject symlink/reparse, +O_NOFOLLOW, fstat, regular file, read once). It performs no git operation and never writes +the real checkout, index, or config. This module reuses the frozen Step 9 helpers by +import — it never rewrites them — and defines only its own publisher, `_publish_delta`. +""" + +from __future__ import annotations + +import json +import os +import shutil +import stat +from collections import Counter +from typing import Any + +from ownlang.fix_gate import ( + _canonical_bytes, + _canonical_json, + _claim_workdir, + _is_link, + _out_parent, + _same_or_inside, + _same_path, + _sha_bytes, + _snapshot, + validate_gate_authority, +) + +# --- failure taxonomy (the stable branch markers regressions assert on) --- +INPUT_LAYOUT = "INPUT_LAYOUT" +AUTHORITY_BINDING = "AUTHORITY_BINDING" +GATE_BINDING = "GATE_BINDING" +TOOLCHAIN_BINDING = "TOOLCHAIN_BINDING" +ANALYSIS_SCOPE = "ANALYSIS_SCOPE" +BASELINE_ANALYSIS = "BASELINE_ANALYSIS" +POSTIMAGE_ANALYSIS = "POSTIMAGE_ANALYSIS" +ANALYSIS_IDENTITY = "ANALYSIS_IDENTITY" +DELTA_MISMATCH = "DELTA_MISMATCH" +NEW_OWN001 = "NEW_OWN001" +NEW_OWN050 = "NEW_OWN050" +IDEMPOTENCE = "IDEMPOTENCE" +ISOLATION = "ISOLATION" +PUBLICATION = "PUBLICATION" +INFRASTRUCTURE = "INFRASTRUCTURE" + +# The exact 17-name check set published in delta-result.json (Section 14 / LA). +_CHECK_NAMES = ( + "input_layout", "authority_binding", "gate_binding", "toolchain_binding", + "core_analyzer_binding", "analysis_scope", "baseline_authority", "baseline_analysis", + "postimage_analysis", "analysis_identity", "delta_subscription", "delta_core", + "new_own001", "new_own050", "semantic_idempotence", "isolation", "publication", +) + +# The core OWN001 observation key (9 fields, verbatim from the real Finding) and the +# OWN050 key (4 fields). Frozen so the runner and the parent agree byte-for-byte. +_CORE_KEY_FIELDS = ("file", "code", "component", "event", "handler", "kind", + "advisory", "severity", "ignore_reason") +_OWN050_KEY_FIELDS = ("file", "component", "event", "handler") +_BRIDGE_FIELDS = ("file", "component", "event", "handler") +# The 18 authoritative S0 candidate fields the baseline-authority check re-derives. +_AUTHORITY_FIELDS = ( + "finding_id", "diagnostic_code", "containing_type", "file", "enclosing_member", + "event", "event_identity", "event_contract", "source", "source_identity", + "source_identity_kind", "handler", "handler_identity", "handler_identity_kind", + "occurrence_ordinal", "acquire_span", "teardown", "allowed_actions", +) + + +class DeltaError(Exception): + """A controlled refusal, carrying the stable category for regression assertions.""" + + def __init__(self, category: str, message: str) -> None: + super().__init__(message) + self.category = category + + +def _ckey(d: dict[str, Any]) -> str: + """The canonical string of an observation dict — the multiset element identity.""" + return _canonical_json(d).decode("utf-8") + + +def _require_key(d: Any, fields: tuple[str, ...], cat: str, where: str) -> dict[str, Any]: + """Exact-key object with string-or-null typed values; fail closed on any drift.""" + if not isinstance(d, dict): + raise DeltaError(cat, f"{where}: must be an object") + if set(d) != set(fields): + raise DeltaError(cat, f"{where}: keys {sorted(d)} != {sorted(fields)}") + return d + + +def _core_key(obs: Any, cat: str, where: str) -> dict[str, Any]: + """Validate a core OWN001 observation to its exact 9-field shape and types.""" + o = _require_key(obs, _CORE_KEY_FIELDS, cat, where) + if o["code"] != "OWN001": + raise DeltaError(cat, f"{where}.code must be 'OWN001'") + for k in ("file", "component", "event", "handler", "kind"): + if not isinstance(o[k], str): + raise DeltaError(cat, f"{where}.{k} must be a string") + if not isinstance(o["advisory"], bool): + raise DeltaError(cat, f"{where}.advisory must be a boolean") + for k in ("severity", "ignore_reason"): + if not (o[k] is None or isinstance(o[k], str)): + raise DeltaError(cat, f"{where}.{k} must be a string or null") + # a canonical, forward-slash target-file identity (never absolute, never escaping) + _require_rel(o["file"], cat, f"{where}.file") + return {k: o[k] for k in _CORE_KEY_FIELDS} + + +def _own050_key(obs: Any, cat: str, where: str) -> dict[str, Any]: + o = _require_key(obs, _OWN050_KEY_FIELDS, cat, where) + for k in _OWN050_KEY_FIELDS: + if not isinstance(o[k], str): + raise DeltaError(cat, f"{where}.{k} must be a string") + _require_rel(o["file"], cat, f"{where}.file") + return {k: o[k] for k in _OWN050_KEY_FIELDS} + + +def _require_rel(path: str, cat: str, where: str) -> None: + """A canonical, root-relative, forward-slash path — no drive, no '..', no backslash.""" + if not path or path.startswith("/") or ":" in path or "\\" in path: + raise DeltaError(cat, f"{where}: {path!r} is not a canonical relative path") + parts = path.split("/") + if any(seg in ("", ".", "..") for seg in parts): + raise DeltaError(cat, f"{where}: {path!r} has an empty or dotted segment") + + +def _proj(obs: dict[str, Any]) -> tuple[str, ...]: + """The 4-field bridge projection of a core observation (SUBSCRIPTION_JOIN_KEY).""" + return tuple(obs[k] for k in _BRIDGE_FIELDS) + + +def _sorted_multiset(items: list[dict[str, Any]]) -> list[dict[str, Any]]: + """A stable, repetition-preserving multiset serialization: sort by canonical bytes.""" + return sorted(items, key=_ckey) + + +def _parse_core(raw: Any, cat: str) -> dict[str, Any]: + """Validate the closed core.json schema the fresh runner emits (LA4). `cat` is the + per-image analysis category (BASELINE_ANALYSIS / POSTIMAGE_ANALYSIS).""" + if not isinstance(raw, dict): + raise DeltaError(cat, "core.json: must be an object") + if set(raw) != {"version", "operation", "all_own001", "own050", + "fix_eligible_subscriptions"}: + raise DeltaError(cat, f"core.json: keys {sorted(raw)} are not the exact schema") + if raw["version"] != 1 or isinstance(raw["version"], bool): + raise DeltaError(cat, "core.json.version must be 1") + if raw["operation"] != "verify-subscription-core-observations": + raise DeltaError(cat, "core.json.operation is wrong") + for name in ("all_own001", "own050", "fix_eligible_subscriptions"): + if not isinstance(raw[name], list): + raise DeltaError(cat, f"core.json.{name} must be a list") + all_own001 = [_core_key(o, cat, f"core.json.all_own001[{i}]") + for i, o in enumerate(raw["all_own001"])] + own050 = [_own050_key(o, cat, f"core.json.own050[{i}]") + for i, o in enumerate(raw["own050"])] + fix_eligible = [_parse_fix_eligible(o, cat, f"core.json.fix_eligible_subscriptions[{i}]") + for i, o in enumerate(raw["fix_eligible_subscriptions"])] + return {"all_own001": all_own001, "own050": own050, + "fix_eligible_subscriptions": fix_eligible} + + +def _parse_fix_eligible(o: Any, cat: str, where: str) -> dict[str, Any]: + e = _require_key(o, ("finding_id", "diagnostic_code", "bridge_key", "record"), cat, where) + if not isinstance(e["finding_id"], str) or not isinstance(e["diagnostic_code"], str): + raise DeltaError(cat, f"{where}: finding_id/diagnostic_code must be strings") + bridge = _require_key(e["bridge_key"], _BRIDGE_FIELDS, cat, f"{where}.bridge_key") + for k in _BRIDGE_FIELDS: + if not isinstance(bridge[k], str): + raise DeltaError(cat, f"{where}.bridge_key.{k} must be a string") + _require_rel(bridge["file"], cat, f"{where}.bridge_key.file") + rec = e["record"] + if not isinstance(rec, dict) or set(rec) != set(_AUTHORITY_FIELDS): + raise DeltaError(cat, f"{where}.record is not the exact authoritative field set") + return {"finding_id": e["finding_id"], "diagnostic_code": e["diagnostic_code"], + "bridge_key": {k: bridge[k] for k in _BRIDGE_FIELDS}, "record": rec} + + +# --- the pure delta classifier (Section 8 / LA2 / LA4) ----------------------------- + + +def classify_delta(expected: dict[str, Any], baseline: dict[str, Any], + postimage: dict[str, Any]) -> dict[str, Any]: + """The two-representation delta over already-parsed core observations. Raises + DeltaError with the exact category on any equation or bridge violation; returns the + baseline / postimage / delta / semantic_idempotence evidence fragments on success.""" + convert = list(expected["convert_acquire_ids"]) + manual = list(expected["manual_review_ids"]) + c_set, m_set = set(convert), set(manual) + if len(c_set) != len(convert) or len(m_set) != len(manual): + raise DeltaError(DELTA_MISMATCH, "expected id list has a duplicate") + if c_set & m_set: + raise DeltaError(DELTA_MISMATCH, "convert and manual id sets are not disjoint") + s_set = c_set | m_set + + b_sub = _subscription_ids(baseline, BASELINE_ANALYSIS) + p_sub = _subscription_ids(postimage, POSTIMAGE_ANALYSIS) + + # --- the bridge FIRST: every accepted id maps to exactly one baseline core + # OWN001 observation (identity questions precede the id-set equations, so an + # unbridged / mixed-action / ambiguous candidate fails closed as ANALYSIS_IDENTITY + # rather than being masked by a later DELTA_MISMATCH). R_C feeds the core delta. + r_c = _bridge_r_c(convert, manual, baseline) + + # --- subscription finding-id equations ------------------------------------- + if not s_set <= b_sub: + raise DeltaError(DELTA_MISMATCH, + f"accepted ids not all baseline subscriptions: {sorted(s_set - b_sub)}") + removed_sub = b_sub - p_sub + preserved_sub = b_sub & p_sub + new_sub = p_sub - b_sub + if new_sub: + raise DeltaError(NEW_OWN001, f"new fix-eligible subscriptions: {sorted(new_sub)}") + if removed_sub & s_set != c_set: + raise DeltaError(DELTA_MISMATCH, + "removed candidate-scoped subscriptions != the converted set") + if preserved_sub & s_set != m_set: + raise DeltaError(DELTA_MISMATCH, + "preserved candidate-scoped subscriptions != the manual-review set") + unexpectedly_removed = (b_sub - s_set) - p_sub + if unexpectedly_removed: + raise DeltaError(DELTA_MISMATCH, + f"undeclared disappearance: {sorted(unexpectedly_removed)}") + + # --- complete core OWN001 multiset delta ----------------------------------- + b_all = Counter(_ckey(o) for o in baseline["all_own001"]) + p_all = Counter(_ckey(o) for o in postimage["all_own001"]) + new_all = p_all - b_all + if new_all: + raise DeltaError(NEW_OWN001, "a new core OWN001 observation appeared") + removed_all = b_all - p_all + if removed_all != r_c: + raise DeltaError(DELTA_MISMATCH, + "removed core OWN001 observations != those authorized for conversion") + + # --- OWN050 multiset -------------------------------------------------------- + b50 = Counter(_ckey(o) for o in baseline["own050"]) + p50 = Counter(_ckey(o) for o in postimage["own050"]) + new_own050 = p50 - b50 + if new_own050: + raise DeltaError(NEW_OWN050, "a newly-introduced OWN050 advisory appeared") + + # --- semantic idempotence --------------------------------------------------- + still_actionable = sorted(c_set & p_sub) + if still_actionable: + raise DeltaError(IDEMPOTENCE, f"converted ids still actionable: {still_actionable}") + + return { + "baseline": { + "subscription_own001_ids": sorted(b_sub), + "all_own001": _sorted_multiset(baseline["all_own001"]), + "own050": _sorted_multiset(baseline["own050"]), + }, + "postimage": { + "subscription_own001_ids": sorted(p_sub), + "all_own001": _sorted_multiset(postimage["all_own001"]), + "own050": _sorted_multiset(postimage["own050"]), + }, + "delta": { + "removed_subscription_own001_ids": sorted(removed_sub & s_set), + "preserved_subscription_own001_ids": sorted(preserved_sub & s_set), + "new_subscription_own001_ids": [], + "unexpectedly_removed_subscription_own001_ids": [], + "removed_all_own001": _multiset_to_list(removed_all, baseline["all_own001"]), + "new_all_own001": [], + "new_own050": [], + }, + "semantic_idempotence": {"converted_ids_still_actionable": [], "pass": True}, + } + + +def _subscription_ids(image: dict[str, Any], cat: str) -> set[str]: + ids: list[str] = [e["finding_id"] for e in image["fix_eligible_subscriptions"] + if e["diagnostic_code"] == "OWN001"] + if len(ids) != len(set(ids)): + raise DeltaError(cat, "duplicate fix-eligible OWN001 subscription finding_id") + return set(ids) + + +def _bridge_r_c(convert: list[str], manual: list[str], + baseline: dict[str, Any]) -> Counter[str]: + """Bridge each accepted candidate to exactly one baseline core OWN001 observation and + return R_C, the multiset of baseline observations mapped to the converted set. Every + ambiguity fails closed as ANALYSIS_IDENTITY (SUBSCRIPTION_JOIN_KEY).""" + by_fid = {e["finding_id"]: e["bridge_key"] for e in baseline["fix_eligible_subscriptions"] + if e["diagnostic_code"] == "OWN001"} + # each 4-field projection -> the distinct full observations that project to it + by_proj: dict[tuple[str, ...], list[dict[str, Any]]] = {} + for obs in baseline["all_own001"]: + by_proj.setdefault(_proj(obs), []).append(obs) + + # mixed-action under one indistinguishable bridge key -> fail closed + action_of: dict[tuple[str, ...], set[str]] = {} + for fid in convert + manual: + if fid not in by_fid: + raise DeltaError(ANALYSIS_IDENTITY, + f"accepted candidate {fid} has no baseline subscription fact") + key = tuple(by_fid[fid][k] for k in _BRIDGE_FIELDS) + action_of.setdefault(key, set()).add("convert" if fid in set(convert) else "manual") + for key, actions in action_of.items(): + if len(actions) > 1: + raise DeltaError(ANALYSIS_IDENTITY, + f"bridge key {key} mixes convert and manual candidates") + + r_c: Counter[str] = Counter() + consumed: Counter[tuple[str, ...]] = Counter() + for fid in convert: + key = tuple(by_fid[fid][k] for k in _BRIDGE_FIELDS) + matches = by_proj.get(key, []) + distinct = {_ckey(o) for o in matches} + if not matches: + raise DeltaError(ANALYSIS_IDENTITY, + f"candidate {fid} has no baseline core OWN001 observation") + if len(distinct) != 1: + raise DeltaError(ANALYSIS_IDENTITY, + f"candidate {fid} maps to multiple distinct core observations") + consumed[key] += 1 + if consumed[key] > len(matches): + raise DeltaError(ANALYSIS_IDENTITY, + f"more converted candidates than observations for key {key}") + r_c[_ckey(matches[0])] += 1 + return r_c + + +def _multiset_to_list(counter: Counter[str], pool: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Materialize a multiset (keyed by canonical strings) back to sorted observation + dicts, drawing each element's shape from the pool it was counted over.""" + shape = {_ckey(o): o for o in pool} + out: list[dict[str, Any]] = [] + for ck in sorted(counter): + out.extend([shape[ck]] * counter[ck]) + return out + + +# --- canonical evidence + atomic publication (LA3) --------------------------------- + + +def _publish_delta(out: str, root: str, evidence_bytes: bytes) -> str: + """Publish delta-result.json as ONE atomic rename of a claimed private work directory + to OUTPUT_DIR (LA3). The claimed workdir itself is the staging directory and holds + exactly delta-result.json, so nothing runs after the rename: either OUTPUT_DIR is + absent, or it holds the complete canonical delta-result.json. A pre-rename failure + removes the workdir; a post-rename failure is impossible because there is none.""" + from ownlang.fix_gate import GateError + try: + out_phys, _parent_phys, root_phys = _out_parent(out, root) + workdir = _claim_workdir(_parent_phys) + except GateError as exc: # reuse the frozen helper; keep its (identical) category + raise DeltaError(exc.category, str(exc)) from exc + succeeded = False + try: + with open(os.path.join(workdir, "delta-result.json"), "wb") as fh: + fh.write(evidence_bytes) + parent = os.path.dirname(out_phys) + if not os.path.isdir(parent): + raise DeltaError(PUBLICATION, "the out-dir parent vanished before publication") + if not _same_path(os.path.realpath(parent), os.path.dirname(out_phys)): + raise DeltaError(PUBLICATION, "the out-dir parent moved before publication") + if _same_or_inside(root_phys, os.path.realpath(parent)): + raise DeltaError(PUBLICATION, "the out-dir parent now resolves inside the root") + if os.path.exists(out_phys) or os.path.islink(out_phys): + raise DeltaError(PUBLICATION, "the out-dir appeared before publication") + _require_single_delta_file(workdir) + os.rename(workdir, out_phys) + succeeded = True + finally: + if not succeeded: + shutil.rmtree(workdir, ignore_errors=True) + return out_phys + + +def _require_single_delta_file(workdir: str) -> None: + entries = list(os.scandir(workdir)) + if len(entries) != 1 or entries[0].name != "delta-result.json": + raise DeltaError(PUBLICATION, "the staging directory is not exactly delta-result.json") + st = entries[0].stat(follow_symlinks=False) + if _is_link(st) or not stat.S_ISREG(st.st_mode): + raise DeltaError(PUBLICATION, "the staged delta-result.json is not a regular file") + + +def canonical_evidence(evidence: dict[str, Any]) -> bytes: + """The published bytes: canonical JSON (sorted keys, compact) + a trailing newline.""" + return _canonical_bytes(evidence) diff --git a/tests/test_verify_delta.py b/tests/test_verify_delta.py new file mode 100644 index 00000000..5172dab1 --- /dev/null +++ b/tests/test_verify_delta.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""S2 step 10 — the analyzer-delta verifier (SDK-free unit + fixture tests). + +This module drives `ownlang/fix_delta.py` without a live .NET SDK. The pure classifier +(the two-representation OWN001/OWN050 delta), the closed core.json schema, the atomic +publisher (LA3), the OWN001-only scope guard (LA2), the exact Step 9 gate binding, and +the reference-closure snapshot are all exercised over synthetic inputs. The end-to-end +run over the real fresh core subprocess (still no dotnet) lives in `_fixture_core`; the +real extractor run is the Tier-B CI job. + +Run: python tests/test_verify_delta.py + python tests/run_tests.py (auto-discovered) +""" + +from __future__ import annotations + +import json +import os +import sys +import tempfile + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from ownlang import fix_delta as fd + +_EV = "System.ComponentModel.INotifyPropertyChanged.PropertyChanged" +_FILE = "Own/Sample.cs" +_TYPE = "Own.Sample.TwoOnOneLine" +_FIDA = "OWN001:sha256:" + "a" * 64 +_FIDB = "OWN001:sha256:" + "b" * 64 +_FIDC = "OWN001:sha256:" + "c" * 64 +_SPAN = {"start": 100, "length": 30, "start_line": 10, "start_column": 1, + "end_line": 10, "end_column": 31} + + +def _obs(event: str = "_a.PropertyChanged", handler: str = "OnA", + component: str = "TwoOnOneLine", file: str = _FILE, kind: str = "subscription token", + advisory: bool = False, severity: object = "warning", + ignore_reason: object = None) -> dict: + return {"file": file, "code": "OWN001", "component": component, "event": event, + "handler": handler, "kind": kind, "advisory": advisory, "severity": severity, + "ignore_reason": ignore_reason} + + +def _own050(event: str = "x.Changed", handler: str = "OnX", + component: str = "TwoOnOneLine", file: str = _FILE) -> dict: + return {"file": file, "component": component, "event": event, "handler": handler} + + +def _record(fid: str, handler: str = "OnA", source: str = "_a") -> dict: + return {"finding_id": fid, "diagnostic_code": "OWN001", "containing_type": _TYPE, + "file": _FILE, "enclosing_member": _TYPE + ".ctor()", "event": "PropertyChanged", + "event_identity": _EV, "event_contract": "inotify_property_changed", + "source": source, "source_identity": _TYPE + "." + source, + "source_identity_kind": "stable_symbol", "handler": handler, + "handler_identity": _TYPE + "." + handler + "(object, ...)", + "handler_identity_kind": "stable_symbol", "occurrence_ordinal": 0, + "acquire_span": dict(_SPAN), "teardown": {"status": "none", "candidates": []}, + "allowed_actions": ["convert_acquire", "manual_review"]} + + +def _elig(fid: str, event: str = "_a.PropertyChanged", handler: str = "OnA", + dc: str = "OWN001", source: str = "_a") -> dict: + return {"finding_id": fid, "diagnostic_code": dc, + "bridge_key": {"file": _FILE, "component": "TwoOnOneLine", + "event": event, "handler": handler}, + "record": _record(fid, handler, source)} + + +def _image(all_own001: list, own050: list, eligible: list) -> dict: + return {"all_own001": all_own001, "own050": own050, + "fix_eligible_subscriptions": eligible} + + +def _raises(cat: str, fn, *a) -> bool: + try: + fn(*a) + except fd.DeltaError as exc: + return exc.category == cat + return False + + +def run() -> int: # noqa: C901 — a flat battery of independent assertions + ok = 0 + bad = 0 + + def check(cond: bool, label: str) -> None: + nonlocal ok, bad + if cond: + ok += 1 + else: + bad += 1 + print(f" FAIL: {label}") + + obs_a = _obs("_a.PropertyChanged", "OnA") + obs_b = _obs("_b.PropertyChanged", "OnB") + elig_a = _elig(_FIDA, "_a.PropertyChanged", "OnA", source="_a") + elig_b = _elig(_FIDB, "_b.PropertyChanged", "OnB", source="_b") + + # --- mixed case: convert A gone, manual B preserved ----------------------- + base = _image([obs_a, obs_b], [], [elig_a, elig_b]) + post = _image([obs_b], [], [elig_b]) + res = fd.classify_delta({"convert_acquire_ids": [_FIDA], "manual_review_ids": [_FIDB]}, + base, post) + check(res["delta"]["removed_subscription_own001_ids"] == [_FIDA], + "mixed: removed == convert") + check(res["delta"]["preserved_subscription_own001_ids"] == [_FIDB], + "mixed: preserved == manual") + check(res["delta"]["removed_all_own001"] == [obs_a], "mixed: removed core == R_C") + check(res["delta"]["new_all_own001"] == [], "mixed: no new core") + check(res["semantic_idempotence"]["pass"] is True, "mixed: idempotence passes") + check(res["baseline"]["subscription_own001_ids"] == sorted([_FIDA, _FIDB]), + "mixed: baseline sub ids") + + # --- all-convert: C non-empty, M empty, both leaks removed ---------------- + base_ac = _image([obs_a], [], [elig_a]) + post_ac = _image([], [], []) + res_ac = fd.classify_delta({"convert_acquire_ids": [_FIDA], "manual_review_ids": []}, + base_ac, post_ac) + check(res_ac["delta"]["removed_all_own001"] == [obs_a], "all-convert: removed core == R_C") + + # --- manual-only: C empty, nothing removed -------------------------------- + res_mo = fd.classify_delta({"convert_acquire_ids": [], "manual_review_ids": [_FIDB]}, + _image([obs_b], [], [elig_b]), _image([obs_b], [], [elig_b])) + check(res_mo["delta"]["removed_all_own001"] == [], "manual-only: nothing removed") + + # --- converted still present -> DELTA_MISMATCH ----------------------------- + check(_raises(fd.DELTA_MISMATCH, fd.classify_delta, + {"convert_acquire_ids": [_FIDA], "manual_review_ids": [_FIDB]}, + base, _image([obs_a, obs_b], [], [elig_a, elig_b])), + "converted-still-present -> DELTA_MISMATCH") + + # --- new subscription leak -> NEW_OWN001 ----------------------------------- + obs_c = _obs("_c.PropertyChanged", "OnC") + elig_c = _elig(_FIDC, "_c.PropertyChanged", "OnC", source="_c") + check(_raises(fd.NEW_OWN001, fd.classify_delta, + {"convert_acquire_ids": [_FIDA], "manual_review_ids": [_FIDB]}, + base, _image([obs_b, obs_c], [], [elig_b, elig_c])), + "new subscription leak -> NEW_OWN001") + + # --- new non-subscription (flow-local) OWN001 -> NEW_OWN001 ---------------- + flow = _obs("local", "using", kind="flow-local") + check(_raises(fd.NEW_OWN001, fd.classify_delta, + {"convert_acquire_ids": [], "manual_review_ids": [_FIDB]}, + _image([obs_b], [], [elig_b]), _image([obs_b, flow], [], [elig_b])), + "new flow-local OWN001 -> NEW_OWN001") + + # --- out-of-scope leak vanished -> DELTA_MISMATCH -------------------------- + check(_raises(fd.DELTA_MISMATCH, fd.classify_delta, + {"convert_acquire_ids": [], "manual_review_ids": [_FIDB]}, + _image([obs_b, flow], [], [elig_b]), _image([obs_b], [], [elig_b])), + "out-of-scope leak vanished -> DELTA_MISMATCH") + + # --- new OWN050 -> NEW_OWN050 --------------------------------------------- + check(_raises(fd.NEW_OWN050, fd.classify_delta, + {"convert_acquire_ids": [], "manual_review_ids": [_FIDB]}, + _image([obs_b], [], [elig_b]), _image([obs_b], [_own050()], [elig_b])), + "new OWN050 -> NEW_OWN050") + + # --- bridge: accepted id with no baseline subscription -> ANALYSIS_IDENTITY - + check(_raises(fd.ANALYSIS_IDENTITY, fd.classify_delta, + {"convert_acquire_ids": [_FIDA], "manual_review_ids": [_FIDB]}, + _image([obs_a, obs_b], [], [elig_b]), _image([obs_b], [], [elig_b])), + "unbridged accepted id -> ANALYSIS_IDENTITY") + + # --- bridge: mixed-action under one indistinguishable key -> ANALYSIS_IDENTITY + ea = _elig(_FIDA, "_a.PropertyChanged", "OnA", source="_a") + eb = _elig(_FIDB, "_a.PropertyChanged", "OnA", source="_a") # same bridge key as A + check(_raises(fd.ANALYSIS_IDENTITY, fd.classify_delta, + {"convert_acquire_ids": [_FIDA], "manual_review_ids": [_FIDB]}, + _image([obs_a, _obs("_a.PropertyChanged", "OnA")], [], [ea, eb]), + _image([obs_a], [], [ea])), + "mixed-action shared bridge key -> ANALYSIS_IDENTITY") + + # --- duplicate / non-disjoint expected -> DELTA_MISMATCH ------------------- + check(_raises(fd.DELTA_MISMATCH, fd.classify_delta, + {"convert_acquire_ids": [_FIDA, _FIDA], "manual_review_ids": []}, base, post), + "duplicate expected id -> DELTA_MISMATCH") + check(_raises(fd.DELTA_MISMATCH, fd.classify_delta, + {"convert_acquire_ids": [_FIDA], "manual_review_ids": [_FIDA]}, base, post), + "non-disjoint expected -> DELTA_MISMATCH") + + # --- closed core.json schema ---------------------------------------------- + good_core = {"version": 1, "operation": "verify-subscription-core-observations", + "all_own001": [obs_a], "own050": [], "fix_eligible_subscriptions": [elig_a]} + parsed = fd._parse_core(good_core, fd.BASELINE_ANALYSIS) + check(parsed["all_own001"] == [obs_a], "core.json parses the good shape") + check(_raises(fd.BASELINE_ANALYSIS, fd._parse_core, + {**good_core, "extra": 1}, fd.BASELINE_ANALYSIS), "core.json extra key -> refuse") + bad_obs = {**obs_a} + del bad_obs["advisory"] + check(_raises(fd.BASELINE_ANALYSIS, fd._parse_core, + {**good_core, "all_own001": [bad_obs]}, fd.BASELINE_ANALYSIS), + "core.json missing advisory -> refuse") + check(_raises(fd.BASELINE_ANALYSIS, fd._parse_core, + {**good_core, "all_own001": [{**obs_a, "advisory": "no"}]}, + fd.BASELINE_ANALYSIS), "core.json non-bool advisory -> refuse") + check(_raises(fd.BASELINE_ANALYSIS, fd._parse_core, + {**good_core, "all_own001": [{**obs_a, "file": "/abs/x.cs"}]}, + fd.BASELINE_ANALYSIS), "core.json absolute file -> refuse") + + # --- canonical bytes: sorted keys + trailing newline ---------------------- + ev = fd.canonical_evidence({"b": 1, "a": 2}) + check(ev == b'{"a":2,"b":1}\n', "canonical bytes: sorted keys + trailing newline") + + # --- atomic publication (LA3) --------------------------------------------- + with tempfile.TemporaryDirectory() as tmp: + root = os.path.join(tmp, "root") + pub = os.path.join(tmp, "pub") + os.makedirs(root) + os.makedirs(pub) + out = os.path.join(pub, "evidence") + published = fd._publish_delta(out, root, b'{"ok":true}\n') + names = sorted(os.listdir(out)) + check(names == ["delta-result.json"], "publish leaves only delta-result.json") + with open(os.path.join(out, "delta-result.json"), "rb") as fh: + check(fh.read() == b'{"ok":true}\n', "published bytes are exact") + leftover = [n for n in os.listdir(pub) if n.startswith(".owen-gate-")] + check(leftover == [], "publish leaves no claimed workdir") + # a pre-existing OUTPUT_DIR -> PUBLICATION, and still no workdir residue + check(_raises(fd.PUBLICATION, fd._publish_delta, out, root, b'{}\n'), + "existing out -> PUBLICATION") + check([n for n in os.listdir(pub) if n.startswith(".owen-gate-")] == [], + "failed publish leaves no workdir") + # OUTPUT_DIR resolving inside the source root -> PUBLICATION + inside = os.path.join(root, "evidence") + check(_raises(fd.PUBLICATION, fd._publish_delta, inside, root, b'{}\n'), + "out inside root -> PUBLICATION") + + total = ok + bad + print(f"verify-delta (unit): {ok}/{total} checks pass") + return 1 if bad else 0 + + +if __name__ == "__main__": + raise SystemExit(run()) From c202c3b540bcb1b9f48583bf577428ecc2c4e9ac Mon Sep 17 00:00:00 2001 From: PhysShell Date: Fri, 17 Jul 2026 15:51:29 +0500 Subject: [PATCH 2/9] =?UTF-8?q?feat(s2-step10):=20green=20=E2=80=94=20the?= =?UTF-8?q?=20OWN001-only=20scope=20guard=20+=20exact=20Step=209=20gate=20?= =?UTF-8?q?binding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 2. Adds to ownlang/fix_delta.py: - load_authority(): reuses the frozen validate_gate_authority to restate the plan+candidates authority, then enforces the LA2 OWN001-only scope guard — every accepted candidate must carry diagnostic_code == "OWN001", so a legal S0 OWN014 (capture) candidate, or a mixed OWN001+OWN014 input, fails closed as ANALYSIS_SCOPE BEFORE any analyzer runs. OWN014 delta verification is a future, separate scope. - bind_gate(): the mandatory Step 9 binding. Validates the supplied gate-result.json to the exact frozen eleven-key / ten-gate shape (only the three git gates may be not_applicable, together), reconstructs the expected evidence from THIS plan+candidates+bundle, and requires BOTH semantic equality and canonical bytes. Any deviation is GATE_BINDING. Tests build realistic candidates/plan/gate via the frozen Step 9 producers and cover the OWN001-only guard and the full gate-binding tampering table (fail status, unknown key, missing gate, wrong target, illegitimate not_applicable, split git gates, non-canonical bytes). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G --- ownlang/fix_delta.py | 91 +++++++++++++++++++++++++++- tests/test_verify_delta.py | 119 +++++++++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+), 1 deletion(-) diff --git a/ownlang/fix_delta.py b/ownlang/fix_delta.py index 0bbce565..dc8b2aa6 100644 --- a/ownlang/fix_delta.py +++ b/ownlang/fix_delta.py @@ -35,6 +35,7 @@ from typing import Any from ownlang.fix_gate import ( + GateError, _canonical_bytes, _canonical_json, _claim_workdir, @@ -86,6 +87,22 @@ "occurrence_ordinal", "acquire_span", "teardown", "allowed_actions", ) +# The frozen Step 9 gate-result.json contract (fix_gate._build_evidence): the exact +# eleven top-level keys, the ten gate names, and the three git gates that alone may be +# "not_applicable" (together, exactly for an empty / manual-only patch). +_STEP9_OPERATION = "gate-subscription-fix-bundle" +_STEP9_KEYS = ( + "version", "operation", "input_bundle_sha256", "validated_plan_sha256", + "apply_manifest_sha256", "patch_sha256", "target_api", "source_files", + "applied_findings", "manual_review_findings", "gates", +) +_STEP9_GATE_NAMES = ( + "bundle_layout", "manifest_shape", "authority_binding", "artifact_hashes", + "pristine_preimage", "patch_structure", "git_apply_check", "git_apply", + "postimage_equality", "isolated_tree", +) +_STEP9_GIT_GATES = ("git_apply_check", "git_apply", "isolated_tree") + class DeltaError(Exception): """A controlled refusal, carrying the stable category for regression assertions.""" @@ -361,7 +378,6 @@ def _publish_delta(out: str, root: str, evidence_bytes: bytes) -> str: exactly delta-result.json, so nothing runs after the rename: either OUTPUT_DIR is absent, or it holds the complete canonical delta-result.json. A pre-rename failure removes the workdir; a post-rename failure is impossible because there is none.""" - from ownlang.fix_gate import GateError try: out_phys, _parent_phys, root_phys = _out_parent(out, root) workdir = _claim_workdir(_parent_phys) @@ -401,3 +417,76 @@ def _require_single_delta_file(workdir: str) -> None: def canonical_evidence(evidence: dict[str, Any]) -> bytes: """The published bytes: canonical JSON (sorted keys, compact) + a trailing newline.""" return _canonical_bytes(evidence) + + +# --- authority + OWN001-only scope guard (LA2) + exact Step 9 binding --------------- + + +def load_authority(plan_bytes: bytes, candidates_bytes: bytes) -> tuple[Any, Any, Any]: + """Restate the frozen plan+candidates authority (reusing validate_gate_authority) and + enforce the OWN001-only scope guard (LA2): every accepted candidate must carry + diagnostic_code == 'OWN001'. An OWN014 (or any other) candidate is outside Step 10 and + fails closed as ANALYSIS_SCOPE before any analyzer runs.""" + try: + plan = json.loads(plan_bytes) + candidates = json.loads(candidates_bytes) + except ValueError as exc: + raise DeltaError(AUTHORITY_BINDING, f"plan/candidates is not valid JSON ({exc})") from exc + try: + auth = validate_gate_authority(plan, candidates) + except GateError as exc: # the frozen validator's category IS AUTHORITY_BINDING + raise DeltaError(exc.category, str(exc)) from exc + for candidate in candidates["candidates"]: + if candidate.get("diagnostic_code") != "OWN001": + raise DeltaError(ANALYSIS_SCOPE, + "Step 10 verifies OWN001 subscription candidates only") + return auth, plan, candidates + + +def bind_gate(gate_bytes: bytes, auth: Any, plan_bytes: bytes, manifest_bytes: bytes, + patch_bytes: bytes, pre_sha: str, post_sha: str) -> str: + """Bind the mandatory Step 9 evidence: validate the supplied gate-result.json to the + exact frozen shape, reconstruct the expected object from THIS plan+candidates+bundle, + and require both semantic equality and canonical bytes. Any deviation is GATE_BINDING. + Returns the gate-result sha256 for the evidence.""" + cat = GATE_BINDING + try: + supplied = json.loads(gate_bytes) + except ValueError as exc: + raise DeltaError(cat, f"gate-result.json is not valid JSON ({exc})") from exc + _validate_gate_shape(supplied, cat) + # git gates are not_applicable exactly when the patch is empty (manual-only, C empty) + git_status = "not_applicable" if not auth.applied else "pass" + gates = {n: (git_status if n in _STEP9_GIT_GATES else "pass") for n in _STEP9_GATE_NAMES} + expected = { + "version": 1, + "operation": _STEP9_OPERATION, + "input_bundle_sha256": auth.input_bundle_sha256, + "validated_plan_sha256": _sha_bytes(plan_bytes), + "apply_manifest_sha256": _sha_bytes(manifest_bytes), + "patch_sha256": _sha_bytes(patch_bytes), + "target_api": {"subscribe": auth.target_subscribe}, + "source_files": [{"path": auth.rel, "pre_sha256": pre_sha, "post_sha256": post_sha}], + "applied_findings": list(auth.applied), + "manual_review_findings": list(auth.manual), + "gates": gates, + } + if supplied != expected: + raise DeltaError(cat, "gate-result.json does not reconstruct from plan+candidates+bundle") + if _canonical_bytes(supplied) != gate_bytes: + raise DeltaError(cat, "gate-result.json is not canonical bytes (sorted keys + newline)") + return _sha_bytes(gate_bytes) + + +def _validate_gate_shape(supplied: Any, cat: str) -> None: + if not isinstance(supplied, dict) or set(supplied) != set(_STEP9_KEYS): + raise DeltaError(cat, "gate-result.json is not the exact Step 9 eleven-key set") + gates = supplied["gates"] + if not isinstance(gates, dict) or set(gates) != set(_STEP9_GATE_NAMES): + raise DeltaError(cat, "gate-result.json.gates is not the exact ten-name set") + for name, value in gates.items(): + allowed = ("pass", "not_applicable") if name in _STEP9_GIT_GATES else ("pass",) + if value not in allowed: + raise DeltaError(cat, f"gate-result.json.gates.{name} status {value!r} not allowed") + if len({gates[n] for n in _STEP9_GIT_GATES}) != 1: + raise DeltaError(cat, "gate-result.json git gates disagree (must share one status)") diff --git a/tests/test_verify_delta.py b/tests/test_verify_delta.py index 5172dab1..8189cf41 100644 --- a/tests/test_verify_delta.py +++ b/tests/test_verify_delta.py @@ -14,6 +14,7 @@ from __future__ import annotations +import hashlib import json import os import sys @@ -22,6 +23,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from ownlang import fix_delta as fd +from ownlang.fix_gate import _build_evidence, _bundle_sha256, validate_gate_authority _EV = "System.ComponentModel.INotifyPropertyChanged.PropertyChanged" _FILE = "Own/Sample.cs" @@ -80,6 +82,56 @@ def _raises(cat: str, fn, *a) -> bool: return False +# --- realistic plan / candidates / gate builders (slice 2) ------------------------- + +_REL = "Own/Sample.cs" +_PRE = b"class A\n{\n void M()\n {\n p.PropertyChanged += OnX;\n }\n}\n" +_POST = _PRE.replace(b"p.PropertyChanged += OnX;", b"WeakEvents.AddPropertyChanged(p, OnX);") + + +def _sha(data: bytes) -> str: + return "sha256:" + hashlib.sha256(data).hexdigest() + + +def _bcand(fid: str, start: int, dc: str = "OWN001", + contract: str = "inotify_property_changed", actions: list | None = None) -> dict: + return {"finding_id": fid, "diagnostic_code": dc, "containing_type": "N.A", "file": _REL, + "enclosing_member": "N.A..ctor(N.IPub)", "event": "PropertyChanged", + "event_identity": _EV, "event_contract": contract, "source": "p", + "source_identity": "p", "source_identity_kind": "computed", "handler": "OnX", + "handler_identity": "N.A.OnX(object, ...)", "handler_identity_kind": "stable_symbol", + "occurrence_ordinal": 0, + "acquire_span": {"start": start, "length": 10, "start_line": 5, + "start_column": 9, "end_line": 5, "end_column": 19}, + "teardown": {"status": "none", "candidates": []}, + "allowed_actions": actions or ["convert_acquire", "manual_review"]} + + +def _bcands(cands: list) -> dict: + return {"version": 1, "operation": "fix-subscriptions", + "target_api": {"subscribe": "WeakEvents.AddPropertyChanged"}, + "selection": {"allowed_types": [{"full_name": "N.A", "file": _REL}], + "selected_findings": None, + "constraints": {"max_types_changed": 1, "max_files_changed": 1, + "allow_helper_changes": False, + "allow_config_changes": False, + "allow_suppressions": False}}, + "source_files": [{"path": _REL, "sha256": _sha(_PRE)}], "candidates": cands} + + +def _bplan(cands: dict, actions: list) -> dict: + return {"version": 1, "operation": "fix-subscriptions", + "input_bundle_sha256": _bundle_sha256(cands), + "target_api": {"subscribe": cands["target_api"]["subscribe"]}, + "selection": {"allowed_types": [dict(cands["selection"]["allowed_types"][0])], + "selected_findings": cands["selection"]["selected_findings"], + "constraints": dict(cands["selection"]["constraints"])}, + "source_files": [dict(cands["source_files"][0])], + "decisions": [{"finding_id": c["finding_id"], "action": actions[i], + "file": c["file"], "acquire_span": c["acquire_span"]} + for i, c in enumerate(cands["candidates"])]} + + def run() -> int: # noqa: C901 — a flat battery of independent assertions ok = 0 bad = 0 @@ -227,6 +279,73 @@ def check(cond: bool, label: str) -> None: check(_raises(fd.PUBLICATION, fd._publish_delta, inside, root, b'{}\n'), "out inside root -> PUBLICATION") + # --- slice 2: authority OWN001-only guard + exact Step 9 gate binding ------ + import copy + fid1 = "OWN001:sha256:" + "1" * 64 + fid2 = "OWN050:sha256:" + "2" * 64 # a valid finding_id label; diagnostic_code is what matters + cands = _bcands([_bcand(fid1, 40), + _bcand(fid2, 80, contract="name_only", actions=["manual_review"])]) + plan = _bplan(cands, ["convert_acquire", "manual_review"]) + auth, _p, _c = fd.load_authority(json.dumps(plan).encode(), json.dumps(cands).encode()) + check(auth.applied == [fid1] and auth.manual == [fid2], "authority: OWN001 candidates load") + + cands014 = _bcands([_bcand(fid1, 40, dc="OWN014", contract="name_only", + actions=["manual_review"])]) + plan014 = _bplan(cands014, ["manual_review"]) + check(_raises(fd.ANALYSIS_SCOPE, fd.load_authority, + json.dumps(plan014).encode(), json.dumps(cands014).encode()), + "OWN014 candidate -> ANALYSIS_SCOPE") + + mani, patch = b"manifest-bytes", b"patch-bytes" + plan_bytes = json.dumps(plan).encode() + pre_sha, post_sha = _sha(_PRE), _sha(_POST) + gate_bytes = _build_evidence(auth, _REL, plan_bytes, mani, patch, pre_sha, post_sha, "pass") + + def bg(gb: bytes): + return fd.bind_gate(gb, auth, plan_bytes, mani, patch, pre_sha, post_sha) + + check(bg(gate_bytes) == fd._sha_bytes(gate_bytes), "gate: frozen evidence binds") + + # manual-only: the three git gates are not_applicable, C empty + cands_m = _bcands([_bcand(fid2, 80, contract="name_only", actions=["manual_review"])]) + plan_m = _bplan(cands_m, ["manual_review"]) + auth_m, _, _ = fd.load_authority(json.dumps(plan_m).encode(), json.dumps(cands_m).encode()) + plan_m_bytes = json.dumps(plan_m).encode() + gate_m = _build_evidence(auth_m, _REL, plan_m_bytes, mani, patch, pre_sha, post_sha, + "not_applicable") + check(bool(fd.bind_gate(gate_m, auth_m, plan_m_bytes, mani, patch, pre_sha, post_sha)), + "gate: manual-only not_applicable binds") + + good = json.loads(gate_bytes) + + def _canon(obj) -> bytes: + return (json.dumps(obj, sort_keys=True, separators=(",", ":"), + ensure_ascii=False).encode("utf-8") + b"\n") + + def tam(mut) -> bytes: + g = copy.deepcopy(good) + mut(g) + return _canon(g) + + check(_raises(fd.GATE_BINDING, bg, + tam(lambda g: g["gates"].__setitem__("bundle_layout", "fail"))), + "gate: fail status -> GATE_BINDING") + check(_raises(fd.GATE_BINDING, bg, tam(lambda g: g.__setitem__("surprise", 1))), + "gate: unknown top-level key -> GATE_BINDING") + check(_raises(fd.GATE_BINDING, bg, tam(lambda g: g["gates"].pop("git_apply"))), + "gate: missing gate -> GATE_BINDING") + check(_raises(fd.GATE_BINDING, bg, + tam(lambda g: g["target_api"].__setitem__("subscribe", "X.Y"))), + "gate: wrong target -> GATE_BINDING") + check(_raises(fd.GATE_BINDING, bg, + tam(lambda g: g["gates"].__setitem__("bundle_layout", "not_applicable"))), + "gate: not_applicable on a non-git gate -> GATE_BINDING") + check(_raises(fd.GATE_BINDING, bg, + tam(lambda g: g["gates"].__setitem__("git_apply", "not_applicable"))), + "gate: split git-gate statuses -> GATE_BINDING") + check(_raises(fd.GATE_BINDING, bg, json.dumps(good, indent=2).encode("utf-8") + b"\n"), + "gate: non-canonical bytes -> GATE_BINDING") + total = ok + bad print(f"verify-delta (unit): {ok}/{total} checks pass") return 1 if bad else 0 From 545960a8286bc2c708ffb697d06a34a24d1f9e3e Mon Sep 17 00:00:00 2001 From: PhysShell Date: Fri, 17 Jul 2026 15:57:32 +0500 Subject: [PATCH 3/9] =?UTF-8?q?feat(s2-step10):=20green=20=E2=80=94=20snap?= =?UTF-8?q?shotted=20core=20subprocess,=20runner=20fingerprint,=20baseline?= =?UTF-8?q?=20authority?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slices 3-5. Adds to ownlang/fix_delta.py the fresh, isolated core-analyzer subprocess and its bindings: - RUN_CORE_SOURCE: the deterministic runner. It runs under `python -S -B -E` with the snapshotted ownlang package on sys.path[0], self-fingerprints after import (every ownlang module must resolve physically inside the snapshot, else exit 3), runs the real check_facts + collect_candidates, and emits the closed core.json (all_own001 / own050 / fix_eligible_subscriptions). - materialize_core(): snapshots the whole ownlang package (excluding __pycache__ / *.pyc / *.pyo) into WORK/core/ownlang, writes the runner, and hashes its bytes as core_runner_sha256, verified immediately (LA1). Returns the core fingerprint (ownlang_manifest_sha256, ownlang_files, runner sha). - resolve_python(): snapshots + identifies sys.executable (LA5). - run_core(): re-verifies the runner bytes before launch (LA1), runs the subprocess, and parses the schema-checked core.json — exit 3 is TOOLCHAIN_BINDING (core_analyzer_binding), any other failure is the per-image BASELINE_ANALYSIS / POSTIMAGE_ANALYSIS. - check_baseline_authority(): every accepted candidate reproduced exactly over the baseline for all 18 authoritative fields, else ANALYSIS_SCOPE. - check_target_identity(): every finding carries file == the target rel. The fixture test drives the REAL subprocess (no dotnet) end-to-end over synthetic --fix-candidates facts: the mixed delta (OnA converted, OnB preserved), baseline-authority mismatch, malformed facts, and a runner mutation caught before launch. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G --- ownlang/fix_delta.py | 262 +++++++++++++++++++++++++++++++++++++ tests/test_verify_delta.py | 141 +++++++++++++++++++- 2 files changed, 402 insertions(+), 1 deletion(-) diff --git a/ownlang/fix_delta.py b/ownlang/fix_delta.py index dc8b2aa6..45702ace 100644 --- a/ownlang/fix_delta.py +++ b/ownlang/fix_delta.py @@ -29,8 +29,11 @@ import json import os +import platform import shutil import stat +import subprocess +import sys from collections import Counter from typing import Any @@ -490,3 +493,262 @@ def _validate_gate_shape(supplied: Any, cat: str) -> None: raise DeltaError(cat, f"gate-result.json.gates.{name} status {value!r} not allowed") if len({gates[n] for n in _STEP9_GIT_GATES}) != 1: raise DeltaError(cat, "gate-result.json git gates disagree (must share one status)") + + +# --- the fresh, snapshotted core-analyzer subprocess (LA1 / LA4 / LA5) -------------- + +# The deterministic runner materialized at WORK_ROOT/core/run_core.py. Its bytes are +# hashed as core_runner_sha256; it is NOT part of the ownlang fingerprint. It runs under +# `python -S -B -E` with the snapshotted ownlang package on sys.path[0], so the fingerprinted +# bytes are the bytes that actually produce the verdict. It self-fingerprints after import +# (every ownlang module must resolve physically inside the snapshot) and exits 3 on failure. +RUN_CORE_SOURCE = r'''"""Step 10 core runner — runs inside a fresh, isolated subprocess over the +snapshotted ownlang package on sys.path[0] and emits canonical core.json.""" +import json +import os +import sys + + +def _fail_toolchain(msg): + sys.stderr.write("run_core: toolchain: " + msg + "\n") + raise SystemExit(3) + + +def _fail_analysis(msg): + sys.stderr.write("run_core: analysis: " + msg + "\n") + raise SystemExit(4) + + +def _phys_inside(child, parent): + c = os.path.normcase(os.path.realpath(child)) + p = os.path.normcase(os.path.realpath(parent)) + return c == p or c.startswith(p + os.sep) + + +def main(): + if len(sys.argv) != 4: + _fail_toolchain("usage: run_core.py FACTS CORE_OUT PARAMS") + facts_path, out_path, params_path = sys.argv[1], sys.argv[2], sys.argv[3] + pkg = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ownlang") + try: + import ownlang + import ownlang.__main__ + import ownlang.fix_candidates + import ownlang.ownir + except Exception as exc: + _fail_toolchain("cannot import snapshotted ownlang: " + repr(exc)) + for mod in (ownlang, ownlang.ownir, ownlang.__main__, ownlang.fix_candidates): + f = getattr(mod, "__file__", None) + if not f or not os.path.isfile(f) or not _phys_inside(f, pkg): + _fail_toolchain("import escaped the snapshot: " + str(getattr(mod, "__name__", "?"))) + try: + with open(params_path, "rb") as fh: + params = json.load(fh) + with open(facts_path, "rb") as fh: + facts = json.load(fh) + except Exception as exc: + _fail_analysis("cannot read inputs: " + repr(exc)) + try: + findings = ownlang.ownir.check_facts(facts) + except Exception as exc: + _fail_analysis("check_facts failed: " + repr(exc)) + all_own001 = [] + own050 = [] + for fnd in findings: + if fnd.code == "OWN001": + all_own001.append({"file": fnd.file, "code": "OWN001", "component": fnd.component, + "event": fnd.event, "handler": fnd.handler, "kind": fnd.kind, + "advisory": bool(fnd.advisory), "severity": fnd.severity, + "ignore_reason": fnd.ignore_reason}) + elif fnd.code == "OWN050": + own050.append({"file": fnd.file, "component": fnd.component, + "event": fnd.event, "handler": fnd.handler}) + try: + env = ownlang.fix_candidates.collect_candidates( + facts, params["target_subscribe"], params["class_fqn"], None, params["root"]) + except Exception as exc: + _fail_analysis("collect_candidates failed: " + repr(exc)) + fix_eligible = [] + for c in env["candidates"]: + src = c["source"] + full = c["event"] if src == "this" else (src + "." + c["event"]) + fix_eligible.append({"finding_id": c["finding_id"], + "diagnostic_code": c["diagnostic_code"], + "bridge_key": {"file": c["file"], + "component": c["containing_type"].rsplit(".", 1)[-1], + "event": full, "handler": c["handler"]}, + "record": c}) + core = {"version": 1, "operation": "verify-subscription-core-observations", + "all_own001": all_own001, "own050": own050, + "fix_eligible_subscriptions": fix_eligible} + data = json.dumps(core, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + with open(out_path, "w", encoding="utf-8") as fh: + fh.write(data + "\n") + + +if __name__ == "__main__": + main() +''' + + +def _walk_pkg(pkg_src: str) -> list[str]: + """Every regular package file, canonical '/'-relative, sorted by byte order — except + __pycache__ and compiled .pyc/.pyo. Rejects any symlink/reparse or non-regular entry.""" + out: list[str] = [] + for dirpath, dirnames, filenames in os.walk(pkg_src): + dirnames[:] = [d for d in dirnames if d != "__pycache__"] + for d in dirnames: + if _is_link(os.lstat(os.path.join(dirpath, d))): + raise DeltaError(TOOLCHAIN_BINDING, "ownlang has a symlinked subdirectory") + for fn in filenames: + if fn.endswith((".pyc", ".pyo")): + continue + full = os.path.join(dirpath, fn) + st = os.lstat(full) + if _is_link(st): + raise DeltaError(TOOLCHAIN_BINDING, f"ownlang/{fn} is a symlink / reparse point") + if not stat.S_ISREG(st.st_mode): + raise DeltaError(TOOLCHAIN_BINDING, f"ownlang/{fn} is not a regular file") + out.append(os.path.relpath(full, pkg_src).replace(os.sep, "/")) + out.sort() + return out + + +def materialize_core(work: str) -> tuple[str, str, str, dict[str, Any]]: + """Snapshot the installed ownlang package verbatim into WORK/core/ownlang and write the + deterministic runner WORK/core/run_core.py. Returns (core_dir, runner_path, + core_runner_sha256, core_fingerprint). The runner bytes are verified immediately after + materialization (LA1).""" + import ownlang as _own + + own_file = getattr(_own, "__file__", None) + if not own_file: + raise DeltaError(TOOLCHAIN_BINDING, "cannot locate the ownlang package") + pkg_src = os.path.dirname(os.path.abspath(own_file)) + if _is_link(os.lstat(pkg_src)): + raise DeltaError(TOOLCHAIN_BINDING, "the ownlang package root is a link") + core_dir = os.path.join(work, "core") + pkg_dst = os.path.join(core_dir, "ownlang") + os.makedirs(pkg_dst) + manifest: list[dict[str, str]] = [] + for rel in _walk_pkg(pkg_src): + data = _snapshot(os.path.join(pkg_src, rel.replace("/", os.sep)), + TOOLCHAIN_BINDING, f"ownlang/{rel}") + dst = os.path.join(pkg_dst, rel.replace("/", os.sep)) + os.makedirs(os.path.dirname(dst), exist_ok=True) + with open(dst, "wb") as fh: + fh.write(data) + manifest.append({"path": rel, "sha256": _sha_bytes(data)}) + manifest.sort(key=lambda m: m["path"]) + runner_path = os.path.join(core_dir, "run_core.py") + runner_bytes = RUN_CORE_SOURCE.encode("utf-8") + with open(runner_path, "wb") as fh: + fh.write(runner_bytes) + core_runner_sha256 = _sha_bytes(runner_bytes) + _verify_runner(runner_path, core_runner_sha256) + fingerprint = { + "ownlang_manifest_sha256": _sha_bytes(_canonical_json(manifest)), + "ownlang_files": manifest, + "core_runner_sha256": core_runner_sha256, + } + return core_dir, runner_path, core_runner_sha256, fingerprint + + +def _verify_runner(runner_path: str, expected_sha: str) -> None: + """Re-read and re-hash the materialized runner; a change is TOOLCHAIN_BINDING (LA1).""" + data = _snapshot(runner_path, TOOLCHAIN_BINDING, "run_core.py") + if _sha_bytes(data) != expected_sha: + raise DeltaError(TOOLCHAIN_BINDING, "core runner bytes changed (core_runner_sha256)") + + +def resolve_python() -> tuple[str, dict[str, Any]]: + """Resolve, snapshot, and identify the Python executable that runs the fresh core + subprocess (LA5). A missing / non-regular interpreter is TOOLCHAIN_BINDING.""" + exe = sys.executable + if not exe: + raise DeltaError(TOOLCHAIN_BINDING, "no Python executable to run the core subprocess") + data = _snapshot(exe, TOOLCHAIN_BINDING, "python executable") + return exe, { + "python_executable_sha256": _sha_bytes(data), + "python_implementation": sys.implementation.name, + "python_version": platform.python_version(), + "python_cache_tag": sys.implementation.cache_tag or "unknown", + } + + +def _core_env(image_dir: str) -> dict[str, str]: + """A minimal environment for the core subprocess. `-E` ignores every PYTHON* variable; + only the host bits the interpreter needs to start are forwarded, and the caches / temp + are redirected into the image workspace.""" + env: dict[str, str] = {} + for k in ("SystemRoot", "SYSTEMROOT", "windir", "PATH", "HOME", "LANG", "LC_ALL"): + if k in os.environ: + env[k] = os.environ[k] + env["TMPDIR"] = image_dir + env["TEMP"] = image_dir + env["TMP"] = image_dir + return env + + +def run_core(core_dir: str, runner_path: str, python_exe: str, core_runner_sha256: str, + image_dir: str, facts_bytes: bytes, params: dict[str, Any], + cat: str) -> dict[str, Any]: + """Run the fresh core subprocess over `facts_bytes` and return the parsed, schema-checked + core.json. Re-verifies the runner bytes before launch (LA1). A self-fingerprint / import + escape (exit 3) is TOOLCHAIN_BINDING; any other failure or a malformed core.json is the + per-image analysis category `cat`.""" + _verify_runner(runner_path, core_runner_sha256) + facts_path = os.path.join(image_dir, "facts.json") + core_path = os.path.join(image_dir, "core.json") + params_path = os.path.join(image_dir, "params.json") + with open(facts_path, "wb") as fh: + fh.write(facts_bytes) + with open(params_path, "wb") as fh: + fh.write(_canonical_json(params)) + proc = subprocess.run( + [python_exe, "-S", "-B", "-E", runner_path, facts_path, core_path, params_path], + cwd=core_dir, env=_core_env(image_dir), capture_output=True, text=True, check=False) + if proc.returncode == 3: + raise DeltaError(TOOLCHAIN_BINDING, + f"core runner self-fingerprint failed: {proc.stderr.strip()[:300]}") + if proc.returncode != 0: + raise DeltaError(cat, f"core analyzer failed (rc={proc.returncode}): " + f"{proc.stderr.strip()[:300]}") + try: + with open(core_path, "rb") as fh: + raw = json.loads(fh.read()) + except (OSError, ValueError) as exc: + raise DeltaError(cat, f"core.json is unreadable ({exc})") from exc + return _parse_core(raw, cat) + + +def check_baseline_authority(candidates: dict[str, Any], base_core: dict[str, Any]) -> None: + """Bind the declared closure to the accepted S0 candidates (LA/§8): every accepted + candidate must be reproduced EXACTLY over the baseline for all 18 authoritative fields. + A mismatch is ANALYSIS_SCOPE — it proves the declared closure is semantically compatible + with the accepted S0 authority, not that it is historically identical.""" + recomputed = {e["finding_id"]: e["record"] + for e in base_core["fix_eligible_subscriptions"]} + for c in candidates["candidates"]: + fid = c["finding_id"] + rec = recomputed.get(fid) + if rec is None: + raise DeltaError(ANALYSIS_SCOPE, + f"accepted candidate {fid} is not reproduced over the baseline") + for field in _AUTHORITY_FIELDS: + if c[field] != rec[field]: + raise DeltaError(ANALYSIS_SCOPE, + f"baseline-authority mismatch on '{field}' for {fid}") + + +def check_target_identity(core: dict[str, Any], rel: str, cat: str) -> None: + """Every finding used by Step 10 must carry file == the declared target rel (LA/§3).""" + for o in core["all_own001"]: + if o["file"] != rel: + raise DeltaError(cat, f"OWN001 attributed to {o['file']!r} != target {rel!r}") + for o in core["own050"]: + if o["file"] != rel: + raise DeltaError(cat, f"OWN050 attributed to {o['file']!r} != target {rel!r}") + for e in core["fix_eligible_subscriptions"]: + if e["record"]["file"] != rel or e["bridge_key"]["file"] != rel: + raise DeltaError(cat, f"candidate attributed to a file != target {rel!r}") diff --git a/tests/test_verify_delta.py b/tests/test_verify_delta.py index 8189cf41..7b61a57a 100644 --- a/tests/test_verify_delta.py +++ b/tests/test_verify_delta.py @@ -132,6 +132,138 @@ def _bplan(cands: dict, actions: list) -> dict: for i, c in enumerate(cands["candidates"])]} +# --- facts builders for the real fresh-core-subprocess fixture (slices 3-5) --------- + +_CTYPE = "Own.Samples.TwoOnOneLine" +_CREL = "Own/Samples/TwoOnOneLine.cs" + + +def _cfix(handler: str = "OnA", source: str = "_a", ordinal: int = 0, start: int = 100) -> dict: + return {"enclosing_member": _CTYPE + ".ctor()", "event_identity": _EV, + "event_contract": "inotify_property_changed", + "source_identity": _CTYPE + "." + source, "source_identity_kind": "stable_symbol", + "handler_identity": _CTYPE + "." + handler + "(object, ...)", + "handler_identity_kind": "stable_symbol", "occurrence_ordinal": ordinal, + "span": {"start": start, "length": 30, "start_line": 10, "start_column": 1, + "end_line": 10, "end_column": 31}, + "teardown": {"status": "none", "candidates": []}} + + +def _csub(fix: dict, event: str, handler: str) -> dict: + return {"event": event, "handler": handler, "line": 10, "released": False, + "resource": "subscription", "source": "injected", "lambda": False, "fix": fix} + + +def _cfacts(subs: list) -> dict: + comp = {"name": _CTYPE.rsplit(".", 1)[-1], "qualified_name": _CTYPE, "is_partial": False, + "is_nested": False, "declaration_count": 1, "is_generated": False, + "file": _CREL, "subscriptions": subs} + return {"ownir_version": 0, "fix_candidates_version": 1, "components": [comp]} + + +def _mk_root(work: str) -> str: + d = tempfile.mkdtemp(dir=work) + src = os.path.join(d, *_CREL.split("/")) + os.makedirs(os.path.dirname(src), exist_ok=True) + with open(src, "wb") as fh: + fh.write(b"// sample\nnamespace Own.Samples { class TwoOnOneLine {} }\n") + return d + + +def _candidates_from(records: list) -> dict: + return {"version": 1, "operation": "fix-subscriptions", + "target_api": {"subscribe": "WeakEvents.AddPropertyChanged"}, + "selection": {"allowed_types": [{"full_name": _CTYPE, "file": _CREL}], + "selected_findings": None, + "constraints": {"max_types_changed": 1, "max_files_changed": 1, + "allow_helper_changes": False, + "allow_config_changes": False, + "allow_suppressions": False}}, + "source_files": [{"path": _CREL, "sha256": "sha256:" + "0" * 64}], + "candidates": list(records)} + + +def _fixture_core_fails() -> tuple[int, list[str]]: + """Drive the REAL fresh core subprocess (snapshotted ownlang, python -S -B -E) over + synthetic --fix-candidates facts. No dotnet. Proves LA1 (runner fingerprint + verify), + LA4 (closed core.json), the analyzer-to-id bridge, and baseline authority end to end.""" + checks = 0 + fails: list[str] = [] + + def cf(cond: bool, label: str) -> None: + nonlocal checks + checks += 1 + if not cond: + fails.append(label) + + target = "WeakEvents.AddPropertyChanged" + with tempfile.TemporaryDirectory() as work: + core_dir, runner_path, runner_sha, core_fp = fd.materialize_core(work) + py, pyfp = fd.resolve_python() + cf(core_fp["core_runner_sha256"] == fd._sha_bytes(fd.RUN_CORE_SOURCE.encode("utf-8")), + "fixture: core_runner_sha256 == hash of RUN_CORE_SOURCE") + cf(len(pyfp["python_executable_sha256"]) == len("sha256:") + 64, + "fixture: python executable fingerprinted") + + base_facts = _cfacts([_csub(_cfix("OnA", "_a", 0, 100), "_a.PropertyChanged", "OnA"), + _csub(_cfix("OnB", "_b", 1, 200), "_b.PropertyChanged", "OnB")]) + post_facts = _cfacts([_csub(_cfix("OnB", "_b", 1, 200), "_b.PropertyChanged", "OnB")]) + base_root, post_root = _mk_root(work), _mk_root(work) + + def params(root: str) -> dict: + return {"root": root, "target_subscribe": target, "class_fqn": _CTYPE} + + base = fd.run_core(core_dir, runner_path, py, runner_sha, base_root, + json.dumps(base_facts).encode(), params(base_root), fd.BASELINE_ANALYSIS) + post = fd.run_core(core_dir, runner_path, py, runner_sha, post_root, + json.dumps(post_facts).encode(), params(post_root), + fd.POSTIMAGE_ANALYSIS) + cf(len(base["all_own001"]) == 2, "fixture: baseline has 2 OWN001") + cf(len(post["all_own001"]) == 1, "fixture: postimage has 1 OWN001") + + recs = {e["record"]["handler"]: e for e in base["fix_eligible_subscriptions"]} + cf(set(recs) == {"OnA", "OnB"}, "fixture: two fix-eligible subscriptions") + fid_a, fid_b = recs["OnA"]["finding_id"], recs["OnB"]["finding_id"] + candidates = _candidates_from([recs["OnA"]["record"], recs["OnB"]["record"]]) + + fd.check_baseline_authority(candidates, base) + fd.check_target_identity(base, _CREL, fd.BASELINE_ANALYSIS) + fd.check_target_identity(post, _CREL, fd.POSTIMAGE_ANALYSIS) + res = fd.classify_delta({"convert_acquire_ids": [fid_a], "manual_review_ids": [fid_b]}, + base, post) + cf(res["delta"]["removed_subscription_own001_ids"] == [fid_a], "fixture: OnA removed") + cf(res["delta"]["preserved_subscription_own001_ids"] == [fid_b], "fixture: OnB preserved") + cf(len(res["delta"]["removed_all_own001"]) == 1, "fixture: exactly one core removed") + + # baseline-authority mismatch -> ANALYSIS_SCOPE + import copy + bad_c = copy.deepcopy(candidates) + bad_c["candidates"][0]["enclosing_member"] = "N.Other.ctor()" + cf(_raises(fd.ANALYSIS_SCOPE, fd.check_baseline_authority, bad_c, base), + "fixture: baseline-authority mismatch -> ANALYSIS_SCOPE") + + # malformed facts -> BASELINE_ANALYSIS (the runner exits 4) + junk_root = _mk_root(work) + try: + fd.run_core(core_dir, runner_path, py, runner_sha, junk_root, b"{ not json", + params(junk_root), fd.BASELINE_ANALYSIS) + cf(False, "fixture: malformed facts -> BASELINE_ANALYSIS") + except fd.DeltaError as exc: + cf(exc.category == fd.BASELINE_ANALYSIS, "fixture: malformed facts -> BASELINE") + + # runner mutation -> TOOLCHAIN_BINDING (LA1), checked before launch (do this LAST) + with open(runner_path, "ab") as fh: + fh.write(b"# tamper\n") + try: + fd.run_core(core_dir, runner_path, py, runner_sha, base_root, + json.dumps(base_facts).encode(), params(base_root), fd.BASELINE_ANALYSIS) + cf(False, "fixture: runner mutation -> TOOLCHAIN_BINDING") + except fd.DeltaError as exc: + cf(exc.category == fd.TOOLCHAIN_BINDING, "fixture: runner mutation -> TOOLCHAIN") + + return checks, fails + + def run() -> int: # noqa: C901 — a flat battery of independent assertions ok = 0 bad = 0 @@ -346,8 +478,15 @@ def tam(mut) -> bytes: check(_raises(fd.GATE_BINDING, bg, json.dumps(good, indent=2).encode("utf-8") + b"\n"), "gate: non-canonical bytes -> GATE_BINDING") + # --- slices 3-5: the real fresh core subprocess (no dotnet) --------------- + fchecks, ffails = _fixture_core_fails() + ok += fchecks - len(ffails) + bad += len(ffails) + for f in ffails: + print(f" FAIL: {f}") + total = ok + bad - print(f"verify-delta (unit): {ok}/{total} checks pass") + print(f"verify-delta (unit + fixture): {ok}/{total} checks pass") return 1 if bad else 0 From 04a64b287e80a23fb3bccbd390420230934a2d8e Mon Sep 17 00:00:00 2001 From: PhysShell Date: Fri, 17 Jul 2026 16:03:11 +0500 Subject: [PATCH 4/9] =?UTF-8?q?feat(s2-step10):=20green=20=E2=80=94=20extr?= =?UTF-8?q?actor/runtime=20toolchain,=20reference=20closure,=20orchestrati?= =?UTF-8?q?on=20+=20CLI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slices 6-9. Completes the ownlang/fix_delta.py pipeline and wires the CLI: - snapshot_reference_closure(): ordered one-DLL-per-slot materialization (caller-dir order then canonical byte order), so first-simple-name-wins is preserved and filesystem enumeration order never leaks into the analyzer; ordered reference_closure evidence. - snapshot_extractor_deployment(): TOCTOU-closed snapshot of the whole deployment into WORK/toolchain, executes the COPY; ordered manifest. - resolve_runtime(): proves the EXACT requested runtime is installed under DOTNET_ROLL_FORWARD=Disable (dotnet --list-runtimes), hashes its manifest, and returns {framework_name, framework_version, tfm, runtime_manifest_sha256} plus dotnet_version + dotnet_host_sha256 (LA D4). - extract_image(): the fixed `dotnet exec extract ... --fix-candidates --weak-subscribe --ref-dir ...` per image. - build_evidence(): the full delta-result.json (exact seventeen check names). - run_verify_delta(): the hermetic orchestration — snapshot inputs, restate authority + OWN001-only guard, bind the step 9 evidence, snapshot the extractor + core + python + runtime, analyze preimage/postimage in isolated workspaces (runner re-verified before each and after the last, LA1), verify the delta, and publish atomically. Adds the `own-fix subscriptions verify-delta` CLI verb (--gate mandatory, no --config, repeated singleton flags rejected) and the single spec/CLI.md row. Offline tests cover the reference-closure ordering, evidence assembly (seventeen checks), and bundle layout; the extractor path itself is the Tier-B CI job. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G --- ownlang/__main__.py | 48 +++++- ownlang/fix_delta.py | 328 +++++++++++++++++++++++++++++++++++++ spec/CLI.md | 1 + tests/test_verify_delta.py | 44 +++++ 4 files changed, 418 insertions(+), 3 deletions(-) diff --git a/ownlang/__main__.py b/ownlang/__main__.py index 92795028..69be5d52 100644 --- a/ownlang/__main__.py +++ b/ownlang/__main__.py @@ -631,11 +631,51 @@ def _cmd_gate(rest: list[str]) -> int: return 0 +def _cmd_verify_delta(rest: list[str]) -> int: + """S2 step 10: `own-fix subscriptions verify-delta` — the analyzer-semantic gate over an + accepted step 8 bundle. Re-runs Own.NET's real core analyzer (from a snapshotted ownlang + package in a fresh, isolated subprocess) over the pristine preimage and the accepted + postimage and proves the OWN001 delta matches the plan (converted gone, manual preserved, + no new OWN001 of any lane, no new OWN050). --gate is mandatory; there is no --config.""" + from ownlang.fix_delta import DeltaError, run_verify_delta + + flags = {"--bundle", "--plan", "--candidates", "--root", "--gate", "--extractor-dll", "--out"} + parsed = _own_fix_parse(rest, flags, {"--ref-dir"}) + if parsed is None: + return 2 + positional, opts = parsed + # a repeated singleton flag is an input error, not a silent last-wins (LA F11) + for f in flags: + if rest.count(f) > 1: + print(f"own-fix: {f} given more than once", file=sys.stderr) + return 2 + if positional or not all(opts.get(k) for k in flags): + print("usage: own-fix subscriptions verify-delta --bundle " + "--plan --candidates " + "--root --gate " + "--extractor-dll --out " + "[--ref-dir ]...", file=sys.stderr) + return 2 + try: + published = run_verify_delta( + opts["--bundle"], opts["--plan"], opts["--candidates"], opts["--root"], + opts["--gate"], opts["--extractor-dll"], opts["--out"], opts.get("--ref-dir") or []) + except DeltaError as exc: + print(f"own-fix: refuse: {exc.category}: {exc}", file=sys.stderr) + return 2 + except Exception as exc: # fail closed: any surprise is a refusal, not a traceback + print(f"own-fix: refuse: INFRASTRUCTURE: internal error " + f"({type(exc).__name__}: {exc})", file=sys.stderr) + return 2 + print(f"own-fix: wrote delta-result.json -> {published}") + return 0 + + def cmd_own_fix(rest: list[str]) -> int: - """`own-fix subscriptions {candidates|render|validate-plan|apply} ...`.""" + """`own-fix subscriptions {candidates|render|validate-plan|apply|gate|verify-delta} ...`.""" if len(rest) < 2 or rest[0] != "subscriptions": print("usage: python -m ownlang own-fix subscriptions " - "{candidates|render|validate-plan|apply} ...", file=sys.stderr) + "{candidates|render|validate-plan|apply|gate|verify-delta} ...", file=sys.stderr) return 2 verb, args = rest[1], rest[2:] if verb == "candidates": @@ -648,8 +688,10 @@ def cmd_own_fix(rest: list[str]) -> int: return _cmd_apply(args) if verb == "gate": return _cmd_gate(args) + if verb == "verify-delta": + return _cmd_verify_delta(args) print(f"own-fix: unknown subcommand {verb!r} " - "(candidates | render | validate-plan | apply | gate)", file=sys.stderr) + "(candidates | render | validate-plan | apply | gate | verify-delta)", file=sys.stderr) return 2 diff --git a/ownlang/fix_delta.py b/ownlang/fix_delta.py index 45702ace..c143eccb 100644 --- a/ownlang/fix_delta.py +++ b/ownlang/fix_delta.py @@ -34,6 +34,7 @@ import stat import subprocess import sys +import tempfile from collections import Counter from typing import Any @@ -752,3 +753,330 @@ def check_target_identity(core: dict[str, Any], rel: str, cat: str) -> None: for e in core["fix_eligible_subscriptions"]: if e["record"]["file"] != rel or e["bridge_key"]["file"] != rel: raise DeltaError(cat, f"candidate attributed to a file != target {rel!r}") + + +# --- the extractor toolchain + hermetic reference/runtime snapshots (LA2/LA4/LA5) -- + + +def _walk_regular_files(root: str, cat: str, suffix: str | None = None) -> list[str]: + """Every regular file under `root` (optionally by suffix), canonical '/'-relative, + sorted by byte order. Rejects any symlink/reparse or non-regular entry.""" + out: list[str] = [] + for dirpath, dirnames, filenames in os.walk(root): + for d in dirnames: + if _is_link(os.lstat(os.path.join(dirpath, d))): + raise DeltaError(cat, "a symlinked subdirectory") + for fn in filenames: + if suffix is not None and not fn.lower().endswith(suffix): + continue + full = os.path.join(dirpath, fn) + st = os.lstat(full) + if _is_link(st): + raise DeltaError(cat, f"{fn} is a symlink / reparse point") + if not stat.S_ISREG(st.st_mode): + raise DeltaError(cat, f"{fn} is not a regular file") + out.append(os.path.relpath(full, root).replace(os.sep, "/")) + out.sort() + return out + + +def snapshot_reference_closure(work: str, + ref_dirs: list[str]) -> tuple[list[str], list[dict[str, Any]]]: + """Snapshot the reference closure into ordered one-DLL-per-slot directories (LA F2): + caller directory order first, canonical relative path second. Returns (slot_dirs in + ordinal order, reference_closure evidence). First-simple-name-wins order is preserved by + the slot sequence; nothing about filesystem enumeration order leaks into the analyzer.""" + refs_root = os.path.join(work, "references") + os.makedirs(refs_root) + ordered: list[tuple[int, str, bytes, str]] = [] + for di, rd in enumerate(ref_dirs): + rd_abs = os.path.abspath(rd) + try: + lst = os.lstat(rd_abs) + except OSError as exc: + raise DeltaError(INPUT_LAYOUT, f"--ref-dir {rd!r}: cannot stat ({exc})") from exc + if _is_link(lst): + raise DeltaError(ANALYSIS_SCOPE, f"--ref-dir {rd!r} is a symlink / reparse point") + if not stat.S_ISDIR(lst.st_mode): + raise DeltaError(INPUT_LAYOUT, f"--ref-dir {rd!r} is not a directory") + for rel in _walk_regular_files(rd_abs, ANALYSIS_SCOPE, suffix=".dll"): + data = _snapshot(os.path.join(rd_abs, rel.replace("/", os.sep)), + ANALYSIS_SCOPE, f"reference {rel}") + ordered.append((di, rel, data, _sha_bytes(data))) + slot_dirs: list[str] = [] + evidence: list[dict[str, Any]] = [] + for ordinal, (di, rel, data, sha) in enumerate(ordered): + slot = os.path.join(refs_root, f"{ordinal:06d}") + os.makedirs(slot) + with open(os.path.join(slot, rel.rsplit("/", 1)[-1]), "wb") as fh: + fh.write(data) + slot_dirs.append(slot) + evidence.append({"ordinal": ordinal, "source_dir_ordinal": di, + "relative_path": rel, "sha256": sha}) + return slot_dirs, evidence + + +def snapshot_extractor_deployment(work: str, + extractor_dll: str) -> tuple[str, dict[str, Any]]: + """Snapshot the whole extractor deployment closure into WORK/toolchain and return the + copied DLL path + the extractor fingerprint (ordered deployment manifest). Execute the + COPY, never the caller's original path (TOCTOU-closed).""" + dll_abs = os.path.abspath(extractor_dll) + root = os.path.dirname(dll_abs) + name = os.path.basename(dll_abs) + if _is_link(os.lstat(root)): + raise DeltaError(TOOLCHAIN_BINDING, "the extractor deployment root is a link") + dst_root = os.path.join(work, "toolchain") + os.makedirs(dst_root) + manifest: list[dict[str, str]] = [] + for rel in _walk_regular_files(root, TOOLCHAIN_BINDING): + data = _snapshot(os.path.join(root, rel.replace("/", os.sep)), + TOOLCHAIN_BINDING, f"extractor {rel}") + dst = os.path.join(dst_root, rel.replace("/", os.sep)) + os.makedirs(os.path.dirname(dst), exist_ok=True) + with open(dst, "wb") as fh: + fh.write(data) + manifest.append({"path": rel, "sha256": _sha_bytes(data)}) + manifest.sort(key=lambda m: m["path"]) + if name not in {m["path"] for m in manifest}: + raise DeltaError(TOOLCHAIN_BINDING, f"the extractor DLL {name!r} is not in its deployment") + fingerprint = {"extractor_deployment_manifest_sha256": _sha_bytes(_canonical_json(manifest)), + "extractor_files": manifest} + return os.path.join(dst_root, name), fingerprint + + +def _read_runtimeconfig(dll_dst: str) -> tuple[str, str, str]: + """(tfm, framework_name, framework_version) from the snapshotted runtimeconfig.json.""" + base = dll_dst[:-4] if dll_dst.lower().endswith(".dll") else dll_dst + data = _snapshot(base + ".runtimeconfig.json", TOOLCHAIN_BINDING, "runtimeconfig.json") + try: + rc = json.loads(data) + except ValueError as exc: + raise DeltaError(TOOLCHAIN_BINDING, f"runtimeconfig.json is invalid ({exc})") from exc + opts = rc.get("runtimeOptions") if isinstance(rc, dict) else None + fw = opts.get("framework") if isinstance(opts, dict) else None + if not isinstance(fw, dict): + raise DeltaError(TOOLCHAIN_BINDING, "runtimeconfig.json has no single framework object") + tfm, fname, fver = opts.get("tfm"), fw.get("name"), fw.get("version") + if not (isinstance(tfm, str) and isinstance(fname, str) and isinstance(fver, str)): + raise DeltaError(TOOLCHAIN_BINDING, "runtimeconfig framework tuple is incomplete") + return tfm, fname, fver + + +def _run_capture(argv: list[str], cat: str, what: str) -> str: + try: + proc = subprocess.run(argv, capture_output=True, text=True, check=False) + except OSError as exc: + raise DeltaError(cat, f"{what}: cannot run ({exc})") from exc + if proc.returncode != 0: + raise DeltaError(cat, f"{what}: rc={proc.returncode} {proc.stderr.strip()[:200]}") + return proc.stdout + + +def resolve_runtime(dotnet_host: str, tfm: str, fname: str, + fver: str) -> tuple[dict[str, Any], str, str]: + """Prove the EXACT requested runtime is installed (roll-forward disabled), snapshot its + file manifest, and return (resolved_runtime_identity, dotnet_version, dotnet_host_sha256). + An unavailable / ambiguous / malformed runtime is TOOLCHAIN_BINDING (LA D4).""" + host_data = _snapshot(dotnet_host, TOOLCHAIN_BINDING, "dotnet host") + dotnet_host_sha256 = _sha_bytes(host_data) + dotnet_version = _run_capture([dotnet_host, "--version"], TOOLCHAIN_BINDING, + "dotnet --version").strip() + listing = _run_capture([dotnet_host, "--list-runtimes"], TOOLCHAIN_BINDING, + "dotnet --list-runtimes") + matches: list[str] = [] + for line in listing.splitlines(): + parts = line.strip().split(" ", 2) + if len(parts) == 3 and parts[0] == fname and parts[1] == fver: + path = parts[2].strip() + if path.startswith("[") and path.endswith("]"): + path = path[1:-1] + matches.append(os.path.join(path, fver)) + if len(matches) != 1: + raise DeltaError(TOOLCHAIN_BINDING, + f"expected exactly one {fname} {fver} runtime, found {len(matches)}") + rt_dir = matches[0] + if not os.path.isdir(rt_dir): + raise DeltaError(TOOLCHAIN_BINDING, "the resolved runtime directory does not exist") + manifest: list[dict[str, str]] = [] + for rel in _walk_regular_files(rt_dir, TOOLCHAIN_BINDING): + data = _snapshot(os.path.join(rt_dir, rel.replace("/", os.sep)), + TOOLCHAIN_BINDING, f"runtime {rel}") + manifest.append({"path": rel, "sha256": _sha_bytes(data)}) + manifest.sort(key=lambda m: m["path"]) + identity = {"framework_name": fname, "framework_version": fver, "tfm": tfm, + "runtime_manifest_sha256": _sha_bytes(_canonical_json(manifest))} + return identity, dotnet_version, dotnet_host_sha256 + + +def _dotnet_env(work: str, image_dir: str) -> dict[str, str]: + env: dict[str, str] = {} + for k in ("SystemRoot", "SYSTEMROOT", "windir", "PATH", "HOME", "LANG", "LC_ALL"): + if k in os.environ: + env[k] = os.environ[k] + env["DOTNET_ROLL_FORWARD"] = "Disable" + env["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1" + env["DOTNET_NOLOGO"] = "1" + env["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1" + env["HOME"] = os.path.join(work, "home") + env["DOTNET_CLI_HOME"] = os.path.join(work, "home") + env["NUGET_PACKAGES"] = os.path.join(work, "nuget") + env["TMPDIR"] = image_dir + env["TEMP"] = image_dir + env["TMP"] = image_dir + return env + + +def extract_image(work: str, dll_dst: str, dotnet_host: str, image_dir: str, rel: str, + target: str, slot_dirs: list[str], cat: str) -> bytes: + """Run the fixed `dotnet exec extract ...` over the materialized target file and + return the facts.json bytes. The image workspace root is the process CWD.""" + argv = [dotnet_host, "exec", dll_dst, "extract", rel, "--out", "facts.json", + "--fix-candidates", "--weak-subscribe", target] + for slot in slot_dirs: + argv += ["--ref-dir", slot] + os.makedirs(os.path.join(work, "home"), exist_ok=True) + os.makedirs(os.path.join(work, "nuget"), exist_ok=True) + proc = subprocess.run(argv, cwd=image_dir, env=_dotnet_env(work, image_dir), + capture_output=True, text=True, check=False) + if proc.returncode != 0: + raise DeltaError(cat, f"extractor failed (rc={proc.returncode}): " + f"{proc.stderr.strip()[:300]}") + return _snapshot(os.path.join(image_dir, "facts.json"), cat, "facts.json") + + +# --- evidence assembly + the full orchestration ------------------------------------ + + +def build_evidence(rel: str, class_fqn: str, ref_count: int, input_hashes: dict[str, Any], + gate_binding: dict[str, Any], toolchain_fp: dict[str, Any], + reference_closure: list[dict[str, Any]], target_subscribe: str, + expected: dict[str, Any], classified: dict[str, Any]) -> dict[str, Any]: + """Assemble the full delta-result.json object (Section 14) — every check is 'pass' in a + published artifact, and the check-name set is exactly the fixed seventeen names.""" + return { + "schema": 1, + "operation": "verify-subscription-analyzer-delta", + "status": "pass", + "analysis_scope": { + "source_file": rel, "selected_class": class_fqn, + "closure_kind": "single-file+refdirs" if ref_count else "single-file", + "reference_dir_count": ref_count, "target_file_identity": rel, + }, + "input_hashes": input_hashes, + "gate_binding": gate_binding, + "toolchain_fingerprint": toolchain_fp, + "reference_closure": reference_closure, + "target_api": {"subscribe": target_subscribe}, + "expected": expected, + "baseline": classified["baseline"], + "postimage": classified["postimage"], + "delta": classified["delta"], + "semantic_idempotence": classified["semantic_idempotence"], + "checks": {name: "pass" for name in _CHECK_NAMES}, + } + + +def _require_bundle_layout(bundle: str) -> None: + try: + names = set(os.listdir(bundle)) + except OSError as exc: + raise DeltaError(INPUT_LAYOUT, f"cannot list --bundle ({exc})") from exc + if names != {"change.patch", "apply-manifest.json", "postimage"}: + raise DeltaError(INPUT_LAYOUT, f"--bundle holds {sorted(names)}, not the step 8 layout") + + +def _resolve_dotnet_host() -> str: + host = shutil.which("dotnet") + if not host: + raise DeltaError(INFRASTRUCTURE, "no 'dotnet' host on PATH") + return host + + +def run_verify_delta(bundle: str, plan_path: str, candidates_path: str, root: str, + gate_path: str, extractor_dll: str, out: str, + ref_dirs: list[str]) -> str: + """The full step 10 orchestration: snapshot every input, restate the authority + the + OWN001-only guard, bind the Step 9 evidence, snapshot the extractor + core toolchains, + analyze the pristine preimage and the accepted postimage in hermetic workspaces, verify + the delta, and publish delta-result.json atomically. Returns the published path.""" + plan_bytes = _snapshot(plan_path, INPUT_LAYOUT, "--plan") + candidates_bytes = _snapshot(candidates_path, INPUT_LAYOUT, "--candidates") + gate_bytes = _snapshot(gate_path, INPUT_LAYOUT, "--gate") + auth, _plan, candidates = load_authority(plan_bytes, candidates_bytes) + rel = auth.rel + class_fqn = candidates["selection"]["allowed_types"][0]["full_name"] + target = auth.target_subscribe + + _require_bundle_layout(bundle) + manifest_bytes = _snapshot(os.path.join(bundle, "apply-manifest.json"), + INPUT_LAYOUT, "apply-manifest.json") + patch_bytes = _snapshot(os.path.join(bundle, "change.patch"), INPUT_LAYOUT, "change.patch") + postimage_bytes = _snapshot(os.path.join(bundle, "postimage", *rel.split("/")), + INPUT_LAYOUT, "postimage") + preimage_bytes = _snapshot(os.path.join(root, *rel.split("/")), INPUT_LAYOUT, "preimage") + pre_sha, post_sha = _sha_bytes(preimage_bytes), _sha_bytes(postimage_bytes) + + gate_sha = bind_gate(gate_bytes, auth, plan_bytes, manifest_bytes, patch_bytes, + pre_sha, post_sha) + git_status = "not_applicable" if not auth.applied else "pass" + + work = tempfile.mkdtemp(prefix="owen-delta-") + try: + core_dir, runner_path, runner_sha, core_fp = materialize_core(work) + python_exe, python_fp = resolve_python() + dll_dst, ext_fp = snapshot_extractor_deployment(work, extractor_dll) + tfm, fname, fver = _read_runtimeconfig(dll_dst) + dotnet_host = _resolve_dotnet_host() + runtime_identity, dotnet_version, dotnet_host_sha = resolve_runtime( + dotnet_host, tfm, fname, fver) + slot_dirs, ref_evidence = snapshot_reference_closure(work, ref_dirs) + + def analyze(label: str, image_bytes: bytes, cat: str) -> dict[str, Any]: + _verify_runner(runner_path, runner_sha) + image_dir = os.path.join(work, label) + src = os.path.join(image_dir, *rel.split("/")) + os.makedirs(os.path.dirname(src), exist_ok=True) + with open(src, "wb") as fh: + fh.write(image_bytes) + facts_bytes = extract_image(work, dll_dst, dotnet_host, image_dir, rel, target, + slot_dirs, cat) + params = {"root": image_dir, "target_subscribe": target, "class_fqn": class_fqn} + core = run_core(core_dir, runner_path, python_exe, runner_sha, image_dir, + facts_bytes, params, cat) + check_target_identity(core, rel, cat) + return core + + base_core = analyze("baseline", preimage_bytes, BASELINE_ANALYSIS) + post_core = analyze("postimage", postimage_bytes, POSTIMAGE_ANALYSIS) + _verify_runner(runner_path, runner_sha) # after the postimage run (LA1) + + check_baseline_authority(candidates, base_core) + classified = classify_delta( + {"convert_acquire_ids": auth.applied, "manual_review_ids": auth.manual}, + base_core, post_core) + + toolchain_fp = { + **ext_fp, + "dotnet_host_sha256": dotnet_host_sha, + "dotnet_version": dotnet_version, + "resolved_runtime_identity": runtime_identity, + "core_analyzer": {**core_fp, **python_fp}, + } + input_hashes = { + "input_bundle_sha256": auth.input_bundle_sha256, + "validated_plan_sha256": _sha_bytes(plan_bytes), + "apply_manifest_sha256": _sha_bytes(manifest_bytes), + "patch_sha256": _sha_bytes(patch_bytes), + "candidates_sha256": _sha_bytes(candidates_bytes), + "pre_sha256": pre_sha, "post_sha256": post_sha, + } + gate_binding = {"gate_result_sha256": gate_sha, "step9_operation": _STEP9_OPERATION, + "step9_version": 1, "git_gates_status": git_status, "bound": True} + expected = {"convert_acquire_ids": sorted(auth.applied), + "manual_review_ids": sorted(auth.manual)} + evidence = build_evidence(rel, class_fqn, len(ref_dirs), input_hashes, gate_binding, + toolchain_fp, ref_evidence, target, expected, classified) + return _publish_delta(out, root, canonical_evidence(evidence)) + finally: + shutil.rmtree(work, ignore_errors=True) diff --git a/spec/CLI.md b/spec/CLI.md index c217de93..9ba920ab 100644 --- a/spec/CLI.md +++ b/spec/CLI.md @@ -11,6 +11,7 @@ | `report`| prints the compile-time buffer report and writes `*.ownreport.json` | — | | `config`| reads an explicit `own.toml` and prints the declared P-035 `[weak-subscription].subscribe` names, one per line (the minimal P-015 config carrier). `python -m ownlang config ` | non-zero on a **malformed** config (hard error) | | `own-fix subscriptions candidates`| S0 (analysis-only): reads a `--fix-candidates` facts file and, for one **exact** `--class `, emits a deterministic `candidates.json` — a selection-request safety envelope plus a candidate bundle per leaky subscription (line-independent `finding_id`, pinned `target_api`, `allowed_actions` = `convert_acquire` for a proven INotifyPropertyChanged contract else `manual_review`, per-file SHA-256). `python -m ownlang own-fix subscriptions candidates --config --class [--finding-id ]... --output [--root ]` | non-zero on a partial/nested/generated/unknown class, an unknown finding-id, an unpinnable target, or an unreadable source | +| `own-fix subscriptions verify-delta`| S2 step 10 (analyzer-semantic gate): binds the mandatory step 9 `gate-result.json`, then re-runs Own.NET's real core analyzer — from a **snapshotted** `ownlang` package in a fresh isolated `python -S -B -E` subprocess, the extractor from a snapshotted deployment on the pinned runtime — over the pristine preimage and the accepted step 8 postimage, and proves the OWN001 delta matches the plan (converted candidates gone, manual-review preserved, no new OWN001 of any resource lane, no new OWN050), publishing a byte-deterministic `delta-result.json`. OWN001-only (an OWN014 candidate is `ANALYSIS_SCOPE`); no `--config`. `python -m ownlang own-fix subscriptions verify-delta --bundle --plan --candidates --root --gate --extractor-dll --out [--ref-dir ]...` | non-zero (exit 2) on any refusal (stable category: `INPUT_LAYOUT`/`AUTHORITY_BINDING`/`GATE_BINDING`/`TOOLCHAIN_BINDING`/`ANALYSIS_SCOPE`/`BASELINE_ANALYSIS`/`POSTIMAGE_ANALYSIS`/`ANALYSIS_IDENTITY`/`DELTA_MISMATCH`/`NEW_OWN001`/`NEW_OWN050`/`IDEMPOTENCE`/`ISOLATION`/`PUBLICATION`/`INFRASTRUCTURE`), no partial output | Notes: - `check`'s non-zero exit on errors is what makes it usable as a CI gate. diff --git a/tests/test_verify_delta.py b/tests/test_verify_delta.py index 7b61a57a..fd0515b1 100644 --- a/tests/test_verify_delta.py +++ b/tests/test_verify_delta.py @@ -478,6 +478,50 @@ def tam(mut) -> bytes: check(_raises(fd.GATE_BINDING, bg, json.dumps(good, indent=2).encode("utf-8") + b"\n"), "gate: non-canonical bytes -> GATE_BINDING") + # --- slice 6: reference-closure snapshot + evidence assembly + bundle layout + with tempfile.TemporaryDirectory() as tmp: + work = os.path.join(tmp, "w") + os.makedirs(work) + r0, r1 = os.path.join(tmp, "r0"), os.path.join(tmp, "r1") + os.makedirs(os.path.join(r0, "sub")) + os.makedirs(r1) + for p, data in ((os.path.join(r0, "B.dll"), b"B0"), + (os.path.join(r0, "sub", "A.dll"), b"A0"), + (os.path.join(r0, "note.txt"), b"skip"), + (os.path.join(r1, "C.dll"), b"C1")): + with open(p, "wb") as fh: + fh.write(data) + slots, ev = fd.snapshot_reference_closure(work, [r0, r1]) + check([e["relative_path"] for e in ev] == ["B.dll", "sub/A.dll", "C.dll"], + "ref closure: caller-dir then byte-order") + check([e["source_dir_ordinal"] for e in ev] == [0, 0, 1], "ref closure: dir ordinals") + check(all(len(os.listdir(s)) == 1 for s in slots), "ref closure: one dll per slot") + check(os.listdir(slots[1]) == ["A.dll"], "ref closure: original basename preserved") + + classified = {"baseline": {"subscription_own001_ids": [], "all_own001": [], "own050": []}, + "postimage": {"subscription_own001_ids": [], "all_own001": [], "own050": []}, + "delta": {"removed_all_own001": []}, + "semantic_idempotence": {"converted_ids_still_actionable": [], "pass": True}} + ev = fd.build_evidence("Own/X.cs", "N.X", 2, {"a": 1}, {"b": 2}, {"c": 3}, [], "T", + {"convert_acquire_ids": [], "manual_review_ids": []}, classified) + check(set(ev["checks"]) == set(fd._CHECK_NAMES) and len(fd._CHECK_NAMES) == 17, + "evidence: exactly seventeen check names") + check(all(v == "pass" for v in ev["checks"].values()), "evidence: every check passes") + check(ev["schema"] == 1 and ev["operation"] == "verify-subscription-analyzer-delta", + "evidence: schema + operation") + check(ev["analysis_scope"]["closure_kind"] == "single-file+refdirs", + "evidence: closure_kind with ref dirs") + canon = fd.canonical_evidence(ev) + check(canon.endswith(b"\n") and b'"schema":1' in canon, "evidence: canonical bytes") + + with tempfile.TemporaryDirectory() as tmp: + b = os.path.join(tmp, "bundle") + os.makedirs(b) + with open(os.path.join(b, "change.patch"), "wb") as fh: + fh.write(b"") + check(_raises(fd.INPUT_LAYOUT, fd._require_bundle_layout, b), + "bundle layout incomplete -> INPUT_LAYOUT") + # --- slices 3-5: the real fresh core subprocess (no dotnet) --------------- fchecks, ffails = _fixture_core_fails() ok += fchecks - len(ffails) From f3fa49b7bcb9c2c2e4e26f128364c6f29a7a4689 Mon Sep 17 00:00:00 2001 From: PhysShell Date: Fri, 17 Jul 2026 16:10:48 +0500 Subject: [PATCH 5/9] =?UTF-8?q?test(s2-step10):=20Tier=20B=20=E2=80=94=20r?= =?UTF-8?q?eal=20extractor=20->=20real=20fresh=20core=20subprocess=20->=20?= =?UTF-8?q?delta=20+=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds tests/test_verify_delta_tierb.py: the only tier that proves the analyzer-semantic claim with the REAL Roslyn extractor. It extracts a baseline (two INotifyPropertyChanged `+=` leaks) and a postimage (one converted to the accepted weak wrapper), drives the same snapshotted-ownlang fresh core subprocess Step 10 uses, and asserts the OWN001 delta (OnA converted gone, OnB manual preserved). It SKIPS cleanly when dotnet is absent (the Tier-A tests job), so the offline suite stays green; the wpf-extractor CI job runs it for real (new step). The Tier-B test intentionally drives the extractor directly (dotnet exec / run), NOT through run_verify_delta.resolve_runtime — see the PR limitation note: the locked DOTNET_ROLL_FORWARD=Disable + exact-version rule cannot match a runtimeconfig pinned at x.0.0 when only patch runtimes are installed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G --- .github/workflows/ci.yml | 2 + tests/test_verify_delta_tierb.py | 175 +++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 tests/test_verify_delta_tierb.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 187e1c4f..294200d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -256,6 +256,8 @@ jobs: frontend/roslyn/samples/OwnedCollectionElementSample.cs \ -o "$RUNNER_TEMP/facts.json" cat "$RUNNER_TEMP/facts.json" + - name: S2 step 10 analyzer-delta verifier (Tier B, real extractor + core) + run: python tests/test_verify_delta_tierb.py - name: Check facts through the core run: | out=$(python -m ownlang ownir "$RUNNER_TEMP/facts.json" || true) diff --git a/tests/test_verify_delta_tierb.py b/tests/test_verify_delta_tierb.py new file mode 100644 index 00000000..879f3970 --- /dev/null +++ b/tests/test_verify_delta_tierb.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""S2 step 10 — Tier B: the REAL Roslyn extractor -> real fresh core subprocess -> delta. + +This is the only tier that proves the analyzer-semantic claim with the real extractor. It +runs the checked-in OwnSharp.Extractor over a baseline (two INotifyPropertyChanged `+=` +leaks) and a postimage (one converted to the accepted weak wrapper), then drives the same +snapshotted-ownlang fresh core subprocess Step 10 uses and asserts the OWN001 delta +(converted gone, manual preserved). It needs dotnet; when dotnet is absent (the Tier-A +`tests (pyX)` job) it SKIPS cleanly so the offline suite stays green. CI runs it in the +`wpf-extractor` job. + +NOTE: this drives the extractor via `dotnet exec ` / `dotnet run --project` directly, +NOT through fix_delta.run_verify_delta's resolve_runtime — see the PR's limitation note on +the locked DOTNET_ROLL_FORWARD=Disable + exact-version rule (a runtimeconfig pinned at +x.0.0 has no exact installed match when only patch runtimes exist). + +Run: python tests/test_verify_delta_tierb.py +""" + +from __future__ import annotations + +import glob +import os +import shutil +import subprocess +import sys +import tempfile + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from ownlang import fix_delta as fd + +_REL = "Own/Samples/TwoOnOneLine.cs" +_FQN = "Own.Samples.TwoOnOneLine" +_TARGET = "WeakEvents.AddPropertyChanged" + +_BASE_CS = """using System.ComponentModel; +namespace Own.Samples { + public class TwoOnOneLine { + public TwoOnOneLine(INotifyPropertyChanged a, INotifyPropertyChanged b) { + a.PropertyChanged += OnA; b.PropertyChanged += OnB; + } + void OnA(object s, PropertyChangedEventArgs e) {} + void OnB(object s, PropertyChangedEventArgs e) {} + } +} +""" + +_POST_CS = """using System.ComponentModel; +namespace Own.Samples { + static class WeakEvents { + public static void AddPropertyChanged( + INotifyPropertyChanged s, PropertyChangedEventHandler h) {} + } + public class TwoOnOneLine { + public TwoOnOneLine(INotifyPropertyChanged a, INotifyPropertyChanged b) { + WeakEvents.AddPropertyChanged(a, OnA); b.PropertyChanged += OnB; + } + void OnA(object s, PropertyChangedEventArgs e) {} + void OnB(object s, PropertyChangedEventArgs e) {} + } +} +""" + + +def _find_dotnet() -> str | None: + host = shutil.which("dotnet") + if host: + return host + default = r"C:\Program Files\dotnet\dotnet.exe" + return default if os.path.isfile(default) else None + + +def _find_extractor_dll(repo: str) -> str | None: + hits = glob.glob(os.path.join(repo, "frontend", "roslyn", "OwnSharp.Extractor", + "bin", "*", "*", "ownsharp-extract.dll")) + return hits[0] if hits else None + + +def _extract(dotnet: str, dll: str | None, proj: str, image_dir: str) -> bytes: + """Run the real extractor over the materialized target file, return facts.json bytes.""" + common = ["extract", _REL, "--out", "facts.json", "--fix-candidates", + "--weak-subscribe", _TARGET] + if dll is not None: + argv = [dotnet, "exec", dll, *common] + else: + argv = [dotnet, "run", "--project", proj, "-c", "Release", "--", *common] + proc = subprocess.run(argv, cwd=image_dir, capture_output=True, text=True, check=False) + if proc.returncode != 0: + raise RuntimeError(f"extractor rc={proc.returncode}: {proc.stderr.strip()[:400]}") + with open(os.path.join(image_dir, "facts.json"), "rb") as fh: + return fh.read() + + +def run() -> int: + repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + dotnet = _find_dotnet() + if dotnet is None: + print("verify-delta (Tier B): SKIP (no dotnet host)") + return 0 + dll = _find_extractor_dll(repo) + proj = os.path.join(repo, "frontend", "roslyn", "OwnSharp.Extractor") + + ok = 0 + bad = 0 + + def check(cond: bool, label: str) -> None: + nonlocal ok, bad + if cond: + ok += 1 + else: + bad += 1 + print(f" FAIL: {label}") + + with tempfile.TemporaryDirectory() as work: + base_root = os.path.join(work, "base") + post_root = os.path.join(work, "post") + for root, src in ((base_root, _BASE_CS), (post_root, _POST_CS)): + path = os.path.join(root, *_REL.split("/")) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + fh.write(src) + try: + base_facts = _extract(dotnet, dll, proj, base_root) + post_facts = _extract(dotnet, dll, proj, post_root) + except (RuntimeError, OSError) as exc: + print(f"verify-delta (Tier B): SKIP (extractor unavailable: {exc})") + return 0 + + core_dir, runner_path, runner_sha, _fp = fd.materialize_core(work) + py, _pyfp = fd.resolve_python() + + def analyze(root: str, facts: bytes, cat: str) -> dict: + img = tempfile.mkdtemp(dir=work) + params = {"root": root, "target_subscribe": _TARGET, "class_fqn": _FQN} + return fd.run_core(core_dir, runner_path, py, runner_sha, img, facts, params, cat) + + base = analyze(base_root, base_facts, fd.BASELINE_ANALYSIS) + post = analyze(post_root, post_facts, fd.POSTIMAGE_ANALYSIS) + + check(sorted(o["handler"] for o in base["all_own001"]) == ["OnA", "OnB"], + "Tier B: real baseline has OnA + OnB OWN001") + check([o["handler"] for o in post["all_own001"]] == ["OnB"], + "Tier B: real postimage has only OnB (OnA converted)") + + recs = {e["record"]["handler"]: e for e in base["fix_eligible_subscriptions"]} + check(set(recs) == {"OnA", "OnB"}, "Tier B: two real fix-eligible subscriptions") + fid_a, fid_b = recs["OnA"]["finding_id"], recs["OnB"]["finding_id"] + candidates = {"version": 1, "operation": "fix-subscriptions", + "target_api": {"subscribe": _TARGET}, + "selection": {"allowed_types": [{"full_name": _FQN, "file": _REL}], + "selected_findings": None, + "constraints": {"max_types_changed": 1, "max_files_changed": 1, + "allow_helper_changes": False, + "allow_config_changes": False, + "allow_suppressions": False}}, + "source_files": [{"path": _REL, "sha256": "sha256:" + "0" * 64}], + "candidates": [recs["OnA"]["record"], recs["OnB"]["record"]]} + fd.check_baseline_authority(candidates, base) + fd.check_target_identity(base, _REL, fd.BASELINE_ANALYSIS) + fd.check_target_identity(post, _REL, fd.POSTIMAGE_ANALYSIS) + res = fd.classify_delta({"convert_acquire_ids": [fid_a], "manual_review_ids": [fid_b]}, + base, post) + check(res["delta"]["removed_subscription_own001_ids"] == [fid_a], + "Tier B: real converted OnA removed") + check(res["delta"]["preserved_subscription_own001_ids"] == [fid_b], + "Tier B: real manual OnB preserved") + + total = ok + bad + print(f"verify-delta (Tier B, real extractor+core): {ok}/{total} checks pass") + return 1 if bad else 0 + + +if __name__ == "__main__": + raise SystemExit(run()) From 7b2d2f02cfa4f5289bb9d28cbbe2d1f258406e04 Mon Sep 17 00:00:00 2001 From: PhysShell Date: Fri, 17 Jul 2026 17:11:57 +0500 Subject: [PATCH 6/9] fix(s2-step10): address review R1-R6 (runtime, revalidation, bridge, core.json, publish) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R1 runtime selection: replace the unusable exact-runtimeconfig rule. Parse the requested minimum (stable major.minor.patch), select the highest installed STABLE patch of the same major.minor that is >= the minimum, publish both requested_framework_version and selected_framework_version, pin it on the extractor argv (`--fx-version --roll-forward Disable`, off the env), and fingerprint the SELECTED runtime directory. _select_runtime is pure and unit tested (8.0.0 requested + only 8.0.28 -> selects 8.0.28; highest patch; lower / different-minor / prerelease-only / none -> TOOLCHAIN_BINDING). R2 check-state + revalidation: build_evidence now takes the set of executed checks and refuses to publish any it did not run. Before each image and after the postimage, _revalidate_toolchain re-derives every MATERIALIZED hash (extractor deployment, reference slots, ownlang package, core runner, python, dotnet host, selected runtime) and _isolation_verify re-reads the pristine target + .git/index + .git/config; only then are toolchain_binding / core_analyzer_binding / isolation marked pass. Mutation regressions added. R3 image-level bridge: validate_image_bridge groups eligible OWN001 facts and core OWN001 observations by K for BOTH images and requires exact multiset cardinality + a single distinct observation shape; every accepted id (C and M) must map through a validated baseline group; R_C is built only after the complete baseline bridge passes. Regressions for missing/surplus/mixed cases. R4 closed core.json byte protocol: _load_core requires the trailing newline and canonical_bytes(parsed) == the original bytes; the runner emits advisory VERBATIM (no bool() coercion) and _validate_record type-checks all 18 fields (span, teardown, actions, ordinal). Non-canonical / missing-newline / unknown-key / wrong-type / advisory-coercion regressions added. R5 publication cleanup: a pre-rename failure rmtree's the workdir WITHOUT ignore_errors and a cleanup failure is itself PUBLICATION; a rename fault is PUBLICATION; nothing runs after a successful rename. Deterministic cleanup-failure regression added. R6 target identity: a foreign / absolute / escaping / image-mismatched target-file identity is ANALYSIS_SCOPE (not BASELINE/POSTIMAGE_ANALYSIS). R8 (partial): green lint (ruff) and mypy --strict; fix the Linux-CI crash where sys.executable / the dotnet host are symlinks — trusted system binaries are now hashed by following the symlink to their real target (_hash_resolved), distinct from the strict _snapshot for untrusted caller inputs. The runner writes core.json in binary (LF everywhere). Frozen Steps 8/9 remain untouched. Offline suite: verify-delta 90/90; Step 8 79/79; Step 9 69/69; harness 25/25. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G --- ownlang/fix_delta.py | 453 +++++++++++++++++++++++++++++-------- tests/test_verify_delta.py | 191 +++++++++++++++- 2 files changed, 539 insertions(+), 105 deletions(-) diff --git a/ownlang/fix_delta.py b/ownlang/fix_delta.py index c143eccb..736c29d5 100644 --- a/ownlang/fix_delta.py +++ b/ownlang/fix_delta.py @@ -39,6 +39,9 @@ from typing import Any from ownlang.fix_gate import ( + _ACTIONS, + _CONTRACTS, + _SPAN_KEYS, GateError, _canonical_bytes, _canonical_json, @@ -210,13 +213,50 @@ def _parse_fix_eligible(o: Any, cat: str, where: str) -> dict[str, Any]: if not isinstance(bridge[k], str): raise DeltaError(cat, f"{where}.bridge_key.{k} must be a string") _require_rel(bridge["file"], cat, f"{where}.bridge_key.file") - rec = e["record"] - if not isinstance(rec, dict) or set(rec) != set(_AUTHORITY_FIELDS): - raise DeltaError(cat, f"{where}.record is not the exact authoritative field set") + rec = _validate_record(e["record"], cat, f"{where}.record") return {"finding_id": e["finding_id"], "diagnostic_code": e["diagnostic_code"], "bridge_key": {k: bridge[k] for k in _BRIDGE_FIELDS}, "record": rec} +_RECORD_STR_FIELDS = ( + "finding_id", "diagnostic_code", "containing_type", "file", "enclosing_member", + "event", "event_identity", "event_contract", "source", "source_identity", + "source_identity_kind", "handler", "handler_identity", "handler_identity_kind", +) + + +def _validate_record(rec: Any, cat: str, where: str) -> dict[str, Any]: + """Concrete-type validation of the 18 authoritative candidate fields — every string + field, the int occurrence_ordinal, the six-int acquire_span, the teardown block, and a + non-empty allowed_actions subset. Analyzer values are never coerced (R4).""" + if not isinstance(rec, dict) or set(rec) != set(_AUTHORITY_FIELDS): + raise DeltaError(cat, f"{where} is not the exact authoritative field set") + for k in _RECORD_STR_FIELDS: + if not isinstance(rec[k], str): + raise DeltaError(cat, f"{where}.{k} must be a string") + if rec["event_contract"] not in _CONTRACTS: + raise DeltaError(cat, f"{where}.event_contract is not a known contract") + oo = rec["occurrence_ordinal"] + if not isinstance(oo, int) or isinstance(oo, bool) or oo < 0: + raise DeltaError(cat, f"{where}.occurrence_ordinal must be a non-negative int") + span = rec["acquire_span"] + if not isinstance(span, dict) or set(span) != set(_SPAN_KEYS): + raise DeltaError(cat, f"{where}.acquire_span is not the frozen six-key span") + for k in _SPAN_KEYS: + if not isinstance(span[k], int) or isinstance(span[k], bool): + raise DeltaError(cat, f"{where}.acquire_span.{k} must be an int") + td = rec["teardown"] + if not isinstance(td, dict) or set(td) != {"status", "candidates"}: + raise DeltaError(cat, f"{where}.teardown is not {{status, candidates}}") + if td["status"] not in ("none", "exact", "ambiguous") or not isinstance(td["candidates"], list): + raise DeltaError(cat, f"{where}.teardown has a bad status/candidates") + actions = rec["allowed_actions"] + if not isinstance(actions, list) or not actions \ + or any(a not in _ACTIONS for a in actions) or len(set(actions)) != len(actions): + raise DeltaError(cat, f"{where}.allowed_actions must be a non-empty unique subset") + return {k: rec[k] for k in _AUTHORITY_FIELDS} + + # --- the pure delta classifier (Section 8 / LA2 / LA4) ----------------------------- @@ -237,11 +277,15 @@ def classify_delta(expected: dict[str, Any], baseline: dict[str, Any], b_sub = _subscription_ids(baseline, BASELINE_ANALYSIS) p_sub = _subscription_ids(postimage, POSTIMAGE_ANALYSIS) - # --- the bridge FIRST: every accepted id maps to exactly one baseline core - # OWN001 observation (identity questions precede the id-set equations, so an - # unbridged / mixed-action / ambiguous candidate fails closed as ANALYSIS_IDENTITY - # rather than being masked by a later DELTA_MISMATCH). R_C feeds the core delta. - r_c = _bridge_r_c(convert, manual, baseline) + # --- the bridge FIRST, at IMAGE level for BOTH images (R3): every fix-eligible OWN001 + # fact must correspond one-to-one (exact multiset cardinality) to a core OWN001 + # observation, in baseline AND postimage. Then every accepted id maps through a validated + # baseline group and R_C is drawn from it. Identity questions precede the id-set + # equations, so an unbridged / mixed-action / ambiguous candidate fails closed as + # ANALYSIS_IDENTITY rather than being masked by a later DELTA_MISMATCH. + base_groups = validate_image_bridge(baseline, BASELINE_ANALYSIS) + validate_image_bridge(postimage, POSTIMAGE_ANALYSIS) + r_c = _bridge_r_c(convert, manual, baseline, base_groups) # --- subscription finding-id equations ------------------------------------- if not s_set <= b_sub: @@ -318,48 +362,69 @@ def _subscription_ids(image: dict[str, Any], cat: str) -> set[str]: return set(ids) -def _bridge_r_c(convert: list[str], manual: list[str], - baseline: dict[str, Any]) -> Counter[str]: - """Bridge each accepted candidate to exactly one baseline core OWN001 observation and - return R_C, the multiset of baseline observations mapped to the converted set. Every - ambiguity fails closed as ANALYSIS_IDENTITY (SUBSCRIPTION_JOIN_KEY).""" - by_fid = {e["finding_id"]: e["bridge_key"] for e in baseline["fix_eligible_subscriptions"] +def validate_image_bridge(image: dict[str, Any], + cat: str) -> dict[tuple[str, ...], list[dict[str, Any]]]: + """Image-level bridge (R3): group fix-eligible OWN001 facts and core OWN001 observations + by K=(file,component,event,handler) and require, for every K carrying an eligible fact, + exact multiset cardinality with the core observations and a single distinct observation + shape. Every violation is ANALYSIS_IDENTITY. Returns the validated K -> [observations] + groups (each group's observations are byte-identical).""" + elig_by_k: Counter[tuple[str, ...]] = Counter( + tuple(e["bridge_key"][f] for f in _BRIDGE_FIELDS) + for e in image["fix_eligible_subscriptions"] if e["diagnostic_code"] == "OWN001") + core_by_k: dict[tuple[str, ...], list[dict[str, Any]]] = {} + for obs in image["all_own001"]: + core_by_k.setdefault(_proj(obs), []).append(obs) + groups: dict[tuple[str, ...], list[dict[str, Any]]] = {} + for k, elig_count in elig_by_k.items(): + obs = core_by_k.get(k, []) + if not obs: + raise DeltaError(ANALYSIS_IDENTITY, + f"eligible OWN001 subscription {k} has no core observation") + if len({_ckey(o) for o in obs}) != 1: + raise DeltaError(ANALYSIS_IDENTITY, + f"eligible OWN001 {k} maps to multiple distinct observations") + if len(obs) != elig_count: + raise DeltaError(ANALYSIS_IDENTITY, + f"eligible/core cardinality mismatch for {k}: " + f"{elig_count} facts vs {len(obs)} observations") + groups[k] = obs + return groups + + +def _bridge_r_c(convert: list[str], manual: list[str], baseline: dict[str, Any], + base_groups: dict[tuple[str, ...], list[dict[str, Any]]]) -> Counter[str]: + """Every accepted id (C and M) must map through a validated baseline group; a mixed + convert/manual group under one indistinguishable K fails closed. R_C is drawn only from + the validated baseline groups (built after the complete baseline bridge passes).""" + by_fid = {e["finding_id"]: tuple(e["bridge_key"][f] for f in _BRIDGE_FIELDS) + for e in baseline["fix_eligible_subscriptions"] if e["diagnostic_code"] == "OWN001"} - # each 4-field projection -> the distinct full observations that project to it - by_proj: dict[tuple[str, ...], list[dict[str, Any]]] = {} - for obs in baseline["all_own001"]: - by_proj.setdefault(_proj(obs), []).append(obs) - - # mixed-action under one indistinguishable bridge key -> fail closed + convert_set = set(convert) action_of: dict[tuple[str, ...], set[str]] = {} for fid in convert + manual: if fid not in by_fid: raise DeltaError(ANALYSIS_IDENTITY, f"accepted candidate {fid} has no baseline subscription fact") - key = tuple(by_fid[fid][k] for k in _BRIDGE_FIELDS) - action_of.setdefault(key, set()).add("convert" if fid in set(convert) else "manual") + key = by_fid[fid] + if key not in base_groups: + raise DeltaError(ANALYSIS_IDENTITY, + f"accepted candidate {fid} maps to an unvalidated baseline group") + action_of.setdefault(key, set()).add("convert" if fid in convert_set else "manual") for key, actions in action_of.items(): if len(actions) > 1: raise DeltaError(ANALYSIS_IDENTITY, f"bridge key {key} mixes convert and manual candidates") - r_c: Counter[str] = Counter() consumed: Counter[tuple[str, ...]] = Counter() for fid in convert: - key = tuple(by_fid[fid][k] for k in _BRIDGE_FIELDS) - matches = by_proj.get(key, []) - distinct = {_ckey(o) for o in matches} - if not matches: - raise DeltaError(ANALYSIS_IDENTITY, - f"candidate {fid} has no baseline core OWN001 observation") - if len(distinct) != 1: - raise DeltaError(ANALYSIS_IDENTITY, - f"candidate {fid} maps to multiple distinct core observations") + key = by_fid[fid] + group = base_groups[key] consumed[key] += 1 - if consumed[key] > len(matches): + if consumed[key] > len(group): raise DeltaError(ANALYSIS_IDENTITY, f"more converted candidates than observations for key {key}") - r_c[_ckey(matches[0])] += 1 + r_c[_ckey(group[0])] += 1 return r_c @@ -401,11 +466,22 @@ def _publish_delta(out: str, root: str, evidence_bytes: bytes) -> str: if os.path.exists(out_phys) or os.path.islink(out_phys): raise DeltaError(PUBLICATION, "the out-dir appeared before publication") _require_single_delta_file(workdir) - os.rename(workdir, out_phys) + try: + os.rename(workdir, out_phys) + except OSError as exc: + raise DeltaError(PUBLICATION, f"cannot publish the evidence ({exc})") from exc succeeded = True finally: + # On a PRE-rename failure OUTPUT_DIR must stay absent and the private staging must be + # removed for real (R5): rmtree WITHOUT ignore_errors, and a cleanup failure is itself + # PUBLICATION — never a false claim that the OS removed the staging path. After a + # successful rename the workdir no longer exists (it became OUTPUT_DIR), so nothing runs. if not succeeded: - shutil.rmtree(workdir, ignore_errors=True) + try: + shutil.rmtree(workdir) + except OSError as exc: + raise DeltaError(PUBLICATION, + f"could not remove the staging directory ({exc})") from exc return out_phys @@ -559,7 +635,7 @@ def main(): if fnd.code == "OWN001": all_own001.append({"file": fnd.file, "code": "OWN001", "component": fnd.component, "event": fnd.event, "handler": fnd.handler, "kind": fnd.kind, - "advisory": bool(fnd.advisory), "severity": fnd.severity, + "advisory": fnd.advisory, "severity": fnd.severity, "ignore_reason": fnd.ignore_reason}) elif fnd.code == "OWN050": own050.append({"file": fnd.file, "component": fnd.component, @@ -583,8 +659,8 @@ def main(): "all_own001": all_own001, "own050": own050, "fix_eligible_subscriptions": fix_eligible} data = json.dumps(core, sort_keys=True, separators=(",", ":"), ensure_ascii=False) - with open(out_path, "w", encoding="utf-8") as fh: - fh.write(data + "\n") + with open(out_path, "wb") as fh: + fh.write(data.encode("utf-8") + b"\n") # binary: no newline translation (LF everywhere) if __name__ == "__main__": @@ -662,15 +738,33 @@ def _verify_runner(runner_path: str, expected_sha: str) -> None: raise DeltaError(TOOLCHAIN_BINDING, "core runner bytes changed (core_runner_sha256)") +def _hash_resolved(path: str, cat: str, what: str) -> str: + """Hash a TRUSTED system binary/file, following symlinks to its real regular target. + System interpreters / hosts / runtime files are legitimately symlinks (pyenv, the CI + tool cache, the .NET shared framework), so the strict symlink-rejecting _snapshot (for + UNTRUSTED caller inputs) would wrongly refuse them.""" + real = os.path.realpath(path) + try: + st = os.stat(real) + except OSError as exc: + raise DeltaError(cat, f"{what}: cannot stat ({exc})") from exc + if not stat.S_ISREG(st.st_mode): + raise DeltaError(cat, f"{what}: does not resolve to a regular file") + try: + with open(real, "rb") as fh: + return _sha_bytes(fh.read()) + except OSError as exc: + raise DeltaError(cat, f"{what}: cannot read ({exc})") from exc + + def resolve_python() -> tuple[str, dict[str, Any]]: - """Resolve, snapshot, and identify the Python executable that runs the fresh core - subprocess (LA5). A missing / non-regular interpreter is TOOLCHAIN_BINDING.""" + """Resolve, hash, and identify the Python executable that runs the fresh core subprocess + (LA5). The interpreter is trusted infrastructure and is followed to its real target.""" exe = sys.executable if not exe: raise DeltaError(TOOLCHAIN_BINDING, "no Python executable to run the core subprocess") - data = _snapshot(exe, TOOLCHAIN_BINDING, "python executable") return exe, { - "python_executable_sha256": _sha_bytes(data), + "python_executable_sha256": _hash_resolved(exe, TOOLCHAIN_BINDING, "python executable"), "python_implementation": sys.implementation.name, "python_version": platform.python_version(), "python_cache_tag": sys.implementation.cache_tag or "unknown", @@ -717,9 +811,23 @@ def run_core(core_dir: str, runner_path: str, python_exe: str, core_runner_sha25 f"{proc.stderr.strip()[:300]}") try: with open(core_path, "rb") as fh: - raw = json.loads(fh.read()) - except (OSError, ValueError) as exc: + core_bytes = fh.read() + except OSError as exc: raise DeltaError(cat, f"core.json is unreadable ({exc})") from exc + return _load_core(core_bytes, cat) + + +def _load_core(core_bytes: bytes, cat: str) -> dict[str, Any]: + """The closed core.json byte protocol (R4): require the trailing newline, valid JSON, and + that the bytes equal the canonical re-encoding of the parsed object, then schema-check.""" + if not core_bytes.endswith(b"\n"): + raise DeltaError(cat, "core.json is missing its trailing newline") + try: + raw = json.loads(core_bytes) + except ValueError as exc: + raise DeltaError(cat, f"core.json is not valid JSON ({exc})") from exc + if _canonical_bytes(raw) != core_bytes: + raise DeltaError(cat, "core.json is not canonical bytes (sorted keys + newline)") return _parse_core(raw, cat) @@ -742,17 +850,19 @@ def check_baseline_authority(candidates: dict[str, Any], base_core: dict[str, An f"baseline-authority mismatch on '{field}' for {fid}") -def check_target_identity(core: dict[str, Any], rel: str, cat: str) -> None: - """Every finding used by Step 10 must carry file == the declared target rel (LA/§3).""" +def check_target_identity(core: dict[str, Any], rel: str) -> None: + """Every finding used by Step 10 must carry file == the declared target rel (LA/§3). + A foreign / absolute / escaping / image-mismatched identity is ANALYSIS_SCOPE (R6) — a + scope error, never a per-image analysis error.""" for o in core["all_own001"]: if o["file"] != rel: - raise DeltaError(cat, f"OWN001 attributed to {o['file']!r} != target {rel!r}") + raise DeltaError(ANALYSIS_SCOPE, f"OWN001 file {o['file']!r} != target {rel!r}") for o in core["own050"]: if o["file"] != rel: - raise DeltaError(cat, f"OWN050 attributed to {o['file']!r} != target {rel!r}") + raise DeltaError(ANALYSIS_SCOPE, f"OWN050 file {o['file']!r} != target {rel!r}") for e in core["fix_eligible_subscriptions"]: if e["record"]["file"] != rel or e["bridge_key"]["file"] != rel: - raise DeltaError(cat, f"candidate attributed to a file != target {rel!r}") + raise DeltaError(ANALYSIS_SCOPE, f"candidate attributed to a file != target {rel!r}") # --- the extractor toolchain + hermetic reference/runtime snapshots (LA2/LA4/LA5) -- @@ -845,8 +955,26 @@ def snapshot_extractor_deployment(work: str, return os.path.join(dst_root, name), fingerprint +def _parse_version(v: str) -> tuple[int, int, int] | None: + """A STABLE numeric major.minor.patch, or None for a prerelease / build-metadata / + non-three-component version (prereleases are never eligible, R1).""" + if not isinstance(v, str) or "-" in v or "+" in v: + return None + parts = v.split(".") + if len(parts) != 3: + return None + try: + nums = tuple(int(p) for p in parts) + except ValueError: + return None + if any(n < 0 for n in nums): + return None + return (nums[0], nums[1], nums[2]) + + def _read_runtimeconfig(dll_dst: str) -> tuple[str, str, str]: - """(tfm, framework_name, framework_version) from the snapshotted runtimeconfig.json.""" + """(tfm, framework_name, requested_minimum_version) from the snapshotted runtimeconfig. + The requested version must be a stable numeric major.minor.patch (R1).""" base = dll_dst[:-4] if dll_dst.lower().endswith(".dll") else dll_dst data = _snapshot(base + ".runtimeconfig.json", TOOLCHAIN_BINDING, "runtimeconfig.json") try: @@ -854,12 +982,17 @@ def _read_runtimeconfig(dll_dst: str) -> tuple[str, str, str]: except ValueError as exc: raise DeltaError(TOOLCHAIN_BINDING, f"runtimeconfig.json is invalid ({exc})") from exc opts = rc.get("runtimeOptions") if isinstance(rc, dict) else None - fw = opts.get("framework") if isinstance(opts, dict) else None + if not isinstance(opts, dict): + raise DeltaError(TOOLCHAIN_BINDING, "runtimeconfig.json has no runtimeOptions object") + fw = opts.get("framework") if not isinstance(fw, dict): raise DeltaError(TOOLCHAIN_BINDING, "runtimeconfig.json has no single framework object") tfm, fname, fver = opts.get("tfm"), fw.get("name"), fw.get("version") if not (isinstance(tfm, str) and isinstance(fname, str) and isinstance(fver, str)): raise DeltaError(TOOLCHAIN_BINDING, "runtimeconfig framework tuple is incomplete") + if _parse_version(fver) is None: + raise DeltaError(TOOLCHAIN_BINDING, + f"requested framework version {fver!r} is not stable major.minor.patch") return tfm, fname, fver @@ -873,48 +1006,78 @@ def _run_capture(argv: list[str], cat: str, what: str) -> str: return proc.stdout +def _select_runtime(listing: str, fname: str, fver: str) -> tuple[str, str]: + """Pure runtime selection (R1): from `dotnet --list-runtimes` text, among the requested + framework name keep STABLE releases of the SAME major.minor that are >= the requested + minimum, and return (highest_selected_version, runtime_dir). No eligible runtime is + TOOLCHAIN_BINDING.""" + req = _parse_version(fver) + if req is None: + raise DeltaError(TOOLCHAIN_BINDING, f"requested version {fver!r} is not major.minor.patch") + eligible: list[tuple[tuple[int, int, int], str, str]] = [] + for line in listing.splitlines(): + parts = line.strip().split(" ", 2) + if len(parts) != 3 or parts[0] != fname: + continue + vt = _parse_version(parts[1]) + if vt is None or vt[0] != req[0] or vt[1] != req[1] or vt < req: + continue + path = parts[2].strip() + if path.startswith("[") and path.endswith("]"): + path = path[1:-1] + eligible.append((vt, parts[1], os.path.join(path, parts[1]))) + if not eligible: + raise DeltaError(TOOLCHAIN_BINDING, + f"no eligible {fname} {req[0]}.{req[1]}.x runtime >= {fver} is installed") + eligible.sort(key=lambda e: e[0]) + _vt, selected_ver, rt_dir = eligible[-1] + return selected_ver, rt_dir + + +def _runtime_manifest(rt_dir: str) -> str: + """A follow-symlink file manifest of the selected runtime directory (trusted infra: the + .NET shared framework legitimately contains symlinks; each is hashed by its real target). + os.walk with followlinks=False lists symlinked FILES but does not recurse symlinked dirs.""" + manifest: list[dict[str, str]] = [] + for dirpath, _dirnames, filenames in os.walk(rt_dir): + for fn in filenames: + full = os.path.join(dirpath, fn) + rel = os.path.relpath(full, rt_dir).replace(os.sep, "/") + manifest.append({"path": rel, + "sha256": _hash_resolved(full, TOOLCHAIN_BINDING, f"runtime {rel}")}) + manifest.sort(key=lambda m: m["path"]) + return _sha_bytes(_canonical_json(manifest)) + + def resolve_runtime(dotnet_host: str, tfm: str, fname: str, - fver: str) -> tuple[dict[str, Any], str, str]: - """Prove the EXACT requested runtime is installed (roll-forward disabled), snapshot its - file manifest, and return (resolved_runtime_identity, dotnet_version, dotnet_host_sha256). - An unavailable / ambiguous / malformed runtime is TOOLCHAIN_BINDING (LA D4).""" - host_data = _snapshot(dotnet_host, TOOLCHAIN_BINDING, "dotnet host") - dotnet_host_sha256 = _sha_bytes(host_data) + fver: str) -> tuple[dict[str, Any], str, str, str, str]: + """Select the runtime the host will actually run (R1): among installed runtimes for the + requested framework name, keep the STABLE releases of the SAME major.minor that are >= the + requested minimum, and select the highest patch deterministically. Publish both requested + and selected versions; fingerprint the SELECTED runtime directory. Returns + (resolved_runtime_identity, dotnet_version, dotnet_host_sha256, selected_version, rt_dir). + No eligible runtime is TOOLCHAIN_BINDING.""" + dotnet_host_sha256 = _hash_resolved(dotnet_host, TOOLCHAIN_BINDING, "dotnet host") dotnet_version = _run_capture([dotnet_host, "--version"], TOOLCHAIN_BINDING, "dotnet --version").strip() listing = _run_capture([dotnet_host, "--list-runtimes"], TOOLCHAIN_BINDING, "dotnet --list-runtimes") - matches: list[str] = [] - for line in listing.splitlines(): - parts = line.strip().split(" ", 2) - if len(parts) == 3 and parts[0] == fname and parts[1] == fver: - path = parts[2].strip() - if path.startswith("[") and path.endswith("]"): - path = path[1:-1] - matches.append(os.path.join(path, fver)) - if len(matches) != 1: - raise DeltaError(TOOLCHAIN_BINDING, - f"expected exactly one {fname} {fver} runtime, found {len(matches)}") - rt_dir = matches[0] + selected_ver, rt_dir = _select_runtime(listing, fname, fver) if not os.path.isdir(rt_dir): - raise DeltaError(TOOLCHAIN_BINDING, "the resolved runtime directory does not exist") - manifest: list[dict[str, str]] = [] - for rel in _walk_regular_files(rt_dir, TOOLCHAIN_BINDING): - data = _snapshot(os.path.join(rt_dir, rel.replace("/", os.sep)), - TOOLCHAIN_BINDING, f"runtime {rel}") - manifest.append({"path": rel, "sha256": _sha_bytes(data)}) - manifest.sort(key=lambda m: m["path"]) - identity = {"framework_name": fname, "framework_version": fver, "tfm": tfm, - "runtime_manifest_sha256": _sha_bytes(_canonical_json(manifest))} - return identity, dotnet_version, dotnet_host_sha256 + raise DeltaError(TOOLCHAIN_BINDING, "the selected runtime directory does not exist") + identity = {"framework_name": fname, "tfm": tfm, "requested_framework_version": fver, + "selected_framework_version": selected_ver, + "runtime_manifest_sha256": _runtime_manifest(rt_dir)} + return identity, dotnet_version, dotnet_host_sha256, selected_ver, rt_dir def _dotnet_env(work: str, image_dir: str) -> dict[str, str]: + """Env for the extractor process. Roll-forward is NOT set here — it is authoritative on + the argv (`--roll-forward Disable`, R1.8) so a broad env policy cannot override it.""" env: dict[str, str] = {} - for k in ("SystemRoot", "SYSTEMROOT", "windir", "PATH", "HOME", "LANG", "LC_ALL"): + for k in ("SystemRoot", "SYSTEMROOT", "windir", "PATH", "LANG", "LC_ALL"): if k in os.environ: env[k] = os.environ[k] - env["DOTNET_ROLL_FORWARD"] = "Disable" env["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1" env["DOTNET_NOLOGO"] = "1" env["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1" @@ -927,12 +1090,16 @@ def _dotnet_env(work: str, image_dir: str) -> dict[str, str]: return env -def extract_image(work: str, dll_dst: str, dotnet_host: str, image_dir: str, rel: str, - target: str, slot_dirs: list[str], cat: str) -> bytes: - """Run the fixed `dotnet exec extract ...` over the materialized target file and - return the facts.json bytes. The image workspace root is the process CWD.""" - argv = [dotnet_host, "exec", dll_dst, "extract", rel, "--out", "facts.json", - "--fix-candidates", "--weak-subscribe", target] +def extract_image(work: str, dll_dst: str, dotnet_host: str, selected_ver: str, + image_dir: str, rel: str, target: str, slot_dirs: list[str], + cat: str) -> bytes: + """Run the fixed extractor over the materialized target file and return the facts.json + bytes. The selected runtime is pinned on the argv (`--fx-version + --roll-forward Disable`, R1) so the host runs exactly the fingerprinted runtime. The + image workspace root is the process CWD.""" + argv = [dotnet_host, "exec", "--fx-version", selected_ver, "--roll-forward", "Disable", + dll_dst, "extract", rel, "--out", "facts.json", "--fix-candidates", + "--weak-subscribe", target] for slot in slot_dirs: argv += ["--ref-dir", slot] os.makedirs(os.path.join(work, "home"), exist_ok=True) @@ -948,12 +1115,77 @@ def extract_image(work: str, dll_dst: str, dotnet_host: str, image_dir: str, rel # --- evidence assembly + the full orchestration ------------------------------------ +def _manifest_sha(root: str, rels: list[str], cat: str) -> str: + m = [{"path": rel, + "sha256": _sha_bytes(_snapshot(os.path.join(root, rel.replace("/", os.sep)), cat, rel))} + for rel in rels] + m.sort(key=lambda x: x["path"]) + return _sha_bytes(_canonical_json(m)) + + +def _revalidate_toolchain(work: str, ext_fp: dict[str, Any], core_fp: dict[str, Any], + runner_path: str, runner_sha: str, python_exe: str, + python_fp: dict[str, Any], dotnet_host: str, dotnet_host_sha: str, + rt_dir: str, runtime_manifest_sha: str, slot_dirs: list[str], + ref_evidence: list[dict[str, Any]]) -> None: + """Re-derive every MATERIALIZED toolchain hash and require it unchanged (R2). The copy is + the execution authority, so mutation of any original caller directory after snapshot must + not affect execution — this proves it. Any drift is TOOLCHAIN_BINDING.""" + tdir = os.path.join(work, "toolchain") + if _manifest_sha(tdir, _walk_regular_files(tdir, TOOLCHAIN_BINDING), TOOLCHAIN_BINDING) \ + != ext_fp["extractor_deployment_manifest_sha256"]: + raise DeltaError(TOOLCHAIN_BINDING, "the materialized extractor deployment changed") + pkg = os.path.join(work, "core", "ownlang") + if _manifest_sha(pkg, _walk_pkg(pkg), TOOLCHAIN_BINDING) != core_fp["ownlang_manifest_sha256"]: + raise DeltaError(TOOLCHAIN_BINDING, "the materialized ownlang package changed") + _verify_runner(runner_path, runner_sha) + if _hash_resolved(python_exe, TOOLCHAIN_BINDING, "python") \ + != python_fp["python_executable_sha256"]: + raise DeltaError(TOOLCHAIN_BINDING, "the Python executable changed") + if _hash_resolved(dotnet_host, TOOLCHAIN_BINDING, "dotnet host") != dotnet_host_sha: + raise DeltaError(TOOLCHAIN_BINDING, "the dotnet host changed") + if _runtime_manifest(rt_dir) != runtime_manifest_sha: + raise DeltaError(TOOLCHAIN_BINDING, "the selected runtime manifest changed") + for i, ev in enumerate(ref_evidence): + dll = os.path.join(slot_dirs[i], ev["relative_path"].rsplit("/", 1)[-1]) + if _sha_bytes(_snapshot(dll, TOOLCHAIN_BINDING, "reference slot")) != ev["sha256"]: + raise DeltaError(TOOLCHAIN_BINDING, "a materialized reference slot changed") + + +def _isolation_snapshot(root: str, rel: str) -> dict[str, str | None]: + """Hash the protected surfaces: the pristine target file, and .git/index / .git/config + when each is present as a regular file (R2).""" + snap: dict[str, str | None] = { + "target": _sha_bytes(_snapshot(os.path.join(root, *rel.split("/")), + ISOLATION, "pristine target file")), + } + for name in ("index", "config"): + p = os.path.join(root, ".git", name) + try: + regular = os.path.isfile(p) and not _is_link(os.lstat(p)) + except OSError: + regular = False + snap[name] = _sha_bytes(_snapshot(p, ISOLATION, f".git/{name}")) if regular else None + return snap + + +def _isolation_verify(before: dict[str, str | None], root: str, rel: str) -> None: + if _isolation_snapshot(root, rel) != before: + raise DeltaError(ISOLATION, "a protected surface (target/.git index/config) changed") + + def build_evidence(rel: str, class_fqn: str, ref_count: int, input_hashes: dict[str, Any], gate_binding: dict[str, Any], toolchain_fp: dict[str, Any], reference_closure: list[dict[str, Any]], target_subscribe: str, - expected: dict[str, Any], classified: dict[str, Any]) -> dict[str, Any]: - """Assemble the full delta-result.json object (Section 14) — every check is 'pass' in a - published artifact, and the check-name set is exactly the fixed seventeen names.""" + expected: dict[str, Any], classified: dict[str, Any], + checks_passed: set[str]) -> dict[str, Any]: + """Assemble the full delta-result.json (Section 14). A check is published 'pass' ONLY if + it was actually executed (R2): `checks_passed` is the set of executed-and-passed checks, + and it MUST be exactly the seventeen names — an incomplete set is an orchestration bug, + never a silently-filled artifact.""" + if checks_passed != set(_CHECK_NAMES): + missing = sorted(set(_CHECK_NAMES) - checks_passed) + raise DeltaError(INFRASTRUCTURE, f"refusing to publish unexecuted checks: {missing}") return { "schema": 1, "operation": "verify-subscription-analyzer-delta", @@ -973,7 +1205,7 @@ def build_evidence(rel: str, class_fqn: str, ref_count: int, input_hashes: dict[ "postimage": classified["postimage"], "delta": classified["delta"], "semantic_idempotence": classified["semantic_idempotence"], - "checks": {name: "pass" for name in _CHECK_NAMES}, + "checks": dict.fromkeys(_CHECK_NAMES, "pass"), } @@ -1000,10 +1232,12 @@ def run_verify_delta(bundle: str, plan_path: str, candidates_path: str, root: st OWN001-only guard, bind the Step 9 evidence, snapshot the extractor + core toolchains, analyze the pristine preimage and the accepted postimage in hermetic workspaces, verify the delta, and publish delta-result.json atomically. Returns the published path.""" + passed: set[str] = set() plan_bytes = _snapshot(plan_path, INPUT_LAYOUT, "--plan") candidates_bytes = _snapshot(candidates_path, INPUT_LAYOUT, "--candidates") gate_bytes = _snapshot(gate_path, INPUT_LAYOUT, "--gate") auth, _plan, candidates = load_authority(plan_bytes, candidates_bytes) + passed.add("authority_binding") rel = auth.rel class_fqn = candidates["selection"]["allowed_types"][0]["full_name"] target = auth.target_subscribe @@ -1016,9 +1250,11 @@ def run_verify_delta(bundle: str, plan_path: str, candidates_path: str, root: st INPUT_LAYOUT, "postimage") preimage_bytes = _snapshot(os.path.join(root, *rel.split("/")), INPUT_LAYOUT, "preimage") pre_sha, post_sha = _sha_bytes(preimage_bytes), _sha_bytes(postimage_bytes) + passed.add("input_layout") gate_sha = bind_gate(gate_bytes, auth, plan_bytes, manifest_bytes, patch_bytes, pre_sha, post_sha) + passed.add("gate_binding") git_status = "not_applicable" if not auth.applied else "pass" work = tempfile.mkdtemp(prefix="owen-delta-") @@ -1028,33 +1264,50 @@ def run_verify_delta(bundle: str, plan_path: str, candidates_path: str, root: st dll_dst, ext_fp = snapshot_extractor_deployment(work, extractor_dll) tfm, fname, fver = _read_runtimeconfig(dll_dst) dotnet_host = _resolve_dotnet_host() - runtime_identity, dotnet_version, dotnet_host_sha = resolve_runtime( + runtime_identity, dotnet_version, dotnet_host_sha, selected_ver, rt_dir = resolve_runtime( dotnet_host, tfm, fname, fver) slot_dirs, ref_evidence = snapshot_reference_closure(work, ref_dirs) + runtime_manifest_sha = runtime_identity["runtime_manifest_sha256"] + + def revalidate() -> None: + _revalidate_toolchain(work, ext_fp, core_fp, runner_path, runner_sha, python_exe, + python_fp, dotnet_host, dotnet_host_sha, rt_dir, + runtime_manifest_sha, slot_dirs, ref_evidence) + + # the pristine target's isolation baseline (re-checked after the postimage run) + iso_before = _isolation_snapshot(root, rel) def analyze(label: str, image_bytes: bytes, cat: str) -> dict[str, Any]: - _verify_runner(runner_path, runner_sha) + revalidate() # materialized toolchain unchanged before this image (R2/LA1) image_dir = os.path.join(work, label) src = os.path.join(image_dir, *rel.split("/")) os.makedirs(os.path.dirname(src), exist_ok=True) with open(src, "wb") as fh: fh.write(image_bytes) - facts_bytes = extract_image(work, dll_dst, dotnet_host, image_dir, rel, target, - slot_dirs, cat) + facts_bytes = extract_image(work, dll_dst, dotnet_host, selected_ver, image_dir, + rel, target, slot_dirs, cat) params = {"root": image_dir, "target_subscribe": target, "class_fqn": class_fqn} core = run_core(core_dir, runner_path, python_exe, runner_sha, image_dir, facts_bytes, params, cat) - check_target_identity(core, rel, cat) + check_target_identity(core, rel) return core base_core = analyze("baseline", preimage_bytes, BASELINE_ANALYSIS) + passed.add("baseline_analysis") post_core = analyze("postimage", postimage_bytes, POSTIMAGE_ANALYSIS) - _verify_runner(runner_path, runner_sha) # after the postimage run (LA1) + passed.add("postimage_analysis") + revalidate() # materialized toolchain unchanged after the postimage run (R2/LA1) + passed.update({"toolchain_binding", "core_analyzer_binding"}) + _isolation_verify(iso_before, root, rel) + passed.add("isolation") check_baseline_authority(candidates, base_core) + passed.add("baseline_authority") classified = classify_delta( {"convert_acquire_ids": auth.applied, "manual_review_ids": auth.manual}, base_core, post_core) + passed.update({"analysis_scope", "analysis_identity", "delta_subscription", + "delta_core", "new_own001", "new_own050", "semantic_idempotence"}) toolchain_fp = { **ext_fp, @@ -1075,8 +1328,10 @@ def analyze(label: str, image_bytes: bytes, cat: str) -> dict[str, Any]: "step9_version": 1, "git_gates_status": git_status, "bound": True} expected = {"convert_acquire_ids": sorted(auth.applied), "manual_review_ids": sorted(auth.manual)} + passed.add("publication") # we are publishing exactly one atomic OUTPUT_DIR now evidence = build_evidence(rel, class_fqn, len(ref_dirs), input_hashes, gate_binding, - toolchain_fp, ref_evidence, target, expected, classified) + toolchain_fp, ref_evidence, target, expected, classified, + passed) return _publish_delta(out, root, canonical_evidence(evidence)) finally: shutil.rmtree(work, ignore_errors=True) diff --git a/tests/test_verify_delta.py b/tests/test_verify_delta.py index fd0515b1..050d0f87 100644 --- a/tests/test_verify_delta.py +++ b/tests/test_verify_delta.py @@ -23,7 +23,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from ownlang import fix_delta as fd -from ownlang.fix_gate import _build_evidence, _bundle_sha256, validate_gate_authority +from ownlang.fix_gate import _build_evidence, _bundle_sha256 _EV = "System.ComponentModel.INotifyPropertyChanged.PropertyChanged" _FILE = "Own/Sample.cs" @@ -227,8 +227,8 @@ def params(root: str) -> dict: candidates = _candidates_from([recs["OnA"]["record"], recs["OnB"]["record"]]) fd.check_baseline_authority(candidates, base) - fd.check_target_identity(base, _CREL, fd.BASELINE_ANALYSIS) - fd.check_target_identity(post, _CREL, fd.POSTIMAGE_ANALYSIS) + fd.check_target_identity(base, _CREL) + fd.check_target_identity(post, _CREL) res = fd.classify_delta({"convert_acquire_ids": [fid_a], "manual_review_ids": [fid_b]}, base, post) cf(res["delta"]["removed_subscription_own001_ids"] == [fid_a], "fixture: OnA removed") @@ -264,7 +264,173 @@ def params(root: str) -> dict: return checks, fails -def run() -> int: # noqa: C901 — a flat battery of independent assertions +def _elig_k(fid: str, ordinal: int, handler: str = "OnA", event: str = "_a.PropertyChanged", + dc: str = "OWN001") -> dict: + """A fix-eligible record sharing the SAME bridge key K but a distinct finding_id (via a + distinct occurrence_ordinal) — the identical-K duplicate the bridge must count.""" + e = _elig(fid, event, handler, dc=dc, source="_a") + e["record"]["occurrence_ordinal"] = ordinal + return e + + +def _amendment_regressions() -> tuple[int, list[str]]: + """R1 (runtime selection), R3 (image bridge), R4 (core.json bytes), R5 (publish cleanup), + R2 (toolchain / isolation mutation) — all offline (no dotnet).""" + checks = 0 + fails: list[str] = [] + + def cf(cond: bool, label: str) -> None: + nonlocal checks + checks += 1 + if not cond: + fails.append(label) + + NC = "Microsoft.NETCore.App" + + # --- R1: version parsing + runtime selection -------------------------------- + cf(fd._parse_version("8.0.28") == (8, 0, 28), "R1: parse stable version") + cf(fd._parse_version("8.0.0-rc.1") is None, "R1: prerelease -> None") + cf(fd._parse_version("8.0") is None, "R1: two-component -> None") + sel, _d = fd._select_runtime(f"{NC} 8.0.28 [/p]\nMicrosoft.AspNetCore.App 8.0.28 [/q]\n", + NC, "8.0.0") + cf(sel == "8.0.28", "R1: 8.0.0 requested, only 8.0.28 -> selects 8.0.28") + sel2, _ = fd._select_runtime(f"{NC} 8.0.5 [/a]\n{NC} 8.0.28 [/b]\n{NC} 8.0.11 [/c]\n", + NC, "8.0.0") + cf(sel2 == "8.0.28", "R1: multiple patches -> highest") + cf(_raises(fd.TOOLCHAIN_BINDING, fd._select_runtime, f"{NC} 8.0.5 [/a]\n", NC, "8.0.10"), + "R1: lower-than-minimum -> refuse") + cf(_raises(fd.TOOLCHAIN_BINDING, fd._select_runtime, f"{NC} 8.1.0 [/a]\n", NC, "8.0.0"), + "R1: different minor -> refuse") + cf(_raises(fd.TOOLCHAIN_BINDING, fd._select_runtime, f"{NC} 8.0.0-preview.1 [/a]\n", + NC, "8.0.0"), "R1: prerelease-only -> refuse") + cf(_raises(fd.TOOLCHAIN_BINDING, fd._select_runtime, "", NC, "8.0.0"), + "R1: none installed -> refuse") + + # --- R3: image-level bridge for both images / both actions ------------------ + obs_a, obs_b = _obs("_a.PropertyChanged", "OnA"), _obs("_b.PropertyChanged", "OnB") + ea = _elig(_FIDA, "_a.PropertyChanged", "OnA", source="_a") + eb = _elig(_FIDB, "_b.PropertyChanged", "OnB", source="_b") + cf(_raises(fd.ANALYSIS_IDENTITY, fd.classify_delta, + {"convert_acquire_ids": [], "manual_review_ids": [_FIDB]}, + _image([], [], [eb]), _image([obs_b], [], [eb])), + "R3: manual candidate with no baseline core -> ANALYSIS_IDENTITY") + cf(_raises(fd.ANALYSIS_IDENTITY, fd.classify_delta, + {"convert_acquire_ids": [], "manual_review_ids": [_FIDB]}, + _image([obs_b], [], [eb]), _image([], [], [eb])), + "R3: manual candidate with no postimage core -> ANALYSIS_IDENTITY") + cf(_raises(fd.ANALYSIS_IDENTITY, fd.classify_delta, + {"convert_acquire_ids": [_FIDA], "manual_review_ids": [_FIDB]}, + _image([obs_a], [], [ea, eb]), _image([obs_a], [], [ea, eb])), + "R3: extra eligible fact without core -> ANALYSIS_IDENTITY") + cf(_raises(fd.ANALYSIS_IDENTITY, fd.classify_delta, + {"convert_acquire_ids": [_FIDA], "manual_review_ids": []}, + _image([obs_a, obs_a], [], [ea]), _image([], [], [])), + "R3: surplus core observation -> ANALYSIS_IDENTITY") + # identical-K duplicate, same action -> valid (cardinality 2 == 2) + d1, d2 = _elig_k(_FIDA, 0), _elig_k(_FIDC, 1) + res_dup = fd.classify_delta({"convert_acquire_ids": [_FIDA, _FIDC], "manual_review_ids": []}, + _image([obs_a, obs_a], [], [d1, d2]), _image([], [], [])) + cf(len(res_dup["delta"]["removed_all_own001"]) == 2, "R3: identical-action duplicate group ok") + # identical-K mixed action -> ANALYSIS_IDENTITY + cf(_raises(fd.ANALYSIS_IDENTITY, fd.classify_delta, + {"convert_acquire_ids": [_FIDA], "manual_review_ids": [_FIDC]}, + _image([obs_a, obs_a], [], [d1, d2]), _image([obs_a], [], [d1])), + "R3: mixed-action collision under one K -> ANALYSIS_IDENTITY") + + # --- R4: closed core.json byte protocol ------------------------------------- + core_obj = {"version": 1, "operation": "verify-subscription-core-observations", + "all_own001": [obs_a], "own050": [], "fix_eligible_subscriptions": [ea]} + good = fd.canonical_evidence(core_obj) + cf(fd._load_core(good, fd.BASELINE_ANALYSIS)["all_own001"] == [obs_a], "R4: canonical loads") + cf(_raises(fd.BASELINE_ANALYSIS, fd._load_core, good[:-1], fd.BASELINE_ANALYSIS), + "R4: missing trailing newline -> refuse") + cf(_raises(fd.BASELINE_ANALYSIS, fd._load_core, + json.dumps(core_obj, indent=2).encode() + b"\n", fd.BASELINE_ANALYSIS), + "R4: non-canonical bytes -> refuse") + cf(_raises(fd.BASELINE_ANALYSIS, fd._load_core, + fd.canonical_evidence({**core_obj, "extra": 1}), fd.BASELINE_ANALYSIS), + "R4: unknown key -> refuse") + cf(_raises(fd.BASELINE_ANALYSIS, fd._load_core, + fd.canonical_evidence({**core_obj, "all_own001": [{**obs_a, "advisory": "no"}]}), + fd.BASELINE_ANALYSIS), "R4: non-bool advisory not coerced -> refuse") + bad_rec = _record(_FIDA) + bad_rec["occurrence_ordinal"] = "x" + cf(_raises(fd.BASELINE_ANALYSIS, fd._load_core, + fd.canonical_evidence({**core_obj, "fix_eligible_subscriptions": + [{**ea, "record": bad_rec}]}), fd.BASELINE_ANALYSIS), + "R4: wrong-typed record field -> refuse") + + # --- R5: publication cleanup failure -> PUBLICATION ------------------------- + with tempfile.TemporaryDirectory() as tmp: + root, pub = os.path.join(tmp, "root"), os.path.join(tmp, "pub") + os.makedirs(root) + os.makedirs(pub) + out = os.path.join(pub, "ev") + orig_rename, orig_rmtree = fd.os.rename, fd.shutil.rmtree + + def _boom(*_a, **_k): + raise OSError("boom") + + try: + fd.os.rename = _boom # force a pre-succeeded failure + fd.shutil.rmtree = _boom # force the cleanup itself to fail + cf(_raises(fd.PUBLICATION, fd._publish_delta, out, root, b'{}\n'), + "R5: cleanup failure -> PUBLICATION") + finally: + fd.os.rename, fd.shutil.rmtree = orig_rename, orig_rmtree + cf(not os.path.exists(out), "R5: no OUTPUT_DIR after a failed publish") + + # --- R2: toolchain + isolation mutation regressions ------------------------- + with tempfile.TemporaryDirectory() as work: + _core_dir, _rp, _rs, core_fp = fd.materialize_core(work) + pkg = os.path.join(work, "core", "ownlang") + before = fd._manifest_sha(pkg, fd._walk_pkg(pkg), fd.TOOLCHAIN_BINDING) + cf(before == core_fp["ownlang_manifest_sha256"], "R2: ownlang manifest reproducible") + with open(os.path.join(pkg, "ownir.py"), "ab") as fh: + fh.write(b"\n# mutate\n") + after = fd._manifest_sha(pkg, fd._walk_pkg(pkg), fd.TOOLCHAIN_BINDING) + cf(after != core_fp["ownlang_manifest_sha256"], "R2: ownlang mutation detected") + + with tempfile.TemporaryDirectory() as tmp: + work = os.path.join(tmp, "w") + os.makedirs(work) + rd = os.path.join(tmp, "refs") + os.makedirs(rd) + with open(os.path.join(rd, "A.dll"), "wb") as fh: + fh.write(b"A0") + slots, evid = fd.snapshot_reference_closure(work, [rd]) + dll = os.path.join(slots[0], "A.dll") + with open(dll, "ab") as fh: + fh.write(b"tamper") + rehash = fd._sha_bytes(fd._snapshot(dll, fd.TOOLCHAIN_BINDING, "slot")) + cf(rehash != evid[0]["sha256"], "R2: reference-slot mutation detected") + + with tempfile.TemporaryDirectory() as tmp: + rel = "Own/Sample.cs" + p = os.path.join(tmp, *rel.split("/")) + os.makedirs(os.path.dirname(p)) + with open(p, "wb") as fh: + fh.write(b"class A {}\n") + os.makedirs(os.path.join(tmp, ".git")) + with open(os.path.join(tmp, ".git", "index"), "wb") as fh: + fh.write(b"idx-v1") + snap = fd._isolation_snapshot(tmp, rel) + fd._isolation_verify(snap, tmp, rel) # unchanged: must not raise + with open(p, "ab") as fh: + fh.write(b"// touched\n") + cf(_raises(fd.ISOLATION, fd._isolation_verify, snap, tmp, rel), + "R2: target-file mutation -> ISOLATION") + with open(p, "wb") as fh: + fh.write(b"class A {}\n") # restore target + with open(os.path.join(tmp, ".git", "index"), "wb") as fh: + fh.write(b"idx-v2") + cf(_raises(fd.ISOLATION, fd._isolation_verify, snap, tmp, rel), + "R2: .git/index mutation -> ISOLATION") + + return checks, fails + + +def run() -> int: ok = 0 bad = 0 @@ -394,7 +560,7 @@ def check(cond: bool, label: str) -> None: os.makedirs(root) os.makedirs(pub) out = os.path.join(pub, "evidence") - published = fd._publish_delta(out, root, b'{"ok":true}\n') + fd._publish_delta(out, root, b'{"ok":true}\n') names = sorted(os.listdir(out)) check(names == ["delta-result.json"], "publish leaves only delta-result.json") with open(os.path.join(out, "delta-result.json"), "rb") as fh: @@ -503,9 +669,15 @@ def tam(mut) -> bytes: "delta": {"removed_all_own001": []}, "semantic_idempotence": {"converted_ids_still_actionable": [], "pass": True}} ev = fd.build_evidence("Own/X.cs", "N.X", 2, {"a": 1}, {"b": 2}, {"c": 3}, [], "T", - {"convert_acquire_ids": [], "manual_review_ids": []}, classified) + {"convert_acquire_ids": [], "manual_review_ids": []}, classified, + set(fd._CHECK_NAMES)) check(set(ev["checks"]) == set(fd._CHECK_NAMES) and len(fd._CHECK_NAMES) == 17, "evidence: exactly seventeen check names") + # an unexecuted check must NOT be published (R2) + check(_raises(fd.INFRASTRUCTURE, fd.build_evidence, "Own/X.cs", "N.X", 0, {}, {}, {}, [], + "T", {"convert_acquire_ids": [], "manual_review_ids": []}, classified, + set(fd._CHECK_NAMES) - {"isolation"}), + "evidence: unexecuted check -> INFRASTRUCTURE") check(all(v == "pass" for v in ev["checks"].values()), "evidence: every check passes") check(ev["schema"] == 1 and ev["operation"] == "verify-subscription-analyzer-delta", "evidence: schema + operation") @@ -522,6 +694,13 @@ def tam(mut) -> bytes: check(_raises(fd.INPUT_LAYOUT, fd._require_bundle_layout, b), "bundle layout incomplete -> INPUT_LAYOUT") + # --- R1-R5 amendment regressions (offline) -------------------------------- + achecks, afails = _amendment_regressions() + ok += achecks - len(afails) + bad += len(afails) + for f in afails: + print(f" FAIL: {f}") + # --- slices 3-5: the real fresh core subprocess (no dotnet) --------------- fchecks, ffails = _fixture_core_fails() ok += fchecks - len(ffails) From 3da385a334fe23dcea700b80308f92b580d7f32d Mon Sep 17 00:00:00 2001 From: PhysShell Date: Fri, 17 Jul 2026 17:12:16 +0500 Subject: [PATCH 7/9] test(s2-step10): R7 full public-CLI Tier B + R8 CI wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R7: rewrite the Tier-B test to exercise ONLY the public pipeline, never classify_delta / run_core / a private helper as its proof: real extractor -> candidates -> validate-plan -> apply (Owen rewriter) -> gate -> `own-fix subscriptions verify-delta` CLI -> published delta-result.json using the copied extractor deployment, the amended runtime resolver, real Roslyn extraction, and the real fresh snapshotted Python core. Cases: all-convert, manual-only, mixed (each exit 0 + full published-schema validation, including requested/selected runtime versions); deterministic byte-identical evidence across two independent invocations; an OWN014 candidate -> ANALYSIS_SCOPE; and forged-but-gate-valid bundles whose postimage introduces a new leak (-> NEW_OWN001) or an unresolved subscription (-> NEW_OWN050). Gating (R7): REQUIRED when OWN_TIERB_REQUIRED=1 — set in the wpf-extractor CI job, where a missing dotnet / extractor / execution failure is a FAILURE, not a skip. Any other context (the Tier-A tests job, the pack job, a local no-dotnet run) is the explicit non-required mode and skips cleanly, so the offline suite stays green. R8: the wpf-extractor CI step now runs the required Tier-B; combined with the lint / mypy / symlink fixes in the previous commit, the CI matrix is green. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G --- .github/workflows/ci.yml | 4 +- tests/test_verify_delta_tierb.py | 441 ++++++++++++++++++++++--------- 2 files changed, 322 insertions(+), 123 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 294200d2..39371efa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -256,7 +256,9 @@ jobs: frontend/roslyn/samples/OwnedCollectionElementSample.cs \ -o "$RUNNER_TEMP/facts.json" cat "$RUNNER_TEMP/facts.json" - - name: S2 step 10 analyzer-delta verifier (Tier B, real extractor + core) + - name: S2 step 10 analyzer-delta verifier (Tier B, full public CLI) + env: + OWN_TIERB_REQUIRED: "1" run: python tests/test_verify_delta_tierb.py - name: Check facts through the core run: | diff --git a/tests/test_verify_delta_tierb.py b/tests/test_verify_delta_tierb.py index 879f3970..15eb3c84 100644 --- a/tests/test_verify_delta_tierb.py +++ b/tests/test_verify_delta_tierb.py @@ -1,26 +1,33 @@ #!/usr/bin/env python3 -"""S2 step 10 — Tier B: the REAL Roslyn extractor -> real fresh core subprocess -> delta. - -This is the only tier that proves the analyzer-semantic claim with the real extractor. It -runs the checked-in OwnSharp.Extractor over a baseline (two INotifyPropertyChanged `+=` -leaks) and a postimage (one converted to the accepted weak wrapper), then drives the same -snapshotted-ownlang fresh core subprocess Step 10 uses and asserts the OWN001 delta -(converted gone, manual preserved). It needs dotnet; when dotnet is absent (the Tier-A -`tests (pyX)` job) it SKIPS cleanly so the offline suite stays green. CI runs it in the -`wpf-extractor` job. - -NOTE: this drives the extractor via `dotnet exec ` / `dotnet run --project` directly, -NOT through fix_delta.run_verify_delta's resolve_runtime — see the PR's limitation note on -the locked DOTNET_ROLL_FORWARD=Disable + exact-version rule (a runtimeconfig pinned at -x.0.0 has no exact installed match when only patch runtimes exist). - -Run: python tests/test_verify_delta_tierb.py +"""S2 step 10 — Tier B: the FULL public CLI acceptance (R7). + +This exercises the real pipeline end to end through the PUBLIC command line only — +never classify_delta / run_core / a private helper as its proof: + + real extractor -> candidates -> validate-plan -> apply (Owen rewriter) -> gate + -> `own-fix subscriptions verify-delta` (copied extractor deployment, amended runtime + resolver, real Roslyn extraction, real fresh snapshotted Python core) + -> published delta-result.json + +Cases: all-convert, manual-only, mixed; deterministic byte-identical evidence across two +independent invocations; a NEW_OWN001 refusal (a forged postimage that introduces a leak); +an OWN014 refusal (ANALYSIS_SCOPE); and full published-schema validation. + +Gating: this is REQUIRED (a missing dotnet / extractor / execution failure is a FAILURE, +not a skip) exactly when OWN_TIERB_REQUIRED=1 — which the wpf-extractor CI job sets. In any +other context (the Tier-A `tests (pyX)` job, the pack job, a local no-dotnet run) it is the +explicit non-required mode and skips cleanly, so the offline suite stays green. + +Run: OWN_TIERB_REQUIRED=1 python tests/test_verify_delta_tierb.py """ from __future__ import annotations import glob +import hashlib +import json import os +import re import shutil import subprocess import sys @@ -28,16 +35,22 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -from ownlang import fix_delta as fd +from ownlang.fix_delta import _CHECK_NAMES -_REL = "Own/Samples/TwoOnOneLine.cs" -_FQN = "Own.Samples.TwoOnOneLine" -_TARGET = "WeakEvents.AddPropertyChanged" +_REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_EXT = os.path.join(_REPO, "frontend", "roslyn", "OwnSharp.Extractor") +_RW = os.path.join(_REPO, "frontend", "roslyn", "Owen.CSharp.Rewriter") +_REL = "Own/Samples/S.cs" +_FQN = "Own.Samples.S" -_BASE_CS = """using System.ComponentModel; +_TWO_LEAKS = """using System.ComponentModel; namespace Own.Samples { - public class TwoOnOneLine { - public TwoOnOneLine(INotifyPropertyChanged a, INotifyPropertyChanged b) { + static class WeakEvents { + public static void AddPropertyChanged( + INotifyPropertyChanged s, PropertyChangedEventHandler h) {} + } + public class S { + public S(INotifyPropertyChanged a, INotifyPropertyChanged b) { a.PropertyChanged += OnA; b.PropertyChanged += OnB; } void OnA(object s, PropertyChangedEventArgs e) {} @@ -46,129 +59,313 @@ } """ -_POST_CS = """using System.ComponentModel; + +class Fail(Exception): + pass + + +def _sha(b: bytes) -> str: + return "sha256:" + hashlib.sha256(b).hexdigest() + + +def _run(argv: list[str], cwd: str | None = None) -> subprocess.CompletedProcess: + return subprocess.run(argv, cwd=cwd, capture_output=True, text=True, check=False) + + +def _py(args: list[str], cwd: str | None = None) -> subprocess.CompletedProcess: + return _run([sys.executable, "-m", "ownlang", *args], cwd=cwd or _REPO) + + +def _find_dotnet() -> str | None: + return shutil.which("dotnet") or (r"C:\Program Files\dotnet\dotnet.exe" + if os.path.isfile(r"C:\Program Files\dotnet\dotnet.exe") + else None) + + +def _build(dotnet: str) -> str: + for proj in (_EXT, _RW): + p = _run([dotnet, "build", proj, "-c", "Release", "-v", "q", "--nologo"]) + if p.returncode != 0: + raise Fail(f"build {proj}: {p.stdout[-400:]}{p.stderr[-400:]}") + dlls = glob.glob(os.path.join(_EXT, "bin", "*", "*", "ownsharp-extract.dll")) + if not dlls: + raise Fail("extractor DLL not found after build") + return dlls[0] + + +def _mkroot(cs: str) -> str: + root = tempfile.mkdtemp() + p = os.path.join(root, *_REL.split("/")) + os.makedirs(os.path.dirname(p)) + with open(p, "wb") as fh: # binary LF: no newline translation, so the forge stays clean + fh.write(cs.replace("\r\n", "\n").encode("utf-8")) + return root + + +_ONE_LEAK = """using System.ComponentModel; namespace Own.Samples { static class WeakEvents { public static void AddPropertyChanged( INotifyPropertyChanged s, PropertyChangedEventHandler h) {} } - public class TwoOnOneLine { - public TwoOnOneLine(INotifyPropertyChanged a, INotifyPropertyChanged b) { - WeakEvents.AddPropertyChanged(a, OnA); b.PropertyChanged += OnB; + public class S { + public S(INotifyPropertyChanged a, INotifyPropertyChanged c) { + a.PropertyChanged += OnA; } void OnA(object s, PropertyChangedEventArgs e) {} - void OnB(object s, PropertyChangedEventArgs e) {} } } """ -def _find_dotnet() -> str | None: - host = shutil.which("dotnet") - if host: - return host - default = r"C:\Program Files\dotnet\dotnet.exe" - return default if os.path.isfile(default) else None - - -def _find_extractor_dll(repo: str) -> str | None: - hits = glob.glob(os.path.join(repo, "frontend", "roslyn", "OwnSharp.Extractor", - "bin", "*", "*", "ownsharp-extract.dll")) - return hits[0] if hits else None - - -def _extract(dotnet: str, dll: str | None, proj: str, image_dir: str) -> bytes: - """Run the real extractor over the materialized target file, return facts.json bytes.""" - common = ["extract", _REL, "--out", "facts.json", "--fix-candidates", - "--weak-subscribe", _TARGET] - if dll is not None: - argv = [dotnet, "exec", dll, *common] - else: - argv = [dotnet, "run", "--project", proj, "-c", "Release", "--", *common] - proc = subprocess.run(argv, cwd=image_dir, capture_output=True, text=True, check=False) - if proc.returncode != 0: - raise RuntimeError(f"extractor rc={proc.returncode}: {proc.stderr.strip()[:400]}") - with open(os.path.join(image_dir, "facts.json"), "rb") as fh: - return fh.read() +def _step8_patch(pre_bytes: bytes, post_bytes: bytes) -> bytes: + """A minimal, grammar-valid step-8 patch (preimage -> postimage): our own header lines + plus git's hunk body with any function-context heading stripped from the @@ headers.""" + with tempfile.TemporaryDirectory() as d: + a, b = os.path.join(d, "a"), os.path.join(d, "b") + with open(a, "wb") as fh: + fh.write(pre_bytes) + with open(b, "wb") as fh: + fh.write(post_bytes) + diff = _run(["git", "-c", "core.autocrlf=false", "diff", "--no-index", "--no-color", a, b]) + lines = diff.stdout.splitlines(keepends=True) + start = next((i for i, ln in enumerate(lines) if ln.startswith("@@")), None) + if start is None: + raise Fail("git diff produced no hunk") + body = [] + for ln in lines[start:]: + if ln.startswith("@@"): + ln = re.sub(r"^(@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@).*\n?$", r"\1\n", ln) + body.append(ln) + header = f"diff --git a/{_REL} b/{_REL}\n--- a/{_REL}\n+++ b/{_REL}\n" + return header.encode() + "".join(body).encode() + + +def _candidates(dotnet: str, dll: str, root: str, work: str) -> str: + facts = os.path.join(work, "fc.json") + p = _run([dotnet, "exec", dll, "extract", _REL, "--out", facts, "--fix-candidates", + "--weak-subscribe", "WeakEvents.AddPropertyChanged"], cwd=root) + if p.returncode != 0: + raise Fail(f"extract: {p.stderr[-400:]}") + own = os.path.join(work, "own.toml") + with open(own, "w", encoding="utf-8") as fh: + fh.write('[weak-subscription]\nsubscribe = ["WeakEvents.AddPropertyChanged"]\n') + cands = os.path.join(work, "candidates.json") + p = _py(["own-fix", "subscriptions", "candidates", facts, "--config", own, + "--class", _FQN, "--output", cands, "--root", root]) + if p.returncode != 0: + raise Fail(f"candidates: {p.stderr[-400:]}") + return cands + + +def _plan(cands: str, work: str, convert: set[str]) -> str: + from ownlang.fix_plan import validate_plan + with open(cands, encoding="utf-8") as fh: + c = json.load(fh) + decisions = [{"finding_id": x["finding_id"], + "action": "convert_acquire" if x["handler"] in convert else "manual_review"} + for x in c["candidates"]] + plan = os.path.join(work, "plan.json") + with open(plan, "w", encoding="utf-8") as fh: + json.dump(validate_plan(c, {"version": 1, "decisions": decisions}), fh) + return plan + + +def _apply_and_gate(dotnet: str, cands: str, plan: str, root: str, work: str, + tag: str) -> tuple[str, str]: + bundle = os.path.join(work, f"bundle-{tag}") + # the rewriter command is split with POSIX shell rules, so quote the (possibly-spaced) + # project path and use forward slashes; `dotnet` resolves from PATH (as in CI). + rewriter = f'dotnet run --project "{_RW.replace(os.sep, "/")}" -c Release --no-build --' + p = _py(["own-fix", "subscriptions", "apply", "--plan", plan, "--candidates", cands, + "--root", root, "--out", bundle, "--rewriter", rewriter]) + if p.returncode != 0: + raise Fail(f"apply: {p.stderr[-400:]}") + gate_out = os.path.join(work, f"gate-{tag}") + p = _py(["own-fix", "subscriptions", "gate", "--bundle", bundle, "--plan", plan, + "--candidates", cands, "--root", root, "--out", gate_out]) + if p.returncode != 0: + raise Fail(f"gate: {p.stderr[-400:]}") + return bundle, os.path.join(gate_out, "gate-result.json") + + +def _verify(dotnet: str, dll: str, bundle: str, plan: str, cands: str, root: str, + gate: str, out: str) -> subprocess.CompletedProcess: + env = dict(os.environ) + env["PATH"] = os.path.dirname(dotnet) + os.pathsep + env.get("PATH", "") + return subprocess.run( + [sys.executable, "-m", "ownlang", "own-fix", "subscriptions", "verify-delta", + "--bundle", bundle, "--plan", plan, "--candidates", cands, "--root", root, + "--gate", gate, "--extractor-dll", dll, "--out", out], + cwd=_REPO, capture_output=True, text=True, check=False, env=env) + + +def _schema_ok(path: str) -> None: + with open(path, "rb") as fh: + raw = fh.read() + obj = json.loads(raw) + canon = json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + if raw != canon.encode() + b"\n": + raise Fail("delta-result.json is not canonical bytes + trailing newline") + if obj["schema"] != 1 or obj["operation"] != "verify-subscription-analyzer-delta": + raise Fail("delta-result.json schema/operation wrong") + if set(obj["checks"]) != set(_CHECK_NAMES) or set(obj["checks"].values()) != {"pass"}: + raise Fail("delta-result.json checks are not the exact 17-name all-pass set") + rid = obj["toolchain_fingerprint"]["resolved_runtime_identity"] + if "requested_framework_version" not in rid or "selected_framework_version" not in rid: + raise Fail("resolved_runtime_identity missing requested/selected versions") def run() -> int: - repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + required = os.environ.get("OWN_TIERB_REQUIRED") == "1" + if not required: + print("verify-delta (Tier B): SKIP (non-required mode; set OWN_TIERB_REQUIRED=1)") + return 0 dotnet = _find_dotnet() if dotnet is None: - print("verify-delta (Tier B): SKIP (no dotnet host)") - return 0 - dll = _find_extractor_dll(repo) - proj = os.path.join(repo, "frontend", "roslyn", "OwnSharp.Extractor") + print("verify-delta (Tier B): FAIL — required but no dotnet host") + return 1 ok = 0 - bad = 0 + fails: list[str] = [] def check(cond: bool, label: str) -> None: - nonlocal ok, bad + nonlocal ok if cond: ok += 1 else: - bad += 1 - print(f" FAIL: {label}") - - with tempfile.TemporaryDirectory() as work: - base_root = os.path.join(work, "base") - post_root = os.path.join(work, "post") - for root, src in ((base_root, _BASE_CS), (post_root, _POST_CS)): - path = os.path.join(root, *_REL.split("/")) - os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, "w", encoding="utf-8") as fh: - fh.write(src) - try: - base_facts = _extract(dotnet, dll, proj, base_root) - post_facts = _extract(dotnet, dll, proj, post_root) - except (RuntimeError, OSError) as exc: - print(f"verify-delta (Tier B): SKIP (extractor unavailable: {exc})") - return 0 - - core_dir, runner_path, runner_sha, _fp = fd.materialize_core(work) - py, _pyfp = fd.resolve_python() - - def analyze(root: str, facts: bytes, cat: str) -> dict: - img = tempfile.mkdtemp(dir=work) - params = {"root": root, "target_subscribe": _TARGET, "class_fqn": _FQN} - return fd.run_core(core_dir, runner_path, py, runner_sha, img, facts, params, cat) - - base = analyze(base_root, base_facts, fd.BASELINE_ANALYSIS) - post = analyze(post_root, post_facts, fd.POSTIMAGE_ANALYSIS) - - check(sorted(o["handler"] for o in base["all_own001"]) == ["OnA", "OnB"], - "Tier B: real baseline has OnA + OnB OWN001") - check([o["handler"] for o in post["all_own001"]] == ["OnB"], - "Tier B: real postimage has only OnB (OnA converted)") - - recs = {e["record"]["handler"]: e for e in base["fix_eligible_subscriptions"]} - check(set(recs) == {"OnA", "OnB"}, "Tier B: two real fix-eligible subscriptions") - fid_a, fid_b = recs["OnA"]["finding_id"], recs["OnB"]["finding_id"] - candidates = {"version": 1, "operation": "fix-subscriptions", - "target_api": {"subscribe": _TARGET}, - "selection": {"allowed_types": [{"full_name": _FQN, "file": _REL}], - "selected_findings": None, - "constraints": {"max_types_changed": 1, "max_files_changed": 1, - "allow_helper_changes": False, - "allow_config_changes": False, - "allow_suppressions": False}}, - "source_files": [{"path": _REL, "sha256": "sha256:" + "0" * 64}], - "candidates": [recs["OnA"]["record"], recs["OnB"]["record"]]} - fd.check_baseline_authority(candidates, base) - fd.check_target_identity(base, _REL, fd.BASELINE_ANALYSIS) - fd.check_target_identity(post, _REL, fd.POSTIMAGE_ANALYSIS) - res = fd.classify_delta({"convert_acquire_ids": [fid_a], "manual_review_ids": [fid_b]}, - base, post) - check(res["delta"]["removed_subscription_own001_ids"] == [fid_a], - "Tier B: real converted OnA removed") - check(res["delta"]["preserved_subscription_own001_ids"] == [fid_b], - "Tier B: real manual OnB preserved") - - total = ok + bad - print(f"verify-delta (Tier B, real extractor+core): {ok}/{total} checks pass") - return 1 if bad else 0 + fails.append(label) + + try: + dll = _build(dotnet) + # convert one candidate to obtain a NON-empty selected runtime (--fx-version) and a + # real patch, and materialize the chain once per case. + with tempfile.TemporaryDirectory() as work: + root = _mkroot(_TWO_LEAKS) + cands = _candidates(dotnet, dll, root, work) + + for tag, convert in (("mixed", {"OnA"}), ("allconv", {"OnA", "OnB"}), + ("manual", set())): + plan = _plan(cands, work, convert) + bundle, gate = _apply_and_gate(dotnet, cands, plan, root, work, tag) + out = os.path.join(work, f"delta-{tag}") + p = _verify(dotnet, dll, bundle, plan, cands, root, gate, out) + check(p.returncode == 0, f"Tier B: {tag} verify-delta exits 0 ({p.stderr[-200:]})") + if p.returncode == 0: + _schema_ok(os.path.join(out, "delta-result.json")) + check(True, f"Tier B: {tag} published schema valid") + + # determinism: two independent invocations of the mixed case -> identical bytes + plan = _plan(cands, work, {"OnA"}) + bundle, gate = _apply_and_gate(dotnet, cands, plan, root, work, "det") + b1, b2 = os.path.join(work, "d1"), os.path.join(work, "d2") + _verify(dotnet, dll, bundle, plan, cands, root, gate, b1) + _verify(dotnet, dll, bundle, plan, cands, root, gate, b2) + with open(os.path.join(b1, "delta-result.json"), "rb") as fh: + r1 = fh.read() + with open(os.path.join(b2, "delta-result.json"), "rb") as fh: + r2 = fh.read() + check(r1 == r2, "Tier B: two invocations produce byte-identical evidence") + + # OWN014 refusal: forge a candidate's diagnostic_code -> ANALYSIS_SCOPE + _own014_case(dotnet, dll, root, work, check) + # NEW_OWN001 / NEW_OWN050 refusals: forge a postimage that introduces the anomaly + _forged_refusal(dotnet, dll, work, _POST_NEW_OWN001, "NEW_OWN001", "n1", check) + _forged_refusal(dotnet, dll, work, _POST_NEW_OWN050, "NEW_OWN050", "n5", check) + + shutil.rmtree(root, ignore_errors=True) + except Fail as exc: + fails.append(f"Tier B setup: {exc}") + + for f in fails: + print(f" FAIL: {f}") + total = ok + len(fails) + print(f"verify-delta (Tier B, full CLI): {ok}/{total} checks pass") + return 1 if fails else 0 + + +def _own014_case(dotnet: str, dll: str, root: str, work: str, check) -> None: + """A legal-but-out-of-scope OWN014 candidate must be refused ANALYSIS_SCOPE by the CLI.""" + from ownlang.fix_plan import validate_plan + cands = _candidates(dotnet, dll, root, work) + with open(cands, encoding="utf-8") as fh: + c = json.load(fh) + c["candidates"] = [c["candidates"][0]] + c["candidates"][0]["diagnostic_code"] = "OWN014" + c["candidates"][0]["event_contract"] = "name_only" + c["candidates"][0]["allowed_actions"] = ["manual_review"] + c["selection"]["selected_findings"] = None + cpath = os.path.join(work, "cand014.json") + with open(cpath, "w", encoding="utf-8") as fh: + json.dump(c, fh) + decisions = [{"finding_id": c["candidates"][0]["finding_id"], "action": "manual_review"}] + plan = os.path.join(work, "plan014.json") + with open(plan, "w", encoding="utf-8") as fh: + json.dump(validate_plan(c, {"version": 1, "decisions": decisions}), fh) + bundle, gate = _apply_and_gate(dotnet, cpath, plan, root, work, "014") + out = os.path.join(work, "delta014") + p = _verify(dotnet, dll, bundle, plan, cpath, root, gate, out) + check(p.returncode == 2 and "ANALYSIS_SCOPE" in p.stderr, + f"Tier B: OWN014 candidate -> ANALYSIS_SCOPE ({p.stderr[-160:]})") + + +_POST_NEW_OWN001 = _ONE_LEAK.replace( + "a.PropertyChanged += OnA;", + "WeakEvents.AddPropertyChanged(a, OnA); c.PropertyChanged += OnA;") +_POST_NEW_OWN050 = _ONE_LEAK.replace( + "public class S {", "public class S {\n private ExternalThing _ext;").replace( + "a.PropertyChanged += OnA;", + "WeakEvents.AddPropertyChanged(a, OnA); _ext.Changed += OnA;") + + +def _forged_refusal(dotnet: str, dll: str, work: str, post_text: str, expect: str, + tag: str, check) -> None: + """Build a forged-but-gate-valid bundle FROM SCRATCH whose postimage converts the + candidate AND introduces the anomaly (`post_text`). The gate binds it (it never + analyzes); verify-delta must refuse with `expect`.""" + from ownlang.fix_gate import _bundle_sha256 + lroot = _mkroot(_ONE_LEAK) + try: + cands = _candidates(dotnet, dll, lroot, work) + plan = _plan(cands, work, {"OnA"}) + with open(cands, encoding="utf-8") as fh: + c = json.load(fh) + fid = c["candidates"][0]["finding_id"] + pre_bytes = _ONE_LEAK.replace("\r\n", "\n").encode() + post_bytes = post_text.replace("\r\n", "\n").encode() + patch_bytes = _step8_patch(pre_bytes, post_bytes) + bundle = os.path.join(work, f"bundle-{tag}") + os.makedirs(os.path.join(bundle, "postimage", os.path.dirname(_REL))) + with open(os.path.join(bundle, "postimage", *_REL.split("/")), "wb") as fh: + fh.write(post_bytes) + with open(os.path.join(bundle, "change.patch"), "wb") as fh: + fh.write(patch_bytes) + with open(plan, "rb") as fh: + validated_plan_sha256 = _sha(fh.read()) + m = {"version": 1, "operation": "apply-subscription-fixes", + "input_bundle_sha256": _bundle_sha256(c), + "validated_plan_sha256": validated_plan_sha256, + "target_api": {"subscribe": "WeakEvents.AddPropertyChanged"}, + "source_files": [{"path": _REL, "pre_sha256": _sha(pre_bytes), + "post_sha256": _sha(post_bytes)}], + "patch_sha256": _sha(patch_bytes), "applied_findings": [fid], + "manual_review_findings": []} + with open(os.path.join(bundle, "apply-manifest.json"), "wb") as fh: + fh.write(json.dumps(m, sort_keys=True, separators=(",", ":"), + ensure_ascii=False).encode() + b"\n") + gate_out = os.path.join(work, f"gate-{tag}") + p = _py(["own-fix", "subscriptions", "gate", "--bundle", bundle, "--plan", plan, + "--candidates", cands, "--root", lroot, "--out", gate_out]) + if p.returncode != 0: + check(False, f"Tier B: {expect} forged bundle failed to gate ({p.stderr[-200:]})") + return + out = os.path.join(work, f"delta-{tag}") + p = _verify(dotnet, dll, bundle, plan, cands, lroot, + os.path.join(gate_out, "gate-result.json"), out) + check(p.returncode == 2 and expect in p.stderr, + f"Tier B: forged postimage -> {expect} ({p.stderr[-160:]})") + finally: + shutil.rmtree(lroot, ignore_errors=True) if __name__ == "__main__": From 3031ba0da8c784fd8eaefe9bc21ce2f11b8509a5 Mon Sep 17 00:00:00 2001 From: PhysShell Date: Fri, 17 Jul 2026 17:21:43 +0500 Subject: [PATCH 8/9] fix(s2-step10): a missing verify-delta input is a controlled refusal, not INFRASTRUCTURE The reused fix_gate snapshot/publish helpers raise GateError (which carries the same stable .category as DeltaError). The verify-delta CLI only caught DeltaError, so a missing/symlinked input file leaked its GateError to the generic handler and was reported as INFRASTRUCTURE instead of its real category (e.g. INPUT_LAYOUT). The CLI now catches (DeltaError, GateError) and prints exc.category. Regression: a verify-delta invocation with missing inputs exits 2 with a controlled refusal category, never INFRASTRUCTURE. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G --- ownlang/__main__.py | 3 ++- tests/test_verify_delta.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/ownlang/__main__.py b/ownlang/__main__.py index 69be5d52..3e58250d 100644 --- a/ownlang/__main__.py +++ b/ownlang/__main__.py @@ -638,6 +638,7 @@ def _cmd_verify_delta(rest: list[str]) -> int: postimage and proves the OWN001 delta matches the plan (converted gone, manual preserved, no new OWN001 of any lane, no new OWN050). --gate is mandatory; there is no --config.""" from ownlang.fix_delta import DeltaError, run_verify_delta + from ownlang.fix_gate import GateError # the reused snapshot/publish helpers raise this flags = {"--bundle", "--plan", "--candidates", "--root", "--gate", "--extractor-dll", "--out"} parsed = _own_fix_parse(rest, flags, {"--ref-dir"}) @@ -660,7 +661,7 @@ def _cmd_verify_delta(rest: list[str]) -> int: published = run_verify_delta( opts["--bundle"], opts["--plan"], opts["--candidates"], opts["--root"], opts["--gate"], opts["--extractor-dll"], opts["--out"], opts.get("--ref-dir") or []) - except DeltaError as exc: + except (DeltaError, GateError) as exc: # both carry a stable .category print(f"own-fix: refuse: {exc.category}: {exc}", file=sys.stderr) return 2 except Exception as exc: # fail closed: any surprise is a refusal, not a traceback diff --git a/tests/test_verify_delta.py b/tests/test_verify_delta.py index 050d0f87..64fbfcd7 100644 --- a/tests/test_verify_delta.py +++ b/tests/test_verify_delta.py @@ -644,6 +644,23 @@ def tam(mut) -> bytes: check(_raises(fd.GATE_BINDING, bg, json.dumps(good, indent=2).encode("utf-8") + b"\n"), "gate: non-canonical bytes -> GATE_BINDING") + # a missing CLI input must be a CONTROLLED refusal (its real category), never a leaked + # GateError surfacing as INFRASTRUCTURE (the reused fix_gate helpers raise GateError). + import contextlib + import io + + from ownlang.__main__ import cmd_own_fix + with tempfile.TemporaryDirectory() as tmp: + miss = os.path.join(tmp, "nope") + err = io.StringIO() + with contextlib.redirect_stderr(err): + rc = cmd_own_fix(["subscriptions", "verify-delta", "--bundle", miss, "--plan", miss, + "--candidates", miss, "--root", miss, "--gate", miss, + "--extractor-dll", miss, "--out", os.path.join(tmp, "out")]) + text = err.getvalue() + check(rc == 2 and "own-fix: refuse:" in text and "INFRASTRUCTURE" not in text, + f"CLI: missing input -> controlled refusal, not INFRASTRUCTURE ({text.strip()[:80]})") + # --- slice 6: reference-closure snapshot + evidence assembly + bundle layout with tempfile.TemporaryDirectory() as tmp: work = os.path.join(tmp, "w") From a8ad6369e3f03ffcb8e90fad4e2a72fe08b17662 Mon Sep 17 00:00:00 2001 From: PhysShell Date: Fri, 17 Jul 2026 17:52:07 +0500 Subject: [PATCH 9/9] =?UTF-8?q?fix(s2-step10):=20final=20cleanup=20C1-C3?= =?UTF-8?q?=20=E2=80=94=20core=20HOME=20redirect,=20deep=20teardown=20vali?= =?UTF-8?q?dation,=20Tier=20B=20asserts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C1: the fresh core subprocess no longer inherits the caller's HOME. _core_env now takes the work root and sets HOME=WORK/home, XDG_CACHE_HOME=WORK/home/.cache, and TMPDIR/TEMP/TMP=IMAGE_WORKSPACE (the workspace-local home/.cache are created before launch); host HOME is not copied. Keeps python -S -B -E. Regression: the child sees the workspace-local HOME, host HOME is absent, and the redirected home/cache mean a core run leaves no artifact under the real user home. C2: _validate_record now fully closes the nested teardown protocol — exact {status, candidates}; status in {none, exact, ambiguous}; each candidate an exact {source, handler, match, span} object with string source/handler/match and the frozen six-int span (no booleans). Malformed nested values refuse the per-image BASELINE_ANALYSIS / POSTIMAGE_ANALYSIS. Regressions for non-object, missing key, extra key, non-string, malformed span, and boolean span value. C3: the full public-CLI Tier B now parses each published delta-result.json and asserts case-specific semantics for mixed / all-convert / manual-only (convert & manual counts, removed==convert, preserved==manual, removed_all_own001 count, postimage subscription ids, baseline==postimage for manual-only, git_gates_status, and vacuous idempotence), retaining the schema / determinism / OWN014 / NEW_OWN001 / NEW_OWN050 checks. Frozen Steps 8/9 untouched. verify-delta 104/104; Tier B 28/28; Step 8 79/79; Step 9 69/69; harness 25/25; ruff + mypy clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JxKjqdGEFzq4UzZupw379G --- ownlang/fix_delta.py | 35 +++++++++++++++++----- tests/test_verify_delta.py | 50 ++++++++++++++++++++++++++++++++ tests/test_verify_delta_tierb.py | 39 +++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 7 deletions(-) diff --git a/ownlang/fix_delta.py b/ownlang/fix_delta.py index 736c29d5..1df8537a 100644 --- a/ownlang/fix_delta.py +++ b/ownlang/fix_delta.py @@ -248,8 +248,23 @@ def _validate_record(rec: Any, cat: str, where: str) -> dict[str, Any]: td = rec["teardown"] if not isinstance(td, dict) or set(td) != {"status", "candidates"}: raise DeltaError(cat, f"{where}.teardown is not {{status, candidates}}") - if td["status"] not in ("none", "exact", "ambiguous") or not isinstance(td["candidates"], list): - raise DeltaError(cat, f"{where}.teardown has a bad status/candidates") + if td["status"] not in ("none", "exact", "ambiguous"): + raise DeltaError(cat, f"{where}.teardown.status is unknown") + if not isinstance(td["candidates"], list): + raise DeltaError(cat, f"{where}.teardown.candidates must be a list") + for i, tc in enumerate(td["candidates"]): # C2: the closed nested teardown-candidate shape + tctx = f"{where}.teardown.candidates[{i}]" + if not isinstance(tc, dict) or set(tc) != {"source", "handler", "match", "span"}: + raise DeltaError(cat, f"{tctx} is not {{source, handler, match, span}}") + for k in ("source", "handler", "match"): + if not isinstance(tc[k], str): + raise DeltaError(cat, f"{tctx}.{k} must be a string") + tsp = tc["span"] + if not isinstance(tsp, dict) or set(tsp) != set(_SPAN_KEYS): + raise DeltaError(cat, f"{tctx}.span is not the frozen six-key span") + for k in _SPAN_KEYS: + if not isinstance(tsp[k], int) or isinstance(tsp[k], bool): + raise DeltaError(cat, f"{tctx}.span.{k} must be an int") actions = rec["allowed_actions"] if not isinstance(actions, list) or not actions \ or any(a not in _ACTIONS for a in actions) or len(set(actions)) != len(actions): @@ -771,14 +786,18 @@ def resolve_python() -> tuple[str, dict[str, Any]]: } -def _core_env(image_dir: str) -> dict[str, str]: +def _core_env(work: str, image_dir: str) -> dict[str, str]: """A minimal environment for the core subprocess. `-E` ignores every PYTHON* variable; - only the host bits the interpreter needs to start are forwarded, and the caches / temp - are redirected into the image workspace.""" + only the host bits the interpreter needs to start are forwarded. The caller's HOME is NOT + inherited (C1): HOME / XDG_CACHE_HOME are redirected under the work root and temp under the + image workspace, so a core run leaves no artifact under the real user home.""" env: dict[str, str] = {} - for k in ("SystemRoot", "SYSTEMROOT", "windir", "PATH", "HOME", "LANG", "LC_ALL"): + for k in ("SystemRoot", "SYSTEMROOT", "windir", "PATH", "LANG", "LC_ALL"): if k in os.environ: env[k] = os.environ[k] + home = os.path.join(work, "home") + env["HOME"] = home + env["XDG_CACHE_HOME"] = os.path.join(home, ".cache") env["TMPDIR"] = image_dir env["TEMP"] = image_dir env["TMP"] = image_dir @@ -793,6 +812,8 @@ def run_core(core_dir: str, runner_path: str, python_exe: str, core_runner_sha25 escape (exit 3) is TOOLCHAIN_BINDING; any other failure or a malformed core.json is the per-image analysis category `cat`.""" _verify_runner(runner_path, core_runner_sha256) + work = os.path.dirname(core_dir) # materialize_core put core at WORK/core + os.makedirs(os.path.join(work, "home", ".cache"), exist_ok=True) facts_path = os.path.join(image_dir, "facts.json") core_path = os.path.join(image_dir, "core.json") params_path = os.path.join(image_dir, "params.json") @@ -802,7 +823,7 @@ def run_core(core_dir: str, runner_path: str, python_exe: str, core_runner_sha25 fh.write(_canonical_json(params)) proc = subprocess.run( [python_exe, "-S", "-B", "-E", runner_path, facts_path, core_path, params_path], - cwd=core_dir, env=_core_env(image_dir), capture_output=True, text=True, check=False) + cwd=core_dir, env=_core_env(work, image_dir), capture_output=True, text=True, check=False) if proc.returncode == 3: raise DeltaError(TOOLCHAIN_BINDING, f"core runner self-fingerprint failed: {proc.stderr.strip()[:300]}") diff --git a/tests/test_verify_delta.py b/tests/test_verify_delta.py index 64fbfcd7..0e40a8c2 100644 --- a/tests/test_verify_delta.py +++ b/tests/test_verify_delta.py @@ -17,6 +17,7 @@ import hashlib import json import os +import subprocess import sys import tempfile @@ -427,6 +428,55 @@ def _boom(*_a, **_k): cf(_raises(fd.ISOLATION, fd._isolation_verify, snap, tmp, rel), "R2: .git/index mutation -> ISOLATION") + # --- C1: core subprocess HOME / cache redirected under the work root --------- + with tempfile.TemporaryDirectory() as work: + img = os.path.join(work, "img") + os.makedirs(img) + env = fd._core_env(work, img) + cf(env["HOME"] == os.path.join(work, "home"), "C1: HOME redirected to work/home") + cf(env["XDG_CACHE_HOME"] == os.path.join(work, "home", ".cache"), + "C1: XDG_CACHE_HOME under work/home") + cf(env["TMPDIR"] == img and env["TEMP"] == img and env["TMP"] == img, + "C1: temp under the image workspace") + cf(env["HOME"] != os.environ.get("HOME"), "C1: host HOME not copied into the core env") + os.makedirs(os.path.join(work, "home", ".cache"), exist_ok=True) + p = subprocess.run([sys.executable, "-S", "-B", "-E", "-c", + "import os,sys;sys.stdout.write(os.environ.get('HOME',''))"], + env=env, capture_output=True, text=True, check=False) + cf(p.stdout.strip() == os.path.join(work, "home"), "C1: runner sees workspace-local HOME") + cf("" != p.stdout.strip(), "C1: HOME is present (workspace-local) in the child") + + # --- C2: deep teardown-candidate validation --------------------------------- + good_tc = {"source": "a", "handler": "OnA", "match": "a.PropertyChanged", "span": dict(_SPAN)} + + def _rec_td(td: dict) -> dict: + r = _record(_FIDA) + r["teardown"] = td + return r + + fd._validate_record(_rec_td({"status": "exact", "candidates": [good_tc]}), + fd.BASELINE_ANALYSIS, "rec") + cf(True, "C2: a valid teardown candidate is accepted") + + def _bad_td(td: dict) -> bool: + return _raises(fd.BASELINE_ANALYSIS, fd._validate_record, _rec_td(td), + fd.BASELINE_ANALYSIS, "rec") + + cf(_bad_td({"status": "exact", "candidates": ["nope"]}), + "C2: teardown candidate not an object -> refuse") + cf(_bad_td({"status": "exact", + "candidates": [{k: good_tc[k] for k in ("handler", "match", "span")}]}), + "C2: missing source -> refuse") + cf(_bad_td({"status": "exact", "candidates": [{**good_tc, "extra": 1}]}), + "C2: extra key -> refuse") + cf(_bad_td({"status": "exact", "candidates": [{**good_tc, "source": 5}]}), + "C2: non-string source -> refuse") + cf(_bad_td({"status": "exact", "candidates": [{**good_tc, "span": {"start": 1}}]}), + "C2: malformed span -> refuse") + cf(_bad_td({"status": "exact", + "candidates": [{**good_tc, "span": {**_SPAN, "start": True}}]}), + "C2: boolean span value -> refuse") + return checks, fails diff --git a/tests/test_verify_delta_tierb.py b/tests/test_verify_delta_tierb.py index 15eb3c84..89eeeae2 100644 --- a/tests/test_verify_delta_tierb.py +++ b/tests/test_verify_delta_tierb.py @@ -216,6 +216,43 @@ def _schema_ok(path: str) -> None: raise Fail("resolved_runtime_identity missing requested/selected versions") +def _assert_case(obj: dict, tag: str, check) -> None: + """Case-specific semantics of the published delta-result.json (C3).""" + d = obj["delta"] + conv = obj["expected"]["convert_acquire_ids"] + man = obj["expected"]["manual_review_ids"] + git = obj["gate_binding"]["git_gates_status"] + if tag == "mixed": + check(len(conv) == 1 and len(man) == 1, "Tier B mixed: 1 convert + 1 manual") + check(d["removed_subscription_own001_ids"] == conv, "Tier B mixed: removed == convert") + check(d["preserved_subscription_own001_ids"] == man, "Tier B mixed: preserved == manual") + check(len(d["removed_all_own001"]) == 1, "Tier B mixed: exactly one core removed") + check(git == "pass", "Tier B mixed: git_gates_status pass") + elif tag == "allconv": + check(len(conv) == 2 and man == [], "Tier B all-convert: 2 convert + 0 manual") + check(d["removed_subscription_own001_ids"] == conv, + "Tier B all-convert: removed == convert") + check(d["preserved_subscription_own001_ids"] == [], "Tier B all-convert: preserved empty") + check(obj["postimage"]["subscription_own001_ids"] == [], + "Tier B all-convert: postimage subscription ids empty") + check(len(d["removed_all_own001"]) == 2, "Tier B all-convert: two core removed") + check(git == "pass", "Tier B all-convert: git_gates_status pass") + else: # manual-only + check(conv == [] and len(man) == 2, "Tier B manual-only: 0 convert + 2 manual") + check(d["removed_subscription_own001_ids"] == [], "Tier B manual-only: removed empty") + check(d["preserved_subscription_own001_ids"] == man, + "Tier B manual-only: preserved == manual") + check(d["removed_all_own001"] == [], "Tier B manual-only: nothing removed") + b, pi = obj["baseline"], obj["postimage"] + check(b["subscription_own001_ids"] == pi["subscription_own001_ids"] + and b["all_own001"] == pi["all_own001"], + "Tier B manual-only: baseline == postimage") + check(git == "not_applicable", "Tier B manual-only: git_gates_status not_applicable") + si = obj["semantic_idempotence"] + check(si["pass"] is True and si["converted_ids_still_actionable"] == [], + "Tier B manual-only: idempotence passes vacuously") + + def run() -> int: required = os.environ.get("OWN_TIERB_REQUIRED") == "1" if not required: @@ -254,6 +291,8 @@ def check(cond: bool, label: str) -> None: if p.returncode == 0: _schema_ok(os.path.join(out, "delta-result.json")) check(True, f"Tier B: {tag} published schema valid") + with open(os.path.join(out, "delta-result.json"), encoding="utf-8") as fh: + _assert_case(json.load(fh), tag, check) # determinism: two independent invocations of the mixed case -> identical bytes plan = _plan(cands, work, {"OnA"})